Virtualize chat (#3196)

This commit is contained in:
Kalle 2026-06-27 11:17:12 +03:00 committed by GitHub
parent 6732b6c59b
commit 0d59dcd734
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 208 additions and 39 deletions

View File

@ -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);
}

View File

@ -8,7 +8,7 @@ const THRESHOLD = 100;
export function useChatAutoScroll(
messages: ChatMessage[],
ref: React.RefObject<HTMLOListElement | null>,
ref: React.RefObject<HTMLElement | null>,
) {
const user = useUser();
const [firstLoadHandled, setFirstLoadHandled] = React.useState(false);

View File

@ -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<number, ChatUser> = {
1: {
username: "Alice",
discordId: "1",
discordAvatar: null,
pronouns: null,
customAvatarUrl: null,
chatNameHue: null,
},
};
function createMessage(overrides: Partial<ChatMessage> = {}): 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: (
<div style={{ width: 400 }}>
<Chat
users={USERS}
rooms={[]}
chat={chat}
missingUserName={props?.missingUserName}
/>
</div>
),
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
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();
});
});

View File

@ -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 {

View File

@ -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<HTMLOListElement>(null);
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(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 (
<section className={clsx(styles.container, className, { hidden })}>
{rooms.length > 1 ? (
@ -147,39 +163,42 @@ export function Chat({
</div>
) : null}
<div className={styles.inputContainer}>
<ol
className={clsx(
styles.messages,
"scrollbar",
messagesContainerClassName,
)}
ref={messagesContainerRef}
<Virtualizer
layout={ListLayout}
layoutOptions={{
gap: MESSAGE_GAP,
estimatedRowSize: ESTIMATED_MESSAGE_HEIGHT,
}}
>
{messages.map((msg) => {
const systemMessage = systemMessageText(msg);
if (systemMessage) {
<ListBox
ref={messagesContainerRef}
aria-label="Chat messages"
selectionMode="none"
items={renderableMessages}
className={clsx(
styles.messages,
"scrollbar",
messagesContainerClassName,
)}
>
{(msg) => {
const systemMessage = systemMessageText(msg);
if (systemMessage) {
return <SystemMessage message={msg} text={systemMessage} />;
}
const user = msg.userId ? users[msg.userId] : null;
return (
<SystemMessage
key={msg.id}
<Message
user={user}
missingUserName={missingUserName}
message={msg}
text={systemMessage}
/>
);
}
const user = msg.userId ? users[msg.userId] : null;
if (!user && !missingUserName) return null;
return (
<Message
key={msg.id}
user={user}
missingUserName={missingUserName}
message={msg}
/>
);
})}
</ol>
}}
</ListBox>
</Virtualizer>
{unseenMessagesInTheRoom ? (
<SendouButton
className={styles.unseenMessages}
@ -241,7 +260,10 @@ function Message({
missingUserName?: string;
}) {
return (
<li className={styles.message}>
<ListBoxItem
className={styles.message}
textValue={message.contents ?? user?.username ?? missingUserName ?? ""}
>
{user ? (
<div
className={clsx(styles.avatarWrapper, {
@ -283,7 +305,7 @@ function Message({
) : null}
</div>
</div>
</li>
</ListBoxItem>
);
}
@ -295,7 +317,7 @@ function SystemMessage({
text: string;
}) {
return (
<li className={styles.message}>
<ListBoxItem className={styles.message} textValue={text}>
<div>
<div className="stack horizontal sm">
<MessageTimestamp timestamp={message.timestamp} />
@ -309,7 +331,7 @@ function SystemMessage({
{text}
</div>
</div>
</li>
</ListBoxItem>
);
}