💩(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 { 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}
>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
<VideoConference />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}

View File

@@ -10,6 +10,7 @@ import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
import { ReactNode } from 'react'
import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
type StyledSidePanelProps = {
title: string
@@ -81,12 +82,19 @@ const StyledSidePanel = ({
)
type PanelProps = {
isOpen: boolean;
children: React.ReactNode;
};
isOpen: boolean
children: React.ReactNode
}
const Panel = ({ isOpen, children }: PanelProps) => (
<div style={{ display: isOpen ? 'block' : 'none' }}>
<div
style={{
display: isOpen ? 'inherit' : 'none',
flexDirection: 'column',
overflow: 'hidden',
flexGrow: 1,
}}
>
{children}
</div>
)
@@ -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}
>
<Panel
isOpen={isParticipantsOpen}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
<Panel
isOpen={isEffectsOpen}
>
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
</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}
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')}

View File

@@ -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 = () => {
>
<RiChat1Line />
</ToggleButton>
{!!unreadMessages && (
{!!chatSnap.unreadMessages && (
<div
className={css({
position: 'absolute',

View File

@@ -1,41 +1,29 @@
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export enum SidePanel {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
}
export const useWidgetInteraction = () => {
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,
}

View File

@@ -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<HTMLDivElement>,
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
* <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 ulRef = React.useRef<HTMLUListElement>(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<ChatMessage['timestamp']>(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 (
<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 (
<div {...props} className="lk-chat">
<div className="lk-chat-header">
Messages
<ChatToggle className="lk-close-button">
<ChatCloseIcon />
</ChatToggle>
</div>
<ul className="lk-list lk-chat-messages" ref={ulRef}>
{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 (
<ChatEntry
key={msg.id ?? idx}
hideName={hideName}
hideTimestamp={hideName === false ? false : hideTimestamp} // If we show the name always show the timestamp as well.
entry={msg}
messageFormatter={messageFormatter}
/>
)
})}
</ul>
<Div
display={'flex'}
padding={'0 1.5rem'}
flexGrow={1}
flexDirection={'column'}
minHeight={0}
{...props}
>
<Text
variant="sm"
style={{
padding: '0.75rem',
backgroundColor: '#f3f4f6',
borderRadius: 4,
marginBottom: '0.75rem',
}}
>
{t('disclaimer')}
</Text>
<Div
flexGrow={1}
flexDirection={'column'}
minHeight={0}
overflowY="scroll"
>
<ul className="lk-list lk-chat-messages" ref={ulRef}>
{renderedMessages}
</ul>
</Div>
<ChatInput
inputRef={inputRef}
onSubmit={(e) => handleSubmit(e)}
isSending={isSending}
/>
</div>
</Div>
)
}

View File

@@ -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<HTMLDivElement> {
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<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": {
"heading": {
"participants": "",
"effects": ""
"effects": "",
"chat": ""
},
"content": {
"participants": "",
"effects": ""
"effects": "",
"chat": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"participants": {
"subheading": "",
"contributors": "",

View File

@@ -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",

View File

@@ -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",

View File

@@ -15,4 +15,13 @@ export const TextArea = styled(RACTextArea, {
borderRadius: 4,
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,
})