💩(frontend) integrate chat into sidepanel and revamp UI

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).
This commit is contained in:
lebaudantoine
2024-10-14 17:37:55 +02:00
committed by aleb_the_flash
parent 998382020d
commit a84b76170d
14 changed files with 219 additions and 144 deletions

View File

@@ -1,11 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
formatChatMessageLinks,
LiveKitRoom,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client' import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
@@ -104,7 +100,7 @@ export const Conference = ({
audio={userConfig.audioEnabled} audio={userConfig.audioEnabled}
video={userConfig.videoEnabled} video={userConfig.videoEnabled}
> >
<VideoConference chatMessageFormatter={formatChatMessageLinks} /> <VideoConference />
{showInviteDialog && ( {showInviteDialog && (
<InviteDialog <InviteDialog
isOpen={showInviteDialog} isOpen={showInviteDialog}

View File

@@ -10,6 +10,7 @@ import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useWidgetInteraction } from '../hooks/useWidgetInteraction' import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Effects } from './Effects' import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
@@ -81,12 +82,19 @@ const StyledSidePanel = ({
) )
type PanelProps = { type PanelProps = {
isOpen: boolean; isOpen: boolean
children: React.ReactNode; children: React.ReactNode
}; }
const Panel = ({ isOpen, children }: PanelProps) => ( const Panel = ({ isOpen, children }: PanelProps) => (
<div style={{ display: isOpen ? 'block' : 'none' }}> <div
style={{
display: isOpen ? 'inherit' : 'none',
flexDirection: 'column',
overflow: 'hidden',
flexGrow: 1,
}}
>
{children} {children}
</div> </div>
) )
@@ -95,7 +103,8 @@ export const SidePanel = () => {
const layoutSnap = useSnapshot(layoutStore) const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel const sidePanel = layoutSnap.sidePanel
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction() const { isParticipantsOpen, isEffectsOpen, isChatOpen } =
useWidgetInteraction()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
return ( return (
@@ -107,16 +116,15 @@ export const SidePanel = () => {
})} })}
isClosed={!sidePanel} isClosed={!sidePanel}
> >
<Panel <Panel isOpen={isParticipantsOpen}>
isOpen={isParticipantsOpen}
>
<ParticipantsList /> <ParticipantsList />
</Panel> </Panel>
<Panel <Panel isOpen={isEffectsOpen}>
isOpen={isEffectsOpen}
>
<Effects /> <Effects />
</Panel> </Panel>
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
</StyledSidePanel> </StyledSidePanel>
) )
} }

View File

@@ -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<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
) {
// 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 (
<li
className={css({
display: 'flex',
flexDirection: 'column',
})}
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',
})}
>
<Text bold={true} variant="sm">
{entry.from?.name ?? entry.from?.identity}
</Text>
<Text variant="sm">
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
</Text>
</span>
)}
<Text
variant="sm"
margin={false}
className={css({
'& .lk-chat-link': {
color: 'blue',
textDecoration: 'underline',
},
})}
>
{formattedMessage}
</Text>
</li>
)
})

View File

