diff --git a/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx new file mode 100644 index 00000000..965280a2 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx @@ -0,0 +1,151 @@ +import type { ChatMessage, ChatOptions } from '@livekit/components-core' +import * as React from 'react' +import { + ChatCloseIcon, + ChatEntry, + ChatToggle, + MessageFormatter, + useChat, + useMaybeLayoutContext, +} from '@livekit/components-react' +import { cloneSingleChild } from '@/features/rooms/utils/cloneSingleChild' + +/** @public */ +export interface ChatProps + extends React.HTMLAttributes, + ChatOptions { + messageFormatter?: MessageFormatter +} + +/** + * The Chat component adds a basis 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. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export function Chat({ + messageFormatter, + messageDecoder, + messageEncoder, + channelTopic, + ...props +}: ChatProps) { + const inputRef = React.useRef(null) + const ulRef = React.useRef(null) + + const chatOptions: ChatOptions = React.useMemo(() => { + return { messageDecoder, messageEncoder, channelTopic } + }, [messageDecoder, messageEncoder, channelTopic]) + + const { send, chatMessages, isSending } = useChat(chatOptions) + + const layoutContext = useMaybeLayoutContext() + const lastReadMsgAt = React.useRef(0) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (inputRef.current && inputRef.current.value.trim() !== '') { + if (send) { + await send(inputRef.current.value) + inputRef.current.value = '' + inputRef.current.focus() + } + } + } + + React.useEffect(() => { + if (ulRef) { + ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight }) + } + }, [ulRef, chatMessages]) + + React.useEffect(() => { + if (!layoutContext || chatMessages.length === 0) { + return + } + + if ( + layoutContext.widget.state?.showChat && + chatMessages.length > 0 && + lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp + ) { + lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp + return + } + + const unreadMessageCount = chatMessages.filter( + (msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current + ).length + + const { widget } = layoutContext + if ( + unreadMessageCount > 0 && + widget.state?.unreadMessages !== unreadMessageCount + ) { + widget.dispatch?.({ msg: 'unread_msg', count: unreadMessageCount }) + } + }, [chatMessages, layoutContext, layoutContext?.widget]) + + return ( +
+
+ Messages + + + +
+ +
    + {props.children + ? chatMessages.map((msg, idx) => + cloneSingleChild(props.children, { + entry: msg, + key: msg.id ?? idx, + messageFormatter, + }) + ) + : chatMessages.map((msg, idx, allMsg) => { + const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from + // If the time delta between two messages is bigger than 60s show timestamp. + const hideTimestamp = + idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000 + + return ( + + ) + })} +
+
+ ev.stopPropagation()} + onKeyDown={(ev) => ev.stopPropagation()} + onKeyUp={(ev) => ev.stopPropagation()} + /> + +
+
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index c78a5632..808e687c 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -24,7 +24,6 @@ import { usePinnedTracks, useTracks, useCreateLayoutContext, - Chat, } from '@livekit/components-react' import { ControlBar } from './ControlBar' @@ -36,6 +35,7 @@ import { FocusLayout } from '../components/FocusLayout' import { ParticipantTile } from '../components/ParticipantTile' import { SidePanel } from '../components/SidePanel' import { MainNotificationToast } from '@/features/notifications/MainNotificationToast' +import { Chat } from '@/features/rooms/livekit/prefabs/Chat' const LayoutWrapper = styled( 'div', diff --git a/src/frontend/src/features/rooms/utils/cloneSingleChild.ts b/src/frontend/src/features/rooms/utils/cloneSingleChild.ts new file mode 100644 index 00000000..06b618a5 --- /dev/null +++ b/src/frontend/src/features/rooms/utils/cloneSingleChild.ts @@ -0,0 +1,26 @@ +import React from 'react' +import clsx from 'clsx' + +/** @internal function from LiveKit*/ +export function cloneSingleChild( + children: React.ReactNode | React.ReactNode[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props?: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + key?: any +) { + return React.Children.map(children, (child) => { + // Checking isValidElement is the safe way and avoids a typescript + // error too. + if (React.isValidElement(child) && React.Children.only(children)) { + if (child.props.class) { + // make sure we retain classnames of both passed props and child + props ??= {} + props.class = clsx(child.props.class, props.class) + props.style = { ...child.props.style, ...props.style } + } + return React.cloneElement(child, { ...props, key }) + } + return child + }) +}