diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 429a4a79..71ea9391 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -1,11 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { - formatChatMessageLinks, - LiveKitRoom, - type LocalUserChoices, -} from '@livekit/components-react' +import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react' import { Room, RoomOptions } from 'livekit-client' import { keys } from '@/api/queryKeys' import { queryClient } from '@/api/queryClient' @@ -104,7 +100,7 @@ export const Conference = ({ audio={userConfig.audioEnabled} video={userConfig.videoEnabled} > - + {showInviteDialog && ( ( -
+
{children}
) @@ -95,7 +103,8 @@ export const SidePanel = () => { const layoutSnap = useSnapshot(layoutStore) const sidePanel = layoutSnap.sidePanel - const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction() + const { isParticipantsOpen, isEffectsOpen, isChatOpen } = + useWidgetInteraction() const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) return ( @@ -107,16 +116,15 @@ export const SidePanel = () => { })} isClosed={!sidePanel} > - + - + + + + ) } diff --git a/src/frontend/src/features/rooms/livekit/components/chat/Entry.tsx b/src/frontend/src/features/rooms/livekit/components/chat/Entry.tsx new file mode 100644 index 00000000..c9542464 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/chat/Entry.tsx @@ -0,0 +1,69 @@ +import type { ReceivedChatMessage } from '@livekit/components-core' +import * as React from 'react' +import { css } from '@/styled-system/css' +import { Text } from '@/primitives' +import { MessageFormatter } from '@livekit/components-react' + +export interface ChatEntryProps extends React.HTMLAttributes { + entry: ReceivedChatMessage + hideMetadata?: boolean + messageFormatter?: MessageFormatter +} + +export const ChatEntry: ( + props: ChatEntryProps & React.RefAttributes +) => React.ReactNode = /* @__PURE__ */ React.forwardRef< + HTMLLIElement, + ChatEntryProps +>(function ChatEntry( + { entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps, + ref +) { + // Fixme - Livekit messageFormatter strips '\n' char + 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 ( +
  • + {!hideMetadata && ( + + + {entry.from?.name ?? entry.from?.identity} + + + {time.toLocaleTimeString(locale, { timeStyle: 'short' })} + + + )} + + {formattedMessage} + +
  • + ) +}) diff --git a/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx b/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx index d4aa68e1..a6cde080 100644 --- a/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx +++ b/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx @@ -92,7 +92,6 @@ export const ChatInput = ({ }} rows={rows || 1} style={{ - backgroundColor: 'white', border: 'none', resize: 'none', height: 'auto', @@ -101,6 +100,7 @@ export const ChatInput = ({ padding: '7px 10px', overflowY: 'hidden', }} + placeholderStyle={'strong'} spellCheck={false} maxLength={500} aria-label={t('textArea.label')} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/ChatToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/ChatToggle.tsx index 77c74c54..76ab043c 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/ChatToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/ChatToggle.tsx @@ -1,13 +1,17 @@ import { useTranslation } from 'react-i18next' import { RiChat1Line } from '@remixicon/react' -import { ToggleButton } from '@/primitives' +import { useSnapshot } from 'valtio' import { css } from '@/styled-system/css' +import { ToggleButton } from '@/primitives' import { useWidgetInteraction } from '../../hooks/useWidgetInteraction' +import { chatStore } from '@/stores/chat' export const ChatToggle = () => { const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' }) - const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction() + const chatSnap = useSnapshot(chatStore) + + const { isChatOpen, toggleChat } = useWidgetInteraction() const tooltipLabel = isChatOpen ? 'open' : 'closed' return ( @@ -28,7 +32,7 @@ export const ChatToggle = () => { > - {!!unreadMessages && ( + {!!chatSnap.unreadMessages && (
    { - const { dispatch, state } = useLayoutContext().widget - const layoutSnap = useSnapshot(layoutStore) const sidePanel = layoutSnap.sidePanel const isParticipantsOpen = sidePanel == SidePanel.PARTICIPANTS const isEffectsOpen = sidePanel == SidePanel.EFFECTS + const isChatOpen = sidePanel == SidePanel.CHAT const toggleParticipants = () => { - if (dispatch && state?.showChat) { - dispatch({ msg: 'toggle_chat' }) - } layoutStore.sidePanel = isParticipantsOpen ? null : SidePanel.PARTICIPANTS } const toggleChat = () => { - if (isParticipantsOpen || isEffectsOpen) { - layoutStore.sidePanel = null - } - if (dispatch) { - dispatch({ msg: 'toggle_chat' }) - } + layoutStore.sidePanel = isChatOpen ? null : SidePanel.CHAT } const toggleEffects = () => { - if (dispatch && state?.showChat) { - dispatch({ msg: 'toggle_chat' }) - } layoutStore.sidePanel = isEffectsOpen ? null : SidePanel.EFFECTS } @@ -43,8 +31,7 @@ export const useWidgetInteraction = () => { toggleParticipants, toggleChat, toggleEffects, - isChatOpen: state?.showChat, - unreadMessages: state?.unreadMessages, + isChatOpen, isParticipantsOpen, isEffectsOpen, } diff --git a/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx index 5b091628..ea79d918 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx @@ -1,42 +1,40 @@ import type { ChatMessage, ChatOptions } from '@livekit/components-core' import * as React from 'react' import { - ChatCloseIcon, - ChatEntry, - ChatToggle, - MessageFormatter, + formatChatMessageLinks, useChat, - useMaybeLayoutContext, + useParticipants, } from '@livekit/components-react' -import { cloneSingleChild } from '@/features/rooms/utils/cloneSingleChild' -import { ChatInput } from '@/features/rooms/livekit/components/chat/Input' +import { useTranslation } from 'react-i18next' +import { useSnapshot } from 'valtio' +import { chatStore } from '@/stores/chat' +import { Div, Text } from '@/primitives' +import { ChatInput } from '../components/chat/Input' +import { ChatEntry } from '../components/chat/Entry' +import { useWidgetInteraction } from '../hooks/useWidgetInteraction' -/** @public */ export interface ChatProps extends React.HTMLAttributes, - ChatOptions { - messageFormatter?: MessageFormatter -} + ChatOptions {} /** * 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, ...props }: ChatProps) { +export function Chat({ ...props }: ChatProps) { + const { t } = useTranslation('rooms', { keyPrefix: 'chat' }) + const inputRef = React.useRef(null) const ulRef = React.useRef(null) const { send, chatMessages, isSending } = useChat() - const layoutContext = useMaybeLayoutContext() + const { isChatOpen } = useWidgetInteraction() + const chatSnap = useSnapshot(chatStore) + + // Use useParticipants hook to trigger a re-render when the participant list changes. + const participants = useParticipants() + const lastReadMsgAt = React.useRef(0) async function handleSubmit(text: string) { @@ -46,22 +44,21 @@ export function Chat({ messageFormatter, ...props }: ChatProps) { } React.useEffect(() => { - if (ulRef) { + if (chatMessages.length > 0 && ulRef.current) { ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight }) } }, [ulRef, chatMessages]) React.useEffect(() => { - if (!layoutContext || chatMessages.length === 0) { + if (chatMessages.length === 0) { return } - if ( - layoutContext.widget.state?.showChat && - chatMessages.length > 0 && + isChatOpen && lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp ) { lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp + chatStore.unreadMessages = 0 return } @@ -69,55 +66,69 @@ export function Chat({ messageFormatter, ...props }: ChatProps) { (msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current ).length - const { widget } = layoutContext if ( unreadMessageCount > 0 && - widget.state?.unreadMessages !== unreadMessageCount + chatSnap.unreadMessages !== unreadMessageCount ) { - widget.dispatch?.({ msg: 'unread_msg', count: unreadMessageCount }) + chatStore.unreadMessages = unreadMessageCount } - }, [chatMessages, layoutContext, layoutContext?.widget]) + }, [chatMessages, chatSnap.unreadMessages, isChatOpen]) + + const renderedMessages = React.useMemo(() => { + return chatMessages.map((msg, idx, allMsg) => { + const 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. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatMessages, participants]) 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 ( - - ) - })} -
    +
    + + {t('disclaimer')} + +
    +
      + {renderedMessages} +
    +
    handleSubmit(e)} isSending={isSending} /> -
    +
    ) } diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index db3609ad..8a1d8b69 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -1,6 +1,4 @@ -import type { - TrackReferenceOrPlaceholder, -} from '@livekit/components-core' +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' import { isEqualTrackRef, isTrackReference, @@ -17,7 +15,6 @@ import { GridLayout, LayoutContextProvider, RoomAudioRenderer, - MessageFormatter, usePinnedTracks, useTracks, useCreateLayoutContext, @@ -50,7 +47,6 @@ const LayoutWrapper = styled( */ export interface VideoConferenceProps extends React.HTMLAttributes { - chatMessageFormatter?: MessageFormatter /** @alpha */ SettingsComponent?: React.ComponentType } @@ -73,10 +69,7 @@ export interface VideoConferenceProps * ``` * @public */ -export function VideoConference({ - chatMessageFormatter, - ...props -}: VideoConferenceProps) { +export function VideoConference({ ...props }: VideoConferenceProps) { const lastAutoFocusedScreenShareTrack = React.useRef(null) diff --git a/src/frontend/src/features/rooms/utils/cloneSingleChild.ts b/src/frontend/src/features/rooms/utils/cloneSingleChild.ts deleted file mode 100644 index 06b618a5..00000000 --- a/src/frontend/src/features/rooms/utils/cloneSingleChild.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 - }) -} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index ec940b1e..313d98ce 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -97,14 +97,19 @@ "sidePanel": { "heading": { "participants": "", - "effects": "" + "effects": "", + "chat": "" }, "content": { "participants": "", - "effects": "" + "effects": "", + "chat": "" }, "closeButton": "" }, + "chat": { + "disclaimer": "" + }, "participants": { "subheading": "", "contributors": "", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index c512a02d..62048bc6 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -95,14 +95,19 @@ "sidePanel": { "heading": { "participants": "Participants", - "effects": "Effects" + "effects": "Effects", + "chat": "Messages in the chat" }, "content": { "participants": "participants", - "effects": "effects" + "effects": "effects", + "chat": "messages" }, "closeButton": "Hide {{content}}" }, + "chat": { + "disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call." + }, "participants": { "subheading": "In room", "you": "You", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 5e92fd17..4596409d 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -95,14 +95,19 @@ "sidePanel": { "heading": { "participants": "Participants", - "effects": "Effets" + "effects": "Effets", + "chat": "Messages dans l'appel" }, "content": { "participants": "les participants", - "effects": "les effets" + "effects": "les effets", + "chat": "les messages" }, "closeButton": "Masquer {{content}}" }, + "chat": { + "disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel." + }, "participants": { "subheading": "Dans la réunion", "you": "Vous", diff --git a/src/frontend/src/primitives/TextArea.tsx b/src/frontend/src/primitives/TextArea.tsx index b59c7222..e7f1d2d0 100644 --- a/src/frontend/src/primitives/TextArea.tsx +++ b/src/frontend/src/primitives/TextArea.tsx @@ -15,4 +15,13 @@ export const TextArea = styled(RACTextArea, { borderRadius: 4, transition: 'all 200ms', }, + variants: { + placeholderStyle: { + strong: { + _placeholder: { + color: 'black', + }, + }, + }, + }, }) diff --git a/src/frontend/src/stores/chat.ts b/src/frontend/src/stores/chat.ts new file mode 100644 index 00000000..e4664ad7 --- /dev/null +++ b/src/frontend/src/stores/chat.ts @@ -0,0 +1,9 @@ +import { proxy } from 'valtio' + +type State = { + unreadMessages: number +} + +export const chatStore = proxy({ + unreadMessages: 0, +})