From a84b76170db3aff0a981f7fe6b637b2473d544f0 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 14 Oct 2024 17:37:55 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A9(frontend)=20integrate=20chat=20int?= =?UTF-8?q?o=20sidepanel=20and=20revamp=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properly integrate chat into the sidepanel to improve UX and avoid disruptions. Implement initial styling based on Google Meet's design, with plans for future enhancements. Some details remain to be refined, such as preserving newline characters in the message formatter. This substantial commit refactors and cleans up a significant legacy component. Chat notifications will be addressed in a separate PR. Note: While this is a large commit, it represents a major improvement in user experience (in my opinion). --- .../features/rooms/components/Conference.tsx | 8 +- .../rooms/livekit/components/SidePanel.tsx | 30 ++-- .../rooms/livekit/components/chat/Entry.tsx | 69 +++++++++ .../rooms/livekit/components/chat/Input.tsx | 2 +- .../components/controls/ChatToggle.tsx | 10 +- .../livekit/hooks/useWidgetInteraction.ts | 21 +-- .../features/rooms/livekit/prefabs/Chat.tsx | 141 ++++++++++-------- .../rooms/livekit/prefabs/VideoConference.tsx | 11 +- .../features/rooms/utils/cloneSingleChild.ts | 26 ---- src/frontend/src/locales/de/rooms.json | 9 +- src/frontend/src/locales/en/rooms.json | 9 +- src/frontend/src/locales/fr/rooms.json | 9 +- src/frontend/src/primitives/TextArea.tsx | 9 ++ src/frontend/src/stores/chat.ts | 9 ++ 14 files changed, 219 insertions(+), 144 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/chat/Entry.tsx delete mode 100644 src/frontend/src/features/rooms/utils/cloneSingleChild.ts create mode 100644 src/frontend/src/stores/chat.ts 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, +})