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 ? (