From fb3a54a22469484f157d6eb12ece5f43b7b204f8 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 13 Jul 2026 20:07:12 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20virtualize=20cha?= =?UTF-8?q?t=20messages=20to=20reduce=20DOM=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At this stage, the chat is kept mounted in the DOM at all times to preserve state and keep receiving new messages. As a consequence, every chat message ends up rendered in the DOM, which can bloat it significantly in large meetings with hundreds of participants exchanging messages. Virtualize the chat message list so only the few messages actually visible are rendered in the DOM. The fact that the chat is always mounted in the DOM will be addressed in a later commit. --- .../features/rooms/livekit/prefabs/Chat.tsx | 107 ++++++++++++------ 1 file changed, 74 insertions(+), 33 deletions(-) diff --git a/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx index 72fc8999..aa593b5e 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx @@ -6,6 +6,12 @@ import { useParticipants, useRoomContext, } from '@livekit/components-react' +import { + ListBox, + ListBoxItem, + ListLayout, + Virtualizer, +} from 'react-aria-components' import { useTranslation } from 'react-i18next' import { useSnapshot } from 'valtio' import { chatStore } from '@/stores/chat' @@ -24,15 +30,30 @@ import { useRestoreFocus } from '@/hooks/useRestoreFocus' export interface ChatProps extends React.HTMLAttributes, ChatOptions {} +// Estimated height of a chat entry in px. ListLayout measures the real +// rendered height of each row; this value is only used to size the +// scrollbar before rows have been measured. +const ESTIMATED_ROW_HEIGHT = 56 + +interface ChatListItem { + id: string | number + msg: ChatMessage + hideMetadata: boolean +} + /** - * The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants + * The Chat component adds a basic chat functionality to the LiveKit room. The messages are distributed to all participants * in the room. Only users who are in the room at the time of dispatch will receive the message. + * + * The message list is virtualized with React Aria's Virtualizer + ListLayout: + * only visible rows are mounted, so long chat histories stay cheap to render. + * The ListBox itself is the scroll container. */ export function Chat({ ...props }: ChatProps) { const { t } = useTranslation('rooms', { keyPrefix: 'chat' }) const inputRef = React.useRef(null) - const ulRef = React.useRef(null) + const listRef = React.useRef(null) const room = useRoomContext() const { send, chatMessages, isSending } = useChat() @@ -80,10 +101,12 @@ export function Chat({ ...props }: ChatProps) { }, [chatMessages, room]) React.useEffect(() => { - if (chatMessages.length > 0 && ulRef.current) { - ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight }) + if (chatMessages.length > 0 && listRef.current) { + requestAnimationFrame(() => { + listRef.current?.scrollTo({ top: listRef.current.scrollHeight }) + }) } - }, [ulRef, chatMessages]) + }, [listRef, chatMessages]) React.useEffect(() => { if (chatMessages.length === 0) { @@ -110,23 +133,19 @@ export function Chat({ ...props }: ChatProps) { } }, [chatMessages, chatSnap.unreadMessages, isChatOpen]) - const renderedMessages = React.useMemo(() => { - return chatMessages.map((msg, idx, allMsg) => { - const hideMetadata = + // The ListBox render function only receives the item, so precompute + // hideMetadata (metadata grouping) and a stable id for each message here. + const items: ChatListItem[] = React.useMemo(() => { + return chatMessages.map((msg, idx, allMsg) => ({ + id: msg.id ?? `${msg.timestamp}-${idx}`, + msg, + hideMetadata: idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000 && - allMsg[idx - 1].from === msg.from - - return ( - - ) - }) - // This ensures that the chat message list is updated to reflect any changes in participant information. + allMsg[idx - 1].from === msg.from, + })) + // `participants` is included so rows re-render when participant + // information (name, metadata) changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatMessages, participants]) @@ -150,20 +169,42 @@ export function Chat({ ...props }: ChatProps) { > {t('disclaimer')} -
-
    + - {renderedMessages} -
+ + {(item: ChatListItem) => ( + + + + )} + +