@@ -92,7 +92,6 @@ export const ChatInput = ({
}} }}
rows={rows || 1} rows={rows || 1}
style={{ style={{
backgroundColor: 'white',
border: 'none', border: 'none',
resize: 'none', resize: 'none',
height: 'auto', height: 'auto',
@@ -101,6 +100,7 @@ export const ChatInput = ({
padding: '7px 10px', padding: '7px 10px',
overflowY: 'hidden', overflowY: 'hidden',
}} }}
placeholderStyle={'strong'}
spellCheck={false} spellCheck={false}
maxLength={500} maxLength={500}
aria-label={t('textArea.label')} aria-label={t('textArea.label')}

View File

@@ -1,13 +1,17 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react' import { RiChat1Line } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction' import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
import { chatStore } from '@/stores/chat'
export const ChatToggle = () => { export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' }) 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' const tooltipLabel = isChatOpen ? 'open' : 'closed'
return ( return (
@@ -28,7 +32,7 @@ export const ChatToggle = () => {
> >
<RiChat1Line /> <RiChat1Line />
</ToggleButton> </ToggleButton>
{!!unreadMessages && ( {!!chatSnap.unreadMessages && (
<div <div
className={css({ className={css({
position: 'absolute', position: 'absolute',

View File

@@ -1,41 +1,29 @@
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
export enum SidePanel { export enum SidePanel {
PARTICIPANTS = 'participants', PARTICIPANTS = 'participants',
EFFECTS = 'effects', EFFECTS = 'effects',
CHAT = 'chat',
} }
export const useWidgetInteraction = () => { export const useWidgetInteraction = () => {
const { dispatch, state } = useLayoutContext().widget
const layoutSnap = useSnapshot(layoutStore) const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel const sidePanel = layoutSnap.sidePanel
const isParticipantsOpen = sidePanel == SidePanel.PARTICIPANTS const isParticipantsOpen = sidePanel == SidePanel.PARTICIPANTS
const isEffectsOpen = sidePanel == SidePanel.EFFECTS const isEffectsOpen = sidePanel == SidePanel.EFFECTS
const isChatOpen = sidePanel == SidePanel.CHAT
const toggleParticipants = () => { const toggleParticipants = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isParticipantsOpen ? null : SidePanel.PARTICIPANTS layoutStore.sidePanel = isParticipantsOpen ? null : SidePanel.PARTICIPANTS
} }
const toggleChat = () => { const toggleChat = () => {
if (isParticipantsOpen || isEffectsOpen) { layoutStore.sidePanel = isChatOpen ? null : SidePanel.CHAT
layoutStore.sidePanel = null
}
if (dispatch) {
dispatch({ msg: 'toggle_chat' })
}
} }
const toggleEffects = () => { const toggleEffects = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isEffectsOpen ? null : SidePanel.EFFECTS layoutStore.sidePanel = isEffectsOpen ? null : SidePanel.EFFECTS
} }
@@ -43,8 +31,7 @@ export const useWidgetInteraction = () => {
toggleParticipants, toggleParticipants,
toggleChat, toggleChat,
toggleEffects, toggleEffects,
isChatOpen: state?.showChat, isChatOpen,
unreadMessages: state?.unreadMessages,
isParticipantsOpen, isParticipantsOpen,
isEffectsOpen, isEffectsOpen,
} }

View File

@@ -1,42 +1,40 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core' import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import * as React from 'react' import * as React from 'react'
import { import {
ChatCloseIcon, formatChatMessageLinks,
ChatEntry,
ChatToggle,
MessageFormatter,
useChat, useChat,
useMaybeLayoutContext, useParticipants,
} from '@livekit/components-react' } from '@livekit/components-react'
import { cloneSingleChild } from '@/features/rooms/utils/cloneSingleChild' import { useTranslation } from 'react-i18next'
import { ChatInput } from '@/features/rooms/livekit/components/chat/Input' 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 export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
ChatOptions { ChatOptions {}
messageFormatter?: MessageFormatter
}
/** /**
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants * 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. * in the room. Only users who are in the room at the time of dispatch will receive the message.
*
* @example
* ```tsx
* <LiveKitRoom>
* <Chat />
* </LiveKitRoom>
* ```
* @public
*/ */
export function Chat({ messageFormatter, ...props }: ChatProps) { export function Chat({ ...props }: ChatProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
const inputRef = React.useRef<HTMLTextAreaElement>(null) const inputRef = React.useRef<HTMLTextAreaElement>(null)
const ulRef = React.useRef<HTMLUListElement>(null) const ulRef = React.useRef<HTMLUListElement>(null)
const { send, chatMessages, isSending } = useChat() 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<ChatMessage['timestamp']>(0) const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
async function handleSubmit(text: string) { async function handleSubmit(text: string) {
@@ -46,22 +44,21 @@ export function Chat({ messageFormatter, ...props }: ChatProps) {
} }
React.useEffect(() => { React.useEffect(() => {
if (ulRef) { if (chatMessages.length > 0 && ulRef.current) {
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight }) ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight })
} }
}, [ulRef, chatMessages]) }, [ulRef, chatMessages])
React.useEffect(() => { React.useEffect(() => {
if (!layoutContext || chatMessages.length === 0) { if (chatMessages.length === 0) {
return return
} }
if ( if (
layoutContext.widget.state?.showChat && isChatOpen &&
chatMessages.length > 0 &&
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
) { ) {
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp
chatStore.unreadMessages = 0
return return
} }
@@ -69,55 +66,69 @@ export function Chat({ messageFormatter, ...props }: ChatProps) {
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current (msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
).length ).length
const { widget } = layoutContext
if ( if (
unreadMessageCount > 0 && 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 (
<ChatEntry
key={msg.id ?? idx}
hideMetadata={hideMetadata}
entry={msg}
messageFormatter={formatChatMessageLinks}
/>
)
})
// 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 ( return (
<div {...props} className="lk-chat"> <Div
<div className="lk-chat-header"> display={'flex'}
Messages padding={'0 1.5rem'}
<ChatToggle className="lk-close-button"> flexGrow={1}
<ChatCloseIcon /> flexDirection={'column'}
</ChatToggle> minHeight={0}
</div> {...props}
>
<ul className="lk-list lk-chat-messages" ref={ulRef}> <Text
{props.children variant="sm"
? chatMessages.map((msg, idx) => style={{
cloneSingleChild(props.children, { padding: '0.75rem',
entry: msg, backgroundColor: '#f3f4f6',
key: msg.id ?? idx, borderRadius: 4,
messageFormatter, marginBottom: '0.75rem',
}) }}
) >
: chatMessages.map((msg, idx, allMsg) => { {t('disclaimer')}
const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from </Text>
// If the time delta between two messages is bigger than 60s show timestamp. <Div
const hideTimestamp = flexGrow={1}
idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000 flexDirection={'column'}
minHeight={0}
return ( overflowY="scroll"
<ChatEntry >
key={msg.id ?? idx} <ul className="lk-list lk-chat-messages" ref={ulRef}>
hideName={hideName} {renderedMessages}
hideTimestamp={hideName === false ? false : hideTimestamp} // If we show the name always show the timestamp as well. </ul>
entry={msg} </Div>
messageFormatter={messageFormatter}
/>
)
})}
</ul>
<ChatInput <ChatInput
inputRef={inputRef} inputRef={inputRef}
onSubmit={(e) => handleSubmit(e)} onSubmit={(e) => handleSubmit(e)}
isSending={isSending} isSending={isSending}
/> />
</div> </Div>
) )
} }

View File

@@ -1,6 +1,4 @@
import type { import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { import {
isEqualTrackRef, isEqualTrackRef,
isTrackReference, isTrackReference,
@@ -17,7 +15,6 @@ import {
GridLayout, GridLayout,
LayoutContextProvider, LayoutContextProvider,
RoomAudioRenderer, RoomAudioRenderer,
MessageFormatter,
usePinnedTracks, usePinnedTracks,
useTracks, useTracks,
useCreateLayoutContext, useCreateLayoutContext,
@@ -50,7 +47,6 @@ const LayoutWrapper = styled(
*/ */
export interface VideoConferenceProps export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
/** @alpha */ /** @alpha */
SettingsComponent?: React.ComponentType SettingsComponent?: React.ComponentType
} }
@@ -73,10 +69,7 @@ export interface VideoConferenceProps
* ``` * ```
* @public * @public
*/ */
export function VideoConference({ export function VideoConference({ ...props }: VideoConferenceProps) {
chatMessageFormatter,
...props
}: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack = const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null) React.useRef<TrackReferenceOrPlaceholder | null>(null)

View File

@@ -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<string, any>,
// 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
})
}

View File

@@ -97,14 +97,19 @@
"sidePanel": { "sidePanel": {
"heading": { "heading": {
"participants": "", "participants": "",
"effects": "" "effects": "",
"chat": ""
}, },
"content": { "content": {
"participants": "", "participants": "",
"effects": "" "effects": "",
"chat": ""
}, },
"closeButton": "" "closeButton": ""
}, },
"chat": {
"disclaimer": ""
},
"participants": { "participants": {
"subheading": "", "subheading": "",
"contributors": "", "contributors": "",

View File

@@ -95,14 +95,19 @@
"sidePanel": { "sidePanel": {
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Effects" "effects": "Effects",
"chat": "Messages in the chat"
}, },
"content": { "content": {
"participants": "participants", "participants": "participants",
"effects": "effects" "effects": "effects",
"chat": "messages"
}, },
"closeButton": "Hide {{content}}" "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": { "participants": {
"subheading": "In room", "subheading": "In room",
"you": "You", "you": "You",

View File

@@ -95,14 +95,19 @@
"sidePanel": { "sidePanel": {
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Effets" "effects": "Effets",
"chat": "Messages dans l'appel"
}, },
"content": { "content": {
"participants": "les participants", "participants": "les participants",
"effects": "les effets" "effects": "les effets",
"chat": "les messages"
}, },
"closeButton": "Masquer {{content}}" "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": { "participants": {
"subheading": "Dans la réunion", "subheading": "Dans la réunion",
"you": "Vous", "you": "Vous",

View File

@@ -15,4 +15,13 @@ export const TextArea = styled(RACTextArea, {
borderRadius: 4, borderRadius: 4,
transition: 'all 200ms', transition: 'all 200ms',
}, },
variants: {
placeholderStyle: {
strong: {
_placeholder: {
color: 'black',
},
},
},
},
}) })

View File

@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
unreadMessages: number
}
export const chatStore = proxy<State>({
unreadMessages: 0,
})