diff --git a/src/frontend/src/features/chat/components/Chat.tsx b/src/frontend/src/features/chat/components/Chat.tsx index 8cc37983..b8cf2531 100644 --- a/src/frontend/src/features/chat/components/Chat.tsx +++ b/src/frontend/src/features/chat/components/Chat.tsx @@ -1,218 +1,50 @@ -import type { ChatMessage, ChatOptions } from '@livekit/components-core' -import React from 'react' -import { - formatChatMessageLinks, - useChat, - useRemoteParticipants, - 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' -import { Div, Text } from '@/primitives' -import { ChatInput } from './Input' -import { ChatEntry } from './Entry' -import { - type LocalParticipant, - type RemoteParticipant, - RoomEvent, -} from 'livekit-client' -import { css } from '@/styled-system/css' -import { useRestoreFocus } from '@/hooks/useRestoreFocus' -import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' +import { Text } from '@/primitives' +import { ChatMessages } from './ChatMessages' +import { ChatTextArea } from './ChatTextArea' +import { styled } from '@/styled-system/jsx' -export interface ChatProps - extends React.HTMLAttributes, ChatOptions {} +const ChatContainer = styled('div', { + base: { + display: 'flex', + padding: '0 1.5rem', + flexGrow: 1, + flexDirection: 'column', + minHeight: 0, + }, +}) -// 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 +const ChatMessagesContainer = styled('div', { + base: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minHeight: 0, + }, +}) -interface ChatListItem { - id: string | number - msg: ChatMessage - hideMetadata: boolean -} +const TextContainer = styled('div', { + base: { + display: 'flex', + padding: '0.75rem', + backgroundColor: 'greyscale.50', + borderRadius: 4, + marginBottom: '0.75rem', + }, +}) -/** - * 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) { +export const Chat = () => { const { t } = useTranslation('rooms', { keyPrefix: 'chat' }) - const inputRef = React.useRef(null) - const listRef = React.useRef(null) - - const room = useRoomContext() - const { send, chatMessages, isSending } = useChat() - - const { isChatOpen, isSidePanelOpen } = useSidePanel() - const chatSnap = useSnapshot(chatStore) - - // Keep track of the element that opened the chat so we can restore focus - // when the chat panel is closed. - useRestoreFocus(isChatOpen, { - // Avoid layout "jump" during the side panel slide-in animation. - // Focusing can trigger scroll into view; preventScroll keeps the animation smooth. - onOpened: () => { - requestAnimationFrame(() => { - inputRef.current?.focus({ preventScroll: true }) - }) - }, - preventScroll: true, - shouldRestoreOnClose: () => !isSidePanelOpen, - }) - - // Use to trigger a re-render when the participant list changes. - const remoteParticipants = useRemoteParticipants({ - updateOnlyOn: [RoomEvent.ParticipantNameChanged], - }) - - const lastReadMsgAt = React.useRef(0) - const previousMessageCount = React.useRef(0) - - async function handleSubmit(text: string) { - if (!send || !text) return - await send(text) - inputRef?.current?.focus({ preventScroll: true }) - } - - // TEMPORARY: This is a brittle workaround that relies on message count tracking - // due to recent LiveKit useChat changes breaking the previous implementation - // (see https://github.com/livekit/components-js/issues/1158) - // Remove this once we refactor chat to use the new text stream approach - React.useEffect(() => { - if (!chatMessages || chatMessages.length <= previousMessageCount.current) - return - const msg = chatMessages.slice(-1)[0] - const from = msg.from as RemoteParticipant | LocalParticipant | undefined - room.emit(RoomEvent.ChatMessage, msg, from) - previousMessageCount.current = chatMessages.length - }, [chatMessages, room]) - - React.useEffect(() => { - if (chatMessages.length > 0 && listRef.current) { - requestAnimationFrame(() => { - listRef.current?.scrollTo({ top: listRef.current.scrollHeight }) - }) - } - }, [listRef, chatMessages]) - - React.useEffect(() => { - if (chatMessages.length === 0) { - return - } - if ( - isChatOpen && - lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp - ) { - lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp - chatStore.unreadMessages = 0 - return - } - - const unreadMessageCount = chatMessages.filter( - (msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current - ).length - - if ( - unreadMessageCount > 0 && - chatSnap.unreadMessages !== unreadMessageCount - ) { - chatStore.unreadMessages = unreadMessageCount - } - }, [chatMessages, chatSnap.unreadMessages, isChatOpen]) - - // 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, - })) - // `remoteParticipants` is included so rows re-render when participant - // information (name) changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatMessages, remoteParticipants]) - return ( -
- - {t('disclaimer')} - -
- - - {(item: ChatListItem) => ( - - - - )} - - -
- handleSubmit(e)} - isSending={isSending} - /> -
+ + + {t('disclaimer')} + + + + + + ) } diff --git a/src/frontend/src/features/chat/components/ChatMessage.tsx b/src/frontend/src/features/chat/components/ChatMessage.tsx new file mode 100644 index 00000000..58389a08 --- /dev/null +++ b/src/frontend/src/features/chat/components/ChatMessage.tsx @@ -0,0 +1,34 @@ +import type { ChatRow } from '@/stores/chat' +import { styled } from '@/styled-system/jsx' +import { ChatMessageMetadata } from './ChatMessageMedata' +import { ChatMessageBody } from './ChatMessageBody' + +const StyledContainer = styled('li', { + base: { + display: 'flex', + flexDirection: 'column', + gap: '0.25rem', + }, +}) + +type ChatMessageProps = { + item: ChatRow +} + +export const ChatMessage = ({ item }: ChatMessageProps) => { + const time = new Date(item.timestamp) + const locale = navigator ? navigator.language : 'en-US' + return ( + + {!item.hideMetadata && ( + + )} + + + ) +} diff --git a/src/frontend/src/features/chat/components/ChatMessageBody.tsx b/src/frontend/src/features/chat/components/ChatMessageBody.tsx new file mode 100644 index 00000000..67381ea4 --- /dev/null +++ b/src/frontend/src/features/chat/components/ChatMessageBody.tsx @@ -0,0 +1,33 @@ +import { ChatRow } from '@/stores/chat' +import React, { useMemo } from 'react' +import { formatChatMessageLinks } from '@livekit/components-react' +import { css } from '@/styled-system/css' +import { Text } from '@/primitives' + +type ChatMessageBodyProps = Pick + +export const ChatMessageBody = React.memo( + ({ message }: ChatMessageBodyProps) => { + const formattedMessage = useMemo(() => { + return formatChatMessageLinks(message) + }, [message]) + + return ( + + {formattedMessage} + + ) + } +) + +ChatMessageBody.displayName = 'ChatMessageBody' diff --git a/src/frontend/src/features/chat/components/ChatMessageMedata.tsx b/src/frontend/src/features/chat/components/ChatMessageMedata.tsx new file mode 100644 index 00000000..d5d28da5 --- /dev/null +++ b/src/frontend/src/features/chat/components/ChatMessageMedata.tsx @@ -0,0 +1,41 @@ +import { styled } from '@/styled-system/jsx' +import { ChatRow, chatStore } from '@/stores/chat' +import React, { useMemo } from 'react' +import { useSnapshot } from 'valtio' +import { Text } from '@/primitives' + +const StyledContainer = styled('span', { + base: { + display: 'flex', + gap: '0.5rem', + paddingTop: '0.75rem', + }, +}) + +type ChatMessageMetadataProps = Pick + +export const ChatMessageMetadata = React.memo( + ({ identity, timestamp }: ChatMessageMetadataProps) => { + const time = new Date(timestamp) + const locale = navigator ? navigator.language : 'en-US' + + const { names } = useSnapshot(chatStore) + + const currentDisplayName = useMemo(() => { + if (identity) return names[identity] ?? identity + }, [names, identity]) + + return ( + + + {currentDisplayName} + + + {time.toLocaleTimeString(locale, { timeStyle: 'short' })} + + + ) + } +) + +ChatMessageMetadata.displayName = 'ChatMessageMetadata' diff --git a/src/frontend/src/features/chat/components/ChatMessages.tsx b/src/frontend/src/features/chat/components/ChatMessages.tsx new file mode 100644 index 00000000..71859ee5 --- /dev/null +++ b/src/frontend/src/features/chat/components/ChatMessages.tsx @@ -0,0 +1,79 @@ +import { + ListBox, + ListBoxItem, + ListLayout, + Virtualizer, +} from 'react-aria-components' +import { useTranslation } from 'react-i18next' +import { ChatMessage } from './ChatMessage.tsx' +import { useLayoutEffect, useRef } from 'react' +import { useSnapshot } from 'valtio' +import { ChatRow, chatStore } from '@/stores/chat' + +// 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 +const BOTTOM_THRESHOLD_PX = 32 + +export const ChatMessages = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'chat' }) + const { rows: items } = useSnapshot(chatStore) + + const listRef = useRef(null) + const stick = useRef(true) + + useLayoutEffect(() => { + const el = listRef.current + if (!el) return + const pin = () => { + if (stick.current) el.scrollTop = el.scrollHeight + } + const onScroll = () => { + stick.current = + el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX + } + const ro = new ResizeObserver(pin) + // Sizer div = ListLayout's content size. Fires on append, and again each + // time an estimated row height is replaced by a measured one. + if (el.firstElementChild) ro.observe(el.firstElementChild) + // Container. Height goes 0 -> N as the panel animates in, and on resize. + ro.observe(el) + el.addEventListener('scroll', onScroll, { passive: true }) + pin() + return () => { + ro.disconnect() + el.removeEventListener('scroll', onScroll) + } + }, []) + + return ( + + + {(item: ChatRow) => ( + + + + )} + + + ) +} diff --git a/src/frontend/src/features/chat/components/ChatProvider.tsx b/src/frontend/src/features/chat/components/ChatProvider.tsx new file mode 100644 index 00000000..a6328fb0 --- /dev/null +++ b/src/frontend/src/features/chat/components/ChatProvider.tsx @@ -0,0 +1,79 @@ +// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level +import { ref } from 'valtio' +import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' +import React, { useEffect } from 'react' +import { useChat, useRoomContext } from '@livekit/components-react' +import { appendRow, chatStore } from '@/stores/chat' +import type { ChatMessage } from '@livekit/components-core' +import { + LocalParticipant, + Participant, + RemoteParticipant, + RoomEvent, +} from 'livekit-client' + +export const ChatProvider = () => { + const lastReadMsgAt = React.useRef(0) + const { send, chatMessages, isSending } = useChat() + const { isChatOpen } = useSidePanel() + + const room = useRoomContext() + + // Tigger the message notification (temporary) + useEffect(() => { + // TEMPORARY: This is a brittle workaround that relies on message count tracking + // due to recent LiveKit useChat changes breaking the previous implementation + // (see https://github.com/livekit/components-js/issues/1158) + // Remove this once we refactor chat to use the new text stream approach + const latestMessage = chatMessages.slice(-1)[0] + if (!latestMessage) return + const from = latestMessage.from as + | RemoteParticipant + | LocalParticipant + | undefined + + room.emit(RoomEvent.ChatMessage, latestMessage, from) + }, [chatMessages, room]) + + useEffect(() => { + for (let i = chatStore.rows.length; i < chatMessages.length; i++) { + appendRow(chatMessages[i]) + } + }, [chatMessages]) + + useEffect(() => { + chatStore.send = ref(send) + }, [send]) + + useEffect(() => { + chatStore.isSending = isSending + }, [isSending]) + + // Set the unread messages count + useEffect(() => { + if (chatMessages.length === 0) return + const last = chatMessages[chatMessages.length - 1] + if (isChatOpen) { + lastReadMsgAt.current = last.timestamp + chatStore.unreadMessages = 0 + return + } + chatStore.unreadMessages = chatMessages.filter( + (m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current + ).length + }, [chatMessages, isChatOpen]) + + // Listen to participant name changes + useEffect(() => { + const setName = (p: Participant) => { + chatStore.names[p.identity] = p.name || p.identity + } + const onNameChanged = (_name: string, p: Participant) => setName(p) + room.on(RoomEvent.ParticipantNameChanged, onNameChanged) + return () => { + room.off(RoomEvent.ParticipantNameChanged, onNameChanged) + } + }, [room]) + + return null +} diff --git a/src/frontend/src/features/chat/components/ChatSubmitButton.tsx b/src/frontend/src/features/chat/components/ChatSubmitButton.tsx new file mode 100644 index 00000000..de5c9162 --- /dev/null +++ b/src/frontend/src/features/chat/components/ChatSubmitButton.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/primitives' +import { RiSendPlane2Fill } from '@remixicon/react' + +type ChatSubmitButtonProps = { + handleSubmit: () => Promise + isDisabled: boolean +} + +export const ChatSubmitButton = React.memo( + ({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => { + const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' }) + return ( + + ) + } +) + +ChatSubmitButton.displayName = 'ChatSubmitButton' diff --git a/src/frontend/src/features/chat/components/ChatTextArea.tsx b/src/frontend/src/features/chat/components/ChatTextArea.tsx new file mode 100644 index 00000000..d5c4fb5d --- /dev/null +++ b/src/frontend/src/features/chat/components/ChatTextArea.tsx @@ -0,0 +1,92 @@ +import { TextArea } from '@/primitives' +import { styled } from '@/styled-system/jsx' +import React, { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useSnapshot } from 'valtio' +import { + chatStore, + clearTextAreaValue, + persistTextAreaValue, +} from '@/stores/chat' +import { ChatSubmitButton } from './ChatSubmitButton' + +const StyledContainer = styled('div', { + base: { + display: 'flex', + margin: '0.75rem 0 1.5rem', + padding: '0.5rem', + backgroundColor: 'gray.100', + borderRadius: 4, + }, +}) + +export const ChatTextArea = () => { + const { isSending, send, textAreaValue } = useSnapshot(chatStore) + + const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' }) + + const inputRef = React.useRef(null) + + useEffect(() => { + const el = inputRef.current + if (!el) return + const raf = requestAnimationFrame(() => { + el.focus({ preventScroll: true }) + const end = el.value.length + el.setSelectionRange(end, end) + }) + return () => cancelAnimationFrame(raf) + }, []) + + const handleSubmit = useCallback(async () => { + const text = chatStore.textAreaValue + if (!send || !text) return + await send(text) + inputRef?.current?.focus({ preventScroll: true }) + clearTextAreaValue() + }, [send, inputRef]) + + const isDisabled = !textAreaValue.trim() || isSending + + const onKeyDown = async (e: React.KeyboardEvent) => { + e.stopPropagation() + if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled) + return + e.preventDefault() + await handleSubmit() + } + + const onKeyUp = (e: React.KeyboardEvent) => { + e.stopPropagation() + } + + return ( + +