🚧(frontend) introduce a participants list
I faced few challenge while using LiveKit hook, please refer to my commits. I'll work on a PR on their repo to get it fixed. Duplicating their sources add overhead. This commit introduce the most minimal participants list possible. More controls or information will be added in the upcomming commits. Responsiveness and accessibility should be functional. Soon, any right side panel will share the same container to avoid visual glichts.
This commit is contained in:
committed by
aleb_the_flash
parent
144cb56cda
commit
16321b3cb0
@@ -0,0 +1,160 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import * as React from 'react'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { Box, Button, Div } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { capitalize } from '@/utils/capitalize'
|
||||
import { participantsStore } from '@/stores/participants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
|
||||
|
||||
export type AvatarProps = React.HTMLAttributes<HTMLSpanElement> & {
|
||||
name: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||
export const Avatar = ({ name, size = 32 }: AvatarProps) => (
|
||||
<div
|
||||
className={css({
|
||||
minWidth: `${size}px`,
|
||||
minHeight: `${size}px`,
|
||||
backgroundColor: '#3498db',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: '1.25rem',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
{name?.trim()?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
||||
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
||||
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
|
||||
const participants = useParticipants({
|
||||
updateOnlyOn: allParticipantRoomEvents,
|
||||
})
|
||||
|
||||
const formattedParticipants = participants.map((participant) => ({
|
||||
name: participant.name || participant.identity,
|
||||
id: participant.identity,
|
||||
}))
|
||||
|
||||
const sortedRemoteParticipants = formattedParticipants
|
||||
.slice(1)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const allParticipants = [
|
||||
formattedParticipants[0], // first participant returned by the hook, is always the local one
|
||||
...sortedRemoteParticipants,
|
||||
]
|
||||
|
||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||
return (
|
||||
<Box
|
||||
size="sm"
|
||||
minWidth="300px"
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '1.5rem 1.5rem 1.5rem 0',
|
||||
})}
|
||||
>
|
||||
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
||||
<span>{t('participants.heading')}</span>{' '}
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '0.75rem',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '1rem',
|
||||
})}
|
||||
>
|
||||
{participants?.length}
|
||||
</span>
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
size="xs"
|
||||
onPress={() => (participantsStore.showParticipants = false)}
|
||||
aria-label={t('participants.closeButton')}
|
||||
tooltip={t('participants.closeButton')}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
{participants?.length && (
|
||||
<VStack
|
||||
role="list"
|
||||
className={css({
|
||||
alignItems: 'start',
|
||||
gap: 'none',
|
||||
overflowY: 'scroll',
|
||||
overflowX: 'hidden',
|
||||
minHeight: 0,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
{allParticipants.map((participant, index) => (
|
||||
<HStack
|
||||
role="listitem"
|
||||
key={participant.id}
|
||||
id={participant.id}
|
||||
className={css({
|
||||
padding: '0.25rem 0',
|
||||
})}
|
||||
>
|
||||
<Avatar name={participant.name} />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '120px',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{capitalize(participant.name)}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { participantsStore } from '@/stores/participants'
|
||||
|
||||
export const ParticipantsToggle = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
@@ -16,8 +17,10 @@ export const ParticipantsToggle = () => {
|
||||
const participants = useParticipants()
|
||||
const numParticipants = participants?.length
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const tooltipLabel = isOpen ? 'open' : 'closed'
|
||||
const participantsSnap = useSnapshot(participantsStore)
|
||||
const showParticipants = participantsSnap.showParticipants
|
||||
|
||||
const tooltipLabel = showParticipants ? 'open' : 'closed'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -32,7 +35,8 @@ export const ParticipantsToggle = () => {
|
||||
legacyStyle
|
||||
aria-label={t(`controls.participants.${tooltipLabel}`)}
|
||||
tooltip={t(`controls.participants.${tooltipLabel}`)}
|
||||
onPress={() => setIsOpen(!isOpen)}
|
||||
isSelected={showParticipants}
|
||||
onPress={() => (participantsStore.showParticipants = !showParticipants)}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</Button>
|
||||
|
||||
33
src/frontend/src/features/rooms/livekit/constants/events.ts
Normal file
33
src/frontend/src/features/rooms/livekit/constants/events.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
|
||||
// Issue: 'allRemoteParticipantRoomEvents' is not exposed or importable. One event is missing
|
||||
// to trigger the real-time update of participants when they change their name.
|
||||
// This code is duplicated from LiveKit.
|
||||
export const allRemoteParticipantRoomEvents = [
|
||||
RoomEvent.ConnectionStateChanged,
|
||||
RoomEvent.RoomMetadataChanged,
|
||||
|
||||
RoomEvent.ActiveSpeakersChanged,
|
||||
RoomEvent.ConnectionQualityChanged,
|
||||
|
||||
RoomEvent.ParticipantConnected,
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
RoomEvent.ParticipantPermissionsChanged,
|
||||
RoomEvent.ParticipantMetadataChanged,
|
||||
RoomEvent.ParticipantNameChanged, // This element is missing in LiveKit and causes problems
|
||||
|
||||
RoomEvent.TrackMuted,
|
||||
RoomEvent.TrackUnmuted,
|
||||
RoomEvent.TrackPublished,
|
||||
RoomEvent.TrackUnpublished,
|
||||
RoomEvent.TrackStreamStateChanged,
|
||||
RoomEvent.TrackSubscriptionFailed,
|
||||
RoomEvent.TrackSubscriptionPermissionChanged,
|
||||
RoomEvent.TrackSubscriptionStatusChanged,
|
||||
]
|
||||
|
||||
export const allParticipantRoomEvents = [
|
||||
...allRemoteParticipantRoomEvents,
|
||||
RoomEvent.LocalTrackPublished,
|
||||
RoomEvent.LocalTrackUnpublished,
|
||||
]
|
||||
@@ -19,6 +19,7 @@ import { StartMediaButton } from '../components/controls/StartMediaButton'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { OptionsButton } from '../components/controls/Options/OptionsButton'
|
||||
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
@@ -187,6 +188,7 @@ export function ControlBar({
|
||||
{showIcon && <ChatIcon />}
|
||||
{showText && t('controls.chat')}
|
||||
</ChatToggle>
|
||||
<ParticipantsToggle />
|
||||
<OptionsButton />
|
||||
<DisconnectButton>
|
||||
{showIcon && <LeaveIcon />}
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
import { ControlBar } from './ControlBar'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { participantsStore } from '@/stores/participants'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -168,6 +171,9 @@ export function VideoConference({
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const participantsSnap = useSnapshot(participantsStore)
|
||||
const showParticipants = participantsSnap.showParticipants
|
||||
|
||||
return (
|
||||
<div className="lk-video-conference" {...props}>
|
||||
{isWeb() && (
|
||||
@@ -203,6 +209,7 @@ export function VideoConference({
|
||||
messageEncoder={chatMessageEncoder}
|
||||
messageDecoder={chatMessageDecoder}
|
||||
/>
|
||||
{showParticipants && <ParticipantsList />}
|
||||
</LayoutWrapper>
|
||||
<ControlBar />
|
||||
</div>
|
||||
|
||||
@@ -50,5 +50,10 @@
|
||||
"validationError": "",
|
||||
"submitLabel": ""
|
||||
}
|
||||
},
|
||||
"participants": {
|
||||
"heading": "",
|
||||
"closeButton": "",
|
||||
"you": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,10 @@
|
||||
"validationError": "Name cannot be empty.",
|
||||
"submitLabel": "Save"
|
||||
}
|
||||
},
|
||||
"participants": {
|
||||
"heading": "Participants",
|
||||
"closeButton": "Hide participants",
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,10 @@
|
||||
"validationError": "Le nom ne peut pas être vide.",
|
||||
"submitLabel": "Enregistrer"
|
||||
}
|
||||
},
|
||||
"participants": {
|
||||
"heading": "Participants",
|
||||
"closeButton": "Masquer les participants",
|
||||
"you": "Vous"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ export type ButtonProps = RecipeVariantProps<typeof button> &
|
||||
RACButtonsProps &
|
||||
Tooltip & {
|
||||
toggle?: boolean
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
|
||||
|
||||
9
src/frontend/src/stores/participants.ts
Normal file
9
src/frontend/src/stores/participants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
showParticipants: boolean
|
||||
}
|
||||
|
||||
export const participantsStore = proxy<State>({
|
||||
showParticipants: false,
|
||||
})
|
||||
7
src/frontend/src/utils/capitalize.ts
Normal file
7
src/frontend/src/utils/capitalize.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function capitalize(string: string) {
|
||||
if (!string) {
|
||||
return string
|
||||
}
|
||||
const trimmed = string.trim()
|
||||
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
|
||||
}
|
||||
Reference in New Issue
Block a user