♻️(frontend) major chat refactoring

- Dissociate the chat observer (which persists messages in a store)
  from chat rendering.
- Do not keep the chat mounted in the DOM anymore.
- Recompute the minimal amount of data when a participant renames.
- Memoize most of the layout.
- Drop the manual textarea row-size computation: recent React Aria
  already handles it.

Known regressions still to solve: focus behavior on the input,
scroll behavior on message updates, and edge cases around list
sizing.

Not sure yet whether there is a better way to memoize the
virtualized list; open to feedback.
This commit is contained in:
lebaudantoine
2026-07-16 22:58:06 +02:00
parent a89fff9f52
commit 43e6275396
16 changed files with 492 additions and 420 deletions
@@ -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<HTMLDivElement>, 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<HTMLTextAreaElement>(null)
const listRef = React.useRef<HTMLDivElement>(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<ChatMessage['timestamp']>(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 (
<Div
display={'flex'}
padding={'0 1.5rem'}
flexGrow={1}
flexDirection={'column'}
minHeight={0}
{...props}
>
<Text
variant="sm"
className={css({
padding: '0.75rem',
backgroundColor: 'greyscale.50',
borderRadius: 4,
marginBottom: '0.75rem',
})}
>
{t('disclaimer')}
</Text>
<Div flexGrow={1} minHeight={0}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
gap: 4,
padding: 4,
}}
>
<ListBox
ref={listRef}
aria-label={t('messagesLabel', 'Chat messages')}
items={items}
selectionMode="none"
className="lk-list lk-chat-messages"
style={{
display: 'block',
height: '100%',
width: '100%',
overflow: 'auto',
}}
>
{(item: ChatListItem) => (
<ListBoxItem
textValue={item.msg.message}
style={{ width: '100%' }}
>
<ChatEntry
hideMetadata={item.hideMetadata}
entry={item.msg}
messageFormatter={formatChatMessageLinks}
/>
</ListBoxItem>
)}
</ListBox>
</Virtualizer>
</Div>
<ChatInput
inputRef={inputRef}
onSubmit={(e) => handleSubmit(e)}
isSending={isSending}
/>
</Div>
<ChatContainer>
<TextContainer>
<Text variant="sm">{t('disclaimer')}</Text>
</TextContainer>
<ChatMessagesContainer>
<ChatMessages />
</ChatMessagesContainer>
<ChatTextArea />
</ChatContainer>
)
}
@@ -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 (
<StyledContainer
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
>
{!item.hideMetadata && (
<ChatMessageMetadata
timestamp={item.timestamp}
identity={item.identity}
/>
)}
<ChatMessageBody message={item.message} />
</StyledContainer>
)
}
@@ -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<ChatRow, 'message'>
export const ChatMessageBody = React.memo(
({ message }: ChatMessageBodyProps) => {
const formattedMessage = useMemo(() => {
return formatChatMessageLinks(message)
}, [message])
return (
<Text
variant="sm"
margin={false}
className={css({
whiteSpace: 'pre-wrap',
'& .lk-chat-link': {
color: 'blue',
textDecoration: 'underline',
},
})}
>
{formattedMessage}
</Text>
)
}
)
ChatMessageBody.displayName = 'ChatMessageBody'
@@ -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<ChatRow, 'identity' | 'timestamp'>
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 (
<StyledContainer>
<Text bold={true} variant="sm">
{currentDisplayName}
</Text>
<Text variant="smNote" wrap="no">
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
</Text>
</StyledContainer>
)
}
)
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
@@ -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<HTMLDivElement>(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 (
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
gap: 4,
padding: 4,
}}
>
<ListBox
ref={listRef}
aria-label={t('messagesLabel', 'Chat messages')}
items={items}
selectionMode="none"
style={{
display: 'block',
height: '100%',
width: '100%',
overflow: 'auto',
}}
>
{(item: ChatRow) => (
<ListBoxItem style={{ width: '100%' }}>
<ChatMessage item={item} />
</ListBoxItem>
)}
</ListBox>
</Virtualizer>
)
}
@@ -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<ChatMessage['timestamp']>(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
}
@@ -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<void>
isDisabled: boolean
}
export const ChatSubmitButton = React.memo(
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
return (
<Button
square
invisible
variant="tertiaryText"
size="sm"
onPress={handleSubmit}
isDisabled={isDisabled}
aria-label={t('button.label')}
>
<RiSendPlane2Fill />
</Button>
)
}
)
ChatSubmitButton.displayName = 'ChatSubmitButton'
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
e.stopPropagation()
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
return
e.preventDefault()
await handleSubmit()
}
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.stopPropagation()
}
return (
<StyledContainer>
<TextArea
ref={inputRef}
value={textAreaValue}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onChange={(e) => {
persistTextAreaValue(e.target.value)
}}
fieldSizing={'content'}
style={{
border: 'none',
resize: 'none',
height: 'auto',
maxHeight: '240px',
minHeight: `34px`,
lineHeight: 1.25,
padding: '7px 10px',
}}
placeholderStyle="strong"
spellCheck={false}
maxLength={2000}
placeholder={t('textArea.placeholder')}
aria-label={t('textArea.label')}
/>
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
</StyledContainer>
)
}
@@ -1,71 +0,0 @@
import type { ReceivedChatMessage } from '@livekit/components-core'
import * as React from 'react'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
import type { MessageFormatter } from '@livekit/components-react'
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
entry: ReceivedChatMessage
hideMetadata?: boolean
messageFormatter?: MessageFormatter
}
export const ChatEntry: (
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLLIElement,
ChatEntryProps
>(function ChatEntry(
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
ref
) {
const formattedMessage = React.useMemo(() => {
return messageFormatter ? messageFormatter(entry.message) : entry.message
}, [entry.message, messageFormatter])
const time = new Date(entry.timestamp)
const locale = navigator ? navigator.language : 'en-US'
return (
<li
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
ref={ref}
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
{...props}
>
{!hideMetadata && (
<span
className={css({
display: 'flex',
gap: '0.5rem',
paddingTop: '0.75rem',
})}
>
<Text bold={true} variant="sm">
{entry.from?.name ?? entry.from?.identity}
</Text>
<Text variant="sm" className={css({ color: 'gray.700' })}>
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
</Text>
</span>
)}
<Text
variant="sm"
margin={false}
className={css({
whiteSpace: 'pre-wrap',
'& .lk-chat-link': {
color: 'blue',
textDecoration: 'underline',
},
})}
>
{formattedMessage}
</Text>
</li>
)
})
@@ -1,120 +0,0 @@
import { Button, TextArea } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiSendPlane2Fill } from '@remixicon/react'
import { useState, useEffect, RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
const MAX_ROWS = 6
interface ChatInputProps {
inputRef: RefObject<HTMLTextAreaElement>
onSubmit: (text: string) => void
isSending: boolean
}
export const ChatInput = ({
inputRef,
onSubmit,
isSending,
}: ChatInputProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
const [text, setText] = useState('')
const [rows, setRows] = useState(1)
const handleSubmit = () => {
onSubmit(text)
setText('')
}
const isDisabled = !text.trim() || isSending
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
e.preventDefault()
if (!isDisabled) handleSubmit()
}
useEffect(() => {
const resize = () => {
if (!inputRef.current) return
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
const previousRows = inputRef.current.rows
inputRef.current.rows = 1
const currentRows = Math.floor(
inputRef.current.scrollHeight / textAreaLineHeight
)
if (currentRows === previousRows) {
inputRef.current.rows = currentRows
}
if (currentRows >= MAX_ROWS) {
inputRef.current.rows = MAX_ROWS
inputRef.current.scrollTop = inputRef.current.scrollHeight
}
if (currentRows < MAX_ROWS) {
inputRef.current.style.overflowY = 'hidden'
} else {
inputRef.current.style.overflowY = 'auto'
}
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
}
resize()
}, [text, inputRef])
return (
<HStack
className={css({
margin: '0.75rem 0 1.5rem',
padding: '0.5rem',
backgroundColor: 'gray.100',
borderRadius: 4,
})}
>
<TextArea
ref={inputRef}
onKeyDown={(e) => {
e.stopPropagation()
submitOnEnter(e)
}}
onKeyUp={(e) => e.stopPropagation()}
placeholder={t('textArea.placeholder')}
value={text}
onChange={(e) => {
setText(e.target.value)
}}
rows={rows || 1}
style={{
border: 'none',
resize: 'none',
height: 'auto',
minHeight: `34px`,
lineHeight: 1.25,
padding: '7px 10px',
overflowY: 'hidden',
}}
placeholderStyle={'strong'}
spellCheck={false}
maxLength={2000}
aria-label={t('textArea.label')}
/>
<Button
square
invisible
variant="tertiaryText"
size="sm"
onPress={handleSubmit}
isDisabled={isDisabled}
aria-label={t('button.label')}
>
<RiSendPlane2Fill />
</Button>
</HStack>
)
}
@@ -218,7 +218,7 @@ export const SidePanel = () => {
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen} keepAlive={true}>
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}>
@@ -23,6 +23,7 @@ import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
import { StageLayout } from '@/features/layout/components/StageLayout'
import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
/**
* @public
@@ -62,6 +63,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
return (
<>
<ConnectionObserver />
<ChatProvider />
<VideoResolutionSubscription />
<div
className="lk-video-conference"
+3
View File
@@ -85,6 +85,9 @@ export const text = cva({
pretty: {
textWrap: 'pretty',
},
no: {
textWrap: 'nowrap',
},
},
bold: {
true: {
+5
View File
@@ -23,5 +23,10 @@ export const TextArea = styled(RACTextArea, {
},
},
},
fieldSizing: {
content: {
fieldSizing: 'content',
},
},
},
})
+53
View File
@@ -1,9 +1,62 @@
import { proxy } from 'valtio'
import type { useChat } from '@livekit/components-react'
import type { ReceivedChatMessage } from '@livekit/components-core'
type ChatApi = ReturnType<typeof useChat>
export type ChatRow = {
id: string
identity?: string
message: string
timestamp: number
hideMetadata: boolean
isLocal: boolean
}
type State = {
unreadMessages: number
isSending: boolean
rows: ChatRow[]
names: Record<string, string>
send?: ChatApi['send']
textAreaValue: string
}
export const chatStore = proxy<State>({
unreadMessages: 0,
isSending: false,
rows: [],
names: {},
send: undefined,
textAreaValue: '',
})
const GROUPING_WINDOW_MS = 60_000
export function appendRow(msg: ReceivedChatMessage) {
const p = msg.from
if (p) chatStore.names[p.identity] = p.name || p.identity
const identity = p?.identity
const prev = chatStore.rows[chatStore.rows.length - 1]
chatStore.rows.push({
id: msg.id ?? `${msg.timestamp}`,
identity,
isLocal: p?.isLocal ?? false,
message: msg.message,
timestamp: msg.timestamp,
hideMetadata:
!!prev &&
prev.identity === identity &&
msg.timestamp - prev.timestamp < GROUPING_WINDOW_MS,
})
}
export const persistTextAreaValue = (value: string) => {
chatStore.textAreaValue = value
}
export const clearTextAreaValue = () => {
chatStore.textAreaValue = ''
}
-20
View File
@@ -114,26 +114,6 @@
color: white;
}
[data-lk-theme='visio-light']
.lk-chat-entry[data-lk-message-origin='local']
.lk-message-body {
align-self: flex-end;
background-color: var(--colors-primary-subtle);
}
[data-lk-theme='visio-light']
.lk-chat-entry[data-lk-message-origin='remote']
.lk-message-body {
background-color: var(--colors-blue-300);
}
[data-lk-theme] .lk-chat-header {
font-weight: bold;
}
[data-lk-theme] .lk-chat-messages {
padding: 0.5rem;
}
.lk-chat-entry .lk-meta-data {
padding-left: 0.75rem;
padding-right: 0.75rem;