From 0d59dcd73452654080337073c192580f8954419e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:17:12 +0300 Subject: [PATCH] Virtualize chat (#3196) --- app/components/layout/ChatSidebar.module.css | 5 +- app/features/chat/chat-hooks.ts | 2 +- .../chat/components/Chat.browser.test.tsx | 141 ++++++++++++++++++ app/features/chat/components/Chat.module.css | 9 +- app/features/chat/components/Chat.tsx | 90 ++++++----- 5 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 app/features/chat/components/Chat.browser.test.tsx diff --git a/app/components/layout/ChatSidebar.module.css b/app/components/layout/ChatSidebar.module.css index 64fe34c95..c4471dbd9 100644 --- a/app/components/layout/ChatSidebar.module.css +++ b/app/components/layout/ChatSidebar.module.css @@ -209,10 +209,13 @@ } /* messages: no top padding, fill available space */ - & ol { + & [role="listbox"] { padding-top: 0; flex: 1; height: auto; + } + + & [role="option"] { padding-inline: var(--s-2); } diff --git a/app/features/chat/chat-hooks.ts b/app/features/chat/chat-hooks.ts index 57d3ecebd..24d98d57a 100644 --- a/app/features/chat/chat-hooks.ts +++ b/app/features/chat/chat-hooks.ts @@ -8,7 +8,7 @@ const THRESHOLD = 100; export function useChatAutoScroll( messages: ChatMessage[], - ref: React.RefObject, + ref: React.RefObject, ) { const user = useUser(); const [firstLoadHandled, setFirstLoadHandled] = React.useState(false); diff --git a/app/features/chat/components/Chat.browser.test.tsx b/app/features/chat/components/Chat.browser.test.tsx new file mode 100644 index 000000000..f077e1636 --- /dev/null +++ b/app/features/chat/components/Chat.browser.test.tsx @@ -0,0 +1,141 @@ +import { createMemoryRouter, RouterProvider } from "react-router"; +import { describe, expect, test, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import type { ChatMessage, ChatUser } from "../chat-types"; +import { Chat, type ChatAdapter } from "./Chat"; + +vi.mock("~/features/auth/core/user", () => ({ + useUser: () => null, +})); + +const USERS: Record = { + 1: { + username: "Alice", + discordId: "1", + discordAvatar: null, + pronouns: null, + customAvatarUrl: null, + chatNameHue: null, + }, +}; + +function createMessage(overrides: Partial = {}): ChatMessage { + return { + id: "1", + userId: 1, + contents: "Hello world", + timestamp: 1700000000000, + room: "room", + ...overrides, + }; +} + +function renderChat( + messages: ChatMessage[], + props?: { missingUserName?: string }, +) { + const chat: ChatAdapter = { + messages, + send: () => {}, + currentRoom: "room", + setCurrentRoom: () => {}, + readyState: "CONNECTED", + unseenMessages: new Map(), + }; + + const router = createMemoryRouter( + [ + { + path: "/", + element: ( +
+ +
+ ), + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +describe("Chat", () => { + test("renders messages inside a virtualized listbox", async () => { + const screen = await renderChat([ + createMessage({ id: "1", contents: "First message" }), + createMessage({ id: "2", contents: "Second message" }), + ]); + + await expect.element(screen.getByRole("listbox")).toBeInTheDocument(); + await expect.element(screen.getByText("First message")).toBeInTheDocument(); + await expect + .element(screen.getByText("Second message")) + .toBeInTheDocument(); + expect(screen.getByRole("option").elements()).toHaveLength(2); + }); + + test("virtualizes a long list into a scrollable region taller than its viewport", async () => { + const screen = await renderChat( + Array.from({ length: 100 }, (_, i) => + createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }), + ), + ); + + const listbox = screen.getByRole("listbox").element() as HTMLElement; + await expect.element(screen.getByRole("listbox")).toBeInTheDocument(); + + const scrollContent = listbox.querySelector( + ":scope > [role=presentation]", + ) as HTMLElement | null; + const messageRow = listbox.querySelector( + "[role=option]", + ) as HTMLElement | null; + + expect(scrollContent).not.toBeNull(); + expect(scrollContent!.offsetHeight).toBeGreaterThan(listbox.clientHeight); + expect(getComputedStyle(messageRow!.parentElement!).position).toBe( + "absolute", + ); + }); + + test("renders system messages", async () => { + const screen = await renderChat([ + createMessage({ + id: "1", + type: "USER_LEFT", + contents: undefined, + userId: undefined, + context: { name: "Bob" }, + }), + ]); + + await expect + .element(screen.getByText("Bob left the group")) + .toBeInTheDocument(); + }); + + test("skips messages with an unknown user when no fallback name is given", async () => { + const screen = await renderChat([ + createMessage({ id: "1", userId: 999, contents: "Ghost message" }), + ]); + + await expect.element(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.getByRole("option").elements()).toHaveLength(0); + }); + + test("renders messages with an unknown user using the fallback name", async () => { + const screen = await renderChat( + [createMessage({ id: "1", userId: 999, contents: "Ghost message" })], + { missingUserName: "Unknown" }, + ); + + await expect.element(screen.getByText("Ghost message")).toBeInTheDocument(); + await expect.element(screen.getByText("Unknown")).toBeInTheDocument(); + }); +}); diff --git a/app/features/chat/components/Chat.module.css b/app/features/chat/components/Chat.module.css index fbae6326c..437b7fcd8 100644 --- a/app/features/chat/components/Chat.module.css +++ b/app/features/chat/components/Chat.module.css @@ -5,10 +5,9 @@ .messages { padding: var(--s-3) 0 0 0; - display: flex; - flex-direction: column; - gap: var(--s-2); + display: block; height: 310px; + overflow-x: hidden; overflow-y: auto; } @@ -16,6 +15,10 @@ list-style: none; display: flex; gap: var(--s-3); + + &:focus-visible { + outline: none; + } } .messageInfo { diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index b1a229c99..fd30bc585 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -3,7 +3,13 @@ import { sub } from "date-fns"; import { SendHorizontal } from "lucide-react"; import { QRCodeSVG } from "qrcode.react"; import * as React from "react"; -import { Button } from "react-aria-components"; +import { + Button, + ListBox, + ListBoxItem, + ListLayout, + Virtualizer, +} from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Avatar } from "../../../components/Avatar"; import { SendouButton } from "../../../components/elements/Button"; @@ -14,6 +20,9 @@ import { useChatAutoScroll } from "../chat-hooks"; import type { ChatMessage, ChatProps, ChatUser } from "../chat-types"; import styles from "./Chat.module.css"; +const MESSAGE_GAP = 8; +const ESTIMATED_MESSAGE_HEIGHT = 44; + export interface ChatAdapter { messages: ChatMessage[]; send: (contents: string) => void; @@ -38,7 +47,7 @@ export function Chat({ chat: ChatAdapter; }) { const { t } = useTranslation(["common"]); - const messagesContainerRef = React.useRef(null); + const messagesContainerRef = React.useRef(null); const inputRef = React.useRef(null); const { send, @@ -114,6 +123,13 @@ export function Chat({ } }; + const renderableMessages = messages.filter((msg) => { + if (systemMessageText(msg)) return true; + + const user = msg.userId ? users[msg.userId] : null; + return Boolean(user) || Boolean(missingUserName); + }); + return (