mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
More robust/intent based chat scroll to bottom logic
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user