From a8f36ddbbaac76bc672abe10dde6edda05233237 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 12 May 2026 16:22:00 +0300 Subject: [PATCH] fix: improve standup chat auto-scroll --- .../liveRooms/LiveRoomChatPanel.spec.tsx | 250 ++++++++++++++++++ .../liveRooms/LiveRoomChatPanel.tsx | 119 ++++++++- 2 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx diff --git a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx new file mode 100644 index 0000000000..24cb3db31d --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.spec.tsx @@ -0,0 +1,250 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import type { LiveRoomChatEntry } from '../../contexts/LiveRoomContext'; +import type { UserShortProfile } from '../../lib/user'; +import { LiveRoomChatPanel } from './LiveRoomChatPanel'; + +jest.mock('../Markdown', () => ({ + __esModule: true, + default: ({ content }: { content: string }) => {content}, +})); + +jest.mock('../ProfilePicture', () => ({ + ProfilePicture: () =>
avatar
, + ProfileImageSize: { Small: 'small' }, +})); + +jest.mock('../tooltips/Portal', () => ({ + RootPortal: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('../drawers', () => ({ + Drawer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock('../fields/EmojiPicker', () => ({ + EmojiPicker: () => null, +})); + +jest.mock('./LiveRoomChatReactions', () => ({ + LiveRoomChatReactions: () => null, + getChatReactionGroups: () => [], +})); + +jest.mock('../../hooks', () => { + const actual = jest.requireActual('../../hooks'); + return { + ...actual, + useViewSize: () => true, + }; +}); + +jest.mock('../../hooks/useTouchLongPress', () => ({ + useTouchLongPress: () => ({ + onTouchStart: jest.fn(), + onTouchEnd: jest.fn(), + onTouchMove: jest.fn(), + onTouchCancel: jest.fn(), + }), +})); + +const createMessage = (messageId: string, body: string): LiveRoomChatEntry => ({ + messageId, + participantId: 'participant-1', + body, + createdAt: '2026-05-12T10:00:00.000Z', + reactions: [], +}); + +const participantProfile: UserShortProfile = { + id: 'participant-1', + name: 'Participant', + username: 'participant', + image: '', + createdAt: '2026-05-12T10:00:00.000Z', + reputation: 0, + permalink: '/participant', +}; + +const defaultProps = { + chatMessages: [createMessage('message-1', 'First message')], + participantProfilesById: new Map([['participant-1', participantProfile]]), + mentionSuggestions: [], + participantChatPermissions: {}, + currentParticipantId: 'participant-1', + hostParticipantId: 'host-1', + coHostParticipantIds: [], + canChat: false, + isLive: true, + isEnded: false, + isLoggedIn: true, + hasHostPrivileges: false, + onSendMessage: jest.fn(), + onDeleteMessage: jest.fn(), + onSendMessageReaction: jest.fn(), + onRemoveMessageReaction: jest.fn(), + onKickParticipant: jest.fn(), + onSetParticipantChatEnabled: jest.fn(), + onRequestLogin: jest.fn(), +}; + +type ScrollMetrics = { + scrollHeight: number; + clientHeight: number; + scrollTop: number; +}; + +const setScrollMetrics = ( + element: HTMLDivElement, + initialMetrics: ScrollMetrics, +): ScrollMetrics => { + const metrics = initialMetrics; + + Object.defineProperty(element, 'scrollHeight', { + configurable: true, + get: () => metrics.scrollHeight, + }); + Object.defineProperty(element, 'clientHeight', { + configurable: true, + get: () => metrics.clientHeight, + }); + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => metrics.scrollTop, + set: (value: number) => { + metrics.scrollTop = value; + }, + }); + + return metrics; +}; + +describe('LiveRoomChatPanel', () => { + let rafCallbacks: Map; + let rafId = 0; + let originalRequestAnimationFrame: typeof window.requestAnimationFrame; + let originalCancelAnimationFrame: typeof window.cancelAnimationFrame; + + const flushAnimationFrames = (): void => { + let attempts = 0; + while (rafCallbacks.size > 0 && attempts < 10) { + const callbacks = [...rafCallbacks.values()]; + rafCallbacks.clear(); + callbacks.forEach((callback) => callback(0)); + attempts += 1; + } + }; + + beforeEach(() => { + jest.useFakeTimers(); + rafCallbacks = new Map(); + rafId = 0; + originalRequestAnimationFrame = window.requestAnimationFrame; + originalCancelAnimationFrame = window.cancelAnimationFrame; + + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + rafId += 1; + rafCallbacks.set(rafId, callback); + return rafId; + }) as typeof window.requestAnimationFrame; + + window.cancelAnimationFrame = ((id: number) => { + rafCallbacks.delete(id); + }) as typeof window.cancelAnimationFrame; + }); + + afterEach(() => { + act(() => { + flushAnimationFrames(); + jest.runOnlyPendingTimers(); + }); + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + jest.useRealTimers(); + }); + + it('scrolls to the full height of a newly added long message', () => { + const { rerender } = render(); + + const scrollContainer = screen.getByTestId( + 'live-room-chat-scroll', + ) as HTMLDivElement; + const metrics = setScrollMetrics(scrollContainer, { + scrollHeight: 120, + clientHeight: 80, + scrollTop: 40, + }); + + act(() => { + flushAnimationFrames(); + }); + + metrics.scrollHeight = 320; + + rerender( + , + ); + + act(() => { + flushAnimationFrames(); + }); + + expect(metrics.scrollTop).toBe(320); + }); + + it('waits until the user stops scrolling before auto-scrolling new messages', () => { + const { rerender } = render(); + + const scrollContainer = screen.getByTestId( + 'live-room-chat-scroll', + ) as HTMLDivElement; + const metrics = setScrollMetrics(scrollContainer, { + scrollHeight: 160, + clientHeight: 100, + scrollTop: 60, + }); + + act(() => { + flushAnimationFrames(); + }); + + metrics.scrollTop = 60; + fireEvent.scroll(scrollContainer); + + metrics.scrollHeight = 240; + + rerender( + , + ); + + act(() => { + flushAnimationFrames(); + }); + + expect(metrics.scrollTop).toBe(60); + + act(() => { + jest.advanceTimersByTime(150); + flushAnimationFrames(); + }); + + expect(metrics.scrollTop).toBe(240); + }); +}); diff --git a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx index 2ff0a3c4d0..1509e8102e 100644 --- a/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx +++ b/packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { DropdownMenu, @@ -213,6 +213,14 @@ interface LiveRoomChatPanelProps { onRequestLogin: () => void; } +const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 32; +const CHAT_AUTO_SCROLL_IDLE_MS = 150; + +const getDistanceFromBottom = (scrollContainer: HTMLDivElement): number => + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + export const LiveRoomChatPanel = ({ chatMessages, participantProfilesById, @@ -236,6 +244,12 @@ export const LiveRoomChatPanel = ({ }: LiveRoomChatPanelProps): ReactElement => { const scrollRef = useRef(null); const shouldAutoScrollRef = useRef(true); + const isUserScrollingRef = useRef(false); + const isProgrammaticScrollRef = useRef(false); + const pendingAutoScrollRef = useRef(false); + const autoScrollFrameRef = useRef(null); + const releaseProgrammaticScrollRef = useRef(null); + const scrollIdleTimeoutRef = useRef(null); const [moderationBusy, setModerationBusy] = useState(null); const [reactionBusyKeys, setReactionBusyKeys] = useState([]); const [openMenuId, setOpenMenuId] = useState(null); @@ -276,14 +290,84 @@ export const LiveRoomChatPanel = ({ } }, [pickerMessage, pickerMessageId]); - useEffect(() => { + const clearScheduledAutoScroll = useCallback((): void => { + if (autoScrollFrameRef.current !== null) { + cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = null; + } + }, []); + + const scrollToBottom = useCallback((): void => { const scrollContainer = scrollRef.current; - if (!scrollContainer || !shouldAutoScrollRef.current) { + if (!scrollContainer) { return; } + if (releaseProgrammaticScrollRef.current !== null) { + cancelAnimationFrame(releaseProgrammaticScrollRef.current); + } + + isProgrammaticScrollRef.current = true; scrollContainer.scrollTop = scrollContainer.scrollHeight; - }, [chatMessages]); + shouldAutoScrollRef.current = true; + pendingAutoScrollRef.current = false; + + releaseProgrammaticScrollRef.current = requestAnimationFrame(() => { + isProgrammaticScrollRef.current = false; + releaseProgrammaticScrollRef.current = null; + }); + }, []); + + const scheduleAutoScroll = useCallback((): void => { + if (!shouldAutoScrollRef.current) { + pendingAutoScrollRef.current = false; + return; + } + + if (isUserScrollingRef.current) { + pendingAutoScrollRef.current = true; + return; + } + + clearScheduledAutoScroll(); + autoScrollFrameRef.current = requestAnimationFrame(() => { + autoScrollFrameRef.current = requestAnimationFrame(() => { + autoScrollFrameRef.current = null; + if (!shouldAutoScrollRef.current || isUserScrollingRef.current) { + pendingAutoScrollRef.current = shouldAutoScrollRef.current; + return; + } + + scrollToBottom(); + }); + }); + }, [clearScheduledAutoScroll, scrollToBottom]); + + const finishUserScroll = useCallback((): void => { + isUserScrollingRef.current = false; + scrollIdleTimeoutRef.current = null; + + if (pendingAutoScrollRef.current && shouldAutoScrollRef.current) { + scheduleAutoScroll(); + } + }, [scheduleAutoScroll]); + + useEffect(() => { + scheduleAutoScroll(); + }, [chatMessages, scheduleAutoScroll]); + + useEffect( + () => () => { + clearScheduledAutoScroll(); + if (releaseProgrammaticScrollRef.current !== null) { + cancelAnimationFrame(releaseProgrammaticScrollRef.current); + } + if (scrollIdleTimeoutRef.current !== null) { + clearTimeout(scrollIdleTimeoutRef.current); + } + }, + [clearScheduledAutoScroll], + ); const handleScroll = (): void => { const scrollContainer = scrollRef.current; @@ -291,11 +375,27 @@ export const LiveRoomChatPanel = ({ return; } - const distanceFromBottom = - scrollContainer.scrollHeight - - scrollContainer.scrollTop - - scrollContainer.clientHeight; - shouldAutoScrollRef.current = distanceFromBottom < 32; + shouldAutoScrollRef.current = + getDistanceFromBottom(scrollContainer) < + CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + + if (!shouldAutoScrollRef.current) { + pendingAutoScrollRef.current = false; + } + + if (isProgrammaticScrollRef.current) { + return; + } + + isUserScrollingRef.current = true; + if (scrollIdleTimeoutRef.current !== null) { + clearTimeout(scrollIdleTimeoutRef.current); + } + + scrollIdleTimeoutRef.current = window.setTimeout( + finishUserScroll, + CHAT_AUTO_SCROLL_IDLE_MS, + ); }; const runModerationAction = ( @@ -358,6 +458,7 @@ export const LiveRoomChatPanel = ({
{chatMessages.length === 0 ? (