mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-07 04:36:35 -05:00
Virtualize chat (#3196)
This commit is contained in:
parent
6732b6c59b
commit
0d59dcd734
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
141
app/features/chat/components/Chat.browser.test.tsx
Normal file
141
app/features/chat/components/Chat.browser.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user