More robust/intent based chat scroll to bottom logic
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-07-08 19:52:38 +03:00
parent dab72d7142
commit fdbfb14f62
3 changed files with 240 additions and 32 deletions

View File

@@ -5,62 +5,135 @@ import { useChatContext } from "./useChatContext";
// increasing this = scrolling happens even when scrolled more upwards
const THRESHOLD = 100;
// how long after wheel/touch/keyboard input a scroll event still counts as user-initiated
const USER_SCROLL_INTENT_MS = 150;
export function useChatAutoScroll(
messages: ChatMessage[],
ref: React.RefObject<HTMLElement | null>,
) {
const user = useUser();
const [firstLoadHandled, setFirstLoadHandled] = React.useState(false);
const [unseenMessages, setUnseenMessages] = React.useState(false);
const pinnedToBottomRef = React.useRef(true);
const lastUserScrollIntentRef = React.useRef(Number.NEGATIVE_INFINITY);
const isPointerDownRef = React.useRef(false);
const lastStableScrollTopRef = React.useRef(0);
const scrollToBottom = React.useCallback(() => {
const messagesContainer = ref.current;
if (!messagesContainer) return;
pinnedToBottomRef.current = true;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
setUnseenMessages(false);
}, [ref]);
React.useEffect(() => {
const messagesContainer = ref.current!;
const isScrolledToBottom =
Math.abs(
messagesContainer.scrollHeight -
messagesContainer.clientHeight -
messagesContainer.scrollTop,
) <= THRESHOLD;
const latestMessageIsOwn =
messages[messages.length - 1]?.userId === user?.id;
// lets wait for messages to load first
if (!firstLoadHandled && messages.length === 0) return;
if (isScrolledToBottom || latestMessageIsOwn || !firstLoadHandled) {
setFirstLoadHandled(true);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
} else if (!isScrolledToBottom) {
setUnseenMessages(true);
function markUserScrollIntent() {
lastUserScrollIntentRef.current = performance.now();
}
function handlePointerDown() {
isPointerDownRef.current = true;
}
function handlePointerUp() {
isPointerDownRef.current = false;
}
}, [messages, ref, user, firstLoadHandled]);
React.useEffect(() => {
const messagesContainer = ref.current!;
function handleScroll() {
if (
const isUserScroll =
isPointerDownRef.current ||
performance.now() - lastUserScrollIntentRef.current <
USER_SCROLL_INTENT_MS;
const isScrolledToBottom =
messagesContainer.scrollTop + messagesContainer.clientHeight >=
messagesContainer.scrollHeight - THRESHOLD
) {
messagesContainer.scrollHeight - THRESHOLD;
// react-aria's Virtualizer resets the scroll position to the top
// whenever the message collection changes; undo those resets so
// they neither unpin the auto scroll nor yank the user out of the
// history they were reading
if (!isUserScroll) {
if (pinnedToBottomRef.current && !isScrolledToBottom) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return;
}
if (
!pinnedToBottomRef.current &&
messagesContainer.scrollTop === 0 &&
lastStableScrollTopRef.current > 0
) {
messagesContainer.scrollTop = lastStableScrollTopRef.current;
return;
}
}
pinnedToBottomRef.current = isScrolledToBottom;
lastStableScrollTopRef.current = messagesContainer.scrollTop;
if (isScrolledToBottom) {
setUnseenMessages(false);
}
}
messagesContainer.addEventListener("wheel", markUserScrollIntent, {
passive: true,
});
messagesContainer.addEventListener("touchmove", markUserScrollIntent, {
passive: true,
});
messagesContainer.addEventListener("keydown", markUserScrollIntent);
messagesContainer.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointerup", handlePointerUp);
messagesContainer.addEventListener("scroll", handleScroll);
return () => {
messagesContainer.removeEventListener("wheel", markUserScrollIntent);
messagesContainer.removeEventListener("touchmove", markUserScrollIntent);
messagesContainer.removeEventListener("keydown", markUserScrollIntent);
messagesContainer.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointerup", handlePointerUp);
messagesContainer.removeEventListener("scroll", handleScroll);
};
}, [ref]);
const scrollToBottom = () => {
ref.current!.scrollTop = ref.current!.scrollHeight;
};
const hasMessages = messages.length > 0;
// the virtualizer resizes the scrollable content asynchronously as it
// measures rows, without scroll events firing, so keep the view glued to
// the bottom whenever the content height changes while pinned there
React.useEffect(() => {
if (!hasMessages) return;
const messagesContainer = ref.current!;
const scrollContent = messagesContainer.firstElementChild;
if (!scrollContent) return;
const observer = new ResizeObserver(() => {
if (pinnedToBottomRef.current) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
observer.observe(scrollContent);
return () => observer.disconnect();
}, [ref, hasMessages]);
React.useEffect(() => {
if (messages.length === 0) return;
const latestMessageIsOwn =
user != null && messages[messages.length - 1]?.userId === user.id;
if (latestMessageIsOwn || pinnedToBottomRef.current) {
scrollToBottom();
} else {
setUnseenMessages(true);
}
}, [messages, user, scrollToBottom]);
const reset = () => {
setFirstLoadHandled(false);
pinnedToBottomRef.current = true;
setUnseenMessages(false);
};

View File

@@ -1,3 +1,4 @@
import * as React from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
@@ -65,6 +66,48 @@ function renderChat(
return render(<RouterProvider router={router} />);
}
async function renderChatWithControls(initialMessages: ChatMessage[]) {
const controls = {
addMessage: (_msg: ChatMessage) => {},
};
function ChatHarness() {
const [messages, setMessages] = React.useState(initialMessages);
controls.addMessage = (msg) => setMessages((prev) => [...prev, msg]);
const chat: ChatAdapter = {
messages,
send: () => {},
currentRoom: "room",
setCurrentRoom: () => {},
readyState: "CONNECTED",
unseenMessages: new Map(),
};
return (
<div style={{ width: 400 }}>
<Chat users={USERS} rooms={[]} chat={chat} />
</div>
);
}
const router = createMemoryRouter([{ path: "/", element: <ChatHarness /> }], {
initialEntries: ["/"],
});
return { screen: await render(<RouterProvider router={router} />), controls };
}
function manyMessages(count: number) {
return Array.from({ length: count }, (_, i) =>
createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }),
);
}
function isScrolledToBottom(element: HTMLElement) {
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
}
describe("Chat", () => {
test("renders messages inside a virtualized listbox", async () => {
const screen = await renderChat([
@@ -138,4 +181,95 @@ describe("Chat", () => {
await expect.element(screen.getByText("Ghost message")).toBeInTheDocument();
await expect.element(screen.getByText("Unknown")).toBeInTheDocument();
});
test("scrolls to the bottom on initial load", async () => {
const { screen } = await renderChatWithControls(manyMessages(50));
const listbox = screen.getByRole("listbox");
await expect.element(listbox).toBeInTheDocument();
await vi.waitFor(() => {
const element = listbox.element() as HTMLElement;
expect(element.scrollHeight).toBeGreaterThan(element.clientHeight);
expect(isScrolledToBottom(element)).toBe(true);
});
});
test("auto scrolls when a new message arrives while at the bottom", async () => {
const { screen, controls } = await renderChatWithControls(manyMessages(50));
const listbox = screen.getByRole("listbox");
await vi.waitFor(() => {
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
});
controls.addMessage(
createMessage({
id: "new",
contents:
"A brand new message that is long enough to wrap onto multiple lines in the chat window",
}),
);
await expect
.element(screen.getByText(/A brand new message/))
.toBeInTheDocument();
await vi.waitFor(() => {
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
});
});
test("does not auto scroll when scrolled up, shows the new messages button instead", async () => {
const { screen, controls } = await renderChatWithControls(manyMessages(50));
const listbox = screen.getByRole("listbox");
await vi.waitFor(() => {
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
});
const element = listbox.element() as HTMLElement;
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
await vi.waitFor(() => {
expect(element.scrollTop).toBe(0);
});
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
expect(isScrolledToBottom(element)).toBe(false);
await screen.getByText("New messages").click();
await vi.waitFor(() => {
expect(isScrolledToBottom(element)).toBe(true);
});
await expect
.element(screen.getByText("New messages"))
.not.toBeInTheDocument();
});
test("keeps the reading position when a new message arrives while scrolled up", async () => {
const { screen, controls } = await renderChatWithControls(manyMessages(50));
const listbox = screen.getByRole("listbox");
await vi.waitFor(() => {
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
});
const element = listbox.element() as HTMLElement;
const readingPosition = Math.floor(element.scrollHeight / 2);
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
element.scrollTop = readingPosition;
element.dispatchEvent(new Event("scroll"));
await new Promise((resolve) => setTimeout(resolve, 300));
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
await new Promise((resolve) => setTimeout(resolve, 300));
expect(element.scrollTop).toBe(readingPosition);
});
});

View File

@@ -22,6 +22,10 @@ import styles from "./Chat.module.css";
const MESSAGE_GAP = 8;
const ESTIMATED_MESSAGE_HEIGHT = 44;
const VIRTUALIZER_LAYOUT_OPTIONS = {
gap: MESSAGE_GAP,
estimatedRowSize: ESTIMATED_MESSAGE_HEIGHT,
};
export interface ChatAdapter {
messages: ChatMessage[];
@@ -165,10 +169,7 @@ export function Chat({
<div className={styles.inputContainer}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
gap: MESSAGE_GAP,
estimatedRowSize: ESTIMATED_MESSAGE_HEIGHT,
}}
layoutOptions={VIRTUALIZER_LAYOUT_OPTIONS}
>
<ListBox
ref={messagesContainerRef}