(frontend) add waiting participants list for room admins

Implement list showing waiting participants for admins already in the room.
Initial fetch on render, then stops polling if empty until LiveKit emits event
for new arrivals. Uses long polling with configurable timeouts to prevent UI
flicker. Focus on UX implementation with responsive layout issues remaining
for long participant names.
This commit is contained in:
lebaudantoine
2025-02-19 17:09:28 +01:00
committed by aleb_the_flash
parent a48501bc02
commit 65ddf2e2a1
11 changed files with 359 additions and 12 deletions

View File

@@ -3,4 +3,5 @@ export const keys = {
room: 'room', room: 'room',
config: 'config', config: 'config',
requestEntry: 'requestEntry', requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants',
} }

View File

@@ -5,4 +5,5 @@ export enum NotificationType {
MessageReceived = 'messageReceived', MessageReceived = 'messageReceived',
LowerHand = 'lowerHand', LowerHand = 'lowerHand',
ReactionReceived = 'reactionReceived', ReactionReceived = 'reactionReceived',
ParticipantWaiting = 'participantWaiting',
} }

View File

@@ -0,0 +1,37 @@
import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi'
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
export interface EnterRoomParams {
roomId: string
allowEntry: boolean
participantId: string
}
export interface EnterRoomResponse {
message?: string
}
export const enterRoom = async ({
roomId,
allowEntry,
participantId,
}: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
body: JSON.stringify({
participant_id: participantId,
allow_entry: allowEntry,
}),
})
}
export function useEnterRoom(
options?: UseMutationOptions<EnterRoomResponse, ApiError, EnterRoomParams>
) {
return useMutation<EnterRoomResponse, ApiError, EnterRoomParams>({
mutationFn: enterRoom,
onSuccess: options?.onSuccess,
...options,
})
}

View File

@@ -0,0 +1,52 @@
import { fetchApi } from '@/api/fetchApi'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { ApiError } from '@/api/ApiError'
import { keys } from '@/api/queryKeys'
export type WaitingParticipant = {
id: string
status: string
username: string
color: string
}
export type WaitingParticipantsResponse = {
participants: WaitingParticipant[]
}
export type WaitingParticipantsParams = {
roomId: string
}
export const listWaitingParticipants = async ({
roomId,
}: WaitingParticipantsParams): Promise<WaitingParticipantsResponse> => {
return fetchApi<WaitingParticipantsResponse>(
`/rooms/${roomId}/waiting-participants/`,
{
method: 'GET',
}
)
}
export const useListWaitingParticipants = (
roomId: string,
queryOptions?: Omit<
UseQueryOptions<
WaitingParticipantsResponse,
ApiError,
WaitingParticipantsResponse
>,
'queryKey'
>
) => {
return useQuery<
WaitingParticipantsResponse,
ApiError,
WaitingParticipantsResponse
>({
queryKey: [keys.waitingParticipants, roomId],
queryFn: () => listWaitingParticipants({ roomId }),
...queryOptions,
})
}

View File

@@ -0,0 +1,96 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useEnterRoom } from '../api/enterRoom'
import {
useListWaitingParticipants,
WaitingParticipant,
} from '../api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
export const POLL_INTERVAL_MS = 1000
export const useWaitingParticipants = () => {
const [listEnabled, setListEnabled] = useState(true)
const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const handleDataReceived = useCallback((payload: Uint8Array) => {
const { type } = decodeNotificationDataReceived(payload)
if (type === NotificationType.ParticipantWaiting) {
setListEnabled(true)
}
}, [])
useEffect(() => {
if (isAdminOrOwner) {
room.on(RoomEvent.DataReceived, handleDataReceived)
}
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [isAdminOrOwner, room, handleDataReceived])
const { data: waitingData, refetch: refetchWaiting } =
useListWaitingParticipants(roomId, {
retry: false,
enabled: listEnabled && isAdminOrOwner,
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: true,
})
const waitingParticipants = useMemo(
() => waitingData?.participants || [],
[waitingData]
)
useEffect(() => {
if (!waitingParticipants.length) setListEnabled(false)
}, [waitingParticipants])
const { mutateAsync: enterRoom } = useEnterRoom()
const handleParticipantEntry = async (
participant: WaitingParticipant,
allowEntry: boolean
) => {
await enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
await refetchWaiting()
}
const handleParticipantsEntry = async (
allowEntry: boolean
): Promise<void> => {
try {
setListEnabled(false)
for (const participant of waitingParticipants) {
await enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
}
await refetchWaiting()
} catch (e) {
console.error(e)
setListEnabled(true)
}
}
return {
waitingParticipants,
handleParticipantEntry,
handleParticipantsEntry,
}
}

View File

@@ -3,7 +3,6 @@ import { css } from '@/styled-system/css'
import { ToggleButton } from 'react-aria-components' import { ToggleButton } from 'react-aria-components'
import { HStack, styled, VStack } from '@/styled-system/jsx' import { HStack, styled, VStack } from '@/styled-system/jsx'
import { RiArrowUpSLine } from '@remixicon/react' import { RiArrowUpSLine } from '@remixicon/react'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const ToggleHeader = styled(ToggleButton, { const ToggleHeader = styled(ToggleButton, {
@@ -46,19 +45,19 @@ const ListContainer = styled(VStack, {
}, },
}) })
type ParticipantsCollapsableListProps = { export type ParticipantsCollapsableListProps<T> = {
heading: string heading: string
participants: Array<Participant> participants: Array<T>
renderParticipant: (participant: Participant) => JSX.Element renderParticipant: (participant: T) => JSX.Element
action?: () => JSX.Element action?: () => JSX.Element
} }
export const ParticipantsCollapsableList = ({ export function ParticipantsCollapsableList<T>({
heading, heading,
participants, participants,
renderParticipant, renderParticipant,
action, action,
}: ParticipantsCollapsableListProps) => { }: ParticipantsCollapsableListProps<T>) {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const [isOpen, setIsOpen] = useState(true) const [isOpen, setIsOpen] = useState(true)
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, { const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {

View File

@@ -1,12 +1,17 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react' import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives' import { Button, Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem' import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList' import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem' import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton' import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
import { HStack } from '@/styled-system/jsx'
import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short. // TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => { export const ParticipantsList = () => {
@@ -35,6 +40,12 @@ export const ParticipantsList = () => {
return data.raised return data.raised
}) })
const {
waitingParticipants,
handleParticipantEntry,
handleParticipantsEntry,
} = useWaitingParticipants()
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers // TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return ( return (
<Div overflowY="scroll"> <Div overflowY="scroll">
@@ -50,9 +61,42 @@ export const ParticipantsList = () => {
> >
{t('subheading').toUpperCase()} {t('subheading').toUpperCase()}
</H> </H>
{waitingParticipants?.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList<WaitingParticipant>
heading={t('waiting.title')}
participants={waitingParticipants}
renderParticipant={(participant) => (
<WaitingParticipantListItem
key={participant.id}
participant={participant}
onAction={handleParticipantEntry}
/>
)}
action={() => (
<HStack justify={'center'} width={'100%'}>
<Button
size="sm"
variant="secondaryText"
onPress={() => handleParticipantsEntry(false)}
>
{t('waiting.deny.all')}
</Button>
<Button
size="sm"
variant="secondaryText"
onPress={() => handleParticipantsEntry(true)}
>
{t('waiting.accept.all')}
</Button>
</HStack>
)}
/>
</Div>
)}
{raisedHandParticipants.length > 0 && ( {raisedHandParticipants.length > 0 && (
<Div marginBottom=".9375rem"> <Div marginBottom=".9375rem">
<ParticipantsCollapsableList <ParticipantsCollapsableList<Participant>
heading={t('raisedHands')} heading={t('raisedHands')}
participants={raisedHandParticipants} participants={raisedHandParticipants}
renderParticipant={(participant) => ( renderParticipant={(participant) => (
@@ -67,7 +111,7 @@ export const ParticipantsList = () => {
/> />
</Div> </Div>
)} )}
<ParticipantsCollapsableList <ParticipantsCollapsableList<Participant>
heading={t('contributors')} heading={t('contributors')}
participants={sortedParticipants} participants={sortedParticipants}
renderParticipant={(participant) => ( renderParticipant={(participant) => (

View File

@@ -0,0 +1,78 @@
import { Button, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
export const WaitingParticipantListItem = ({
participant,
onAction,
}: {
participant: WaitingParticipant
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
}) => {
const { t } = useTranslation('rooms')
return (
<HStack
role="listitem"
justify="space-between"
key={participant.id}
id={participant.id}
className={css({
padding: '0.25rem 0',
width: 'full',
})}
>
<HStack>
<Avatar name={participant.username} bgColor={participant.color} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
</HStack>
{/* FIXME - flex layout is broken when the participant name is long */}
<HStack
className={css({
marginRight: '0.5rem',
})}
>
<Button
size="sm"
variant="secondaryText"
onPress={() => onAction(participant, true)}
aria-label={t('waiting.accept.label', { name: participant.username })}
data-attr="participants-accept"
>
{t('participants.waiting.accept.button')}
</Button>
<Button
size="sm"
variant="secondaryText"
onPress={() => onAction(participant, false)}
aria-label={t('waiting.deny.label', { name: participant.username })}
data-attr="participants-deny"
>
{t('participants.waiting.deny.button')}
</Button>
</HStack>
</HStack>
)
}

View File

@@ -209,7 +209,20 @@
}, },
"raisedHands": "", "raisedHands": "",
"lowerParticipantHand": "", "lowerParticipantHand": "",
"lowerParticipantsHand": "" "lowerParticipantsHand": "",
"waiting": {
"title": "",
"accept": {
"button": "",
"label": "",
"all": ""
},
"deny": {
"button": "",
"label": "",
"all": ""
}
}
}, },
"recording": { "recording": {
"label": "" "label": ""

View File

@@ -208,7 +208,20 @@
}, },
"raisedHands": "Raised hands", "raisedHands": "Raised hands",
"lowerParticipantHand": "Lower {{name}}'s hand", "lowerParticipantHand": "Lower {{name}}'s hand",
"lowerParticipantsHand": "Lower all hands" "lowerParticipantsHand": "Lower all hands",
"waiting": {
"title": "Lobby",
"accept": {
"button": "Accept",
"label": "Accept {{name}} into the meeting",
"all": "Accept all"
},
"deny": {
"button": "Deny",
"label": "Deny {{name}} from the meeting",
"all": "Deny all"
}
}
}, },
"recording": { "recording": {
"label": "Recording" "label": "Recording"

View File

@@ -208,7 +208,20 @@
}, },
"raisedHands": "Mains levées", "raisedHands": "Mains levées",
"lowerParticipantHand": "Baisser la main de {{name}}", "lowerParticipantHand": "Baisser la main de {{name}}",
"lowerParticipantsHand": "Baisser la main de tous les participants" "lowerParticipantsHand": "Baisser la main de tous les participants",
"waiting": {
"title": "Salle d'attente",
"accept": {
"button": "Accepter",
"label": "Accepter {{name}} dans la réunion",
"all": "Tout accepter"
},
"deny": {
"button": "Refuser",
"label": "Refuser {{name}} dans la réunion",
"all": "Tout rejeter"
}
}
}, },
"recording": { "recording": {
"label": "Enregistrement" "label": "Enregistrement"