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) => ( + + + + )} + +