(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',
config: 'config',
requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants',
}

View File

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

View File

@@ -1,12 +1,17 @@
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives'
import { Button, Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
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.
export const ParticipantsList = () => {
@@ -35,6 +40,12 @@ export const ParticipantsList = () => {
return data.raised
})
const {
waitingParticipants,
handleParticipantEntry,
handleParticipantsEntry,
} = useWaitingParticipants()
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Div overflowY="scroll">
@@ -50,9 +61,42 @@ export const ParticipantsList = () => {
>
{t('subheading').toUpperCase()}
</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 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList
<ParticipantsCollapsableList<Participant>
heading={t('raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
@@ -67,7 +111,7 @@ export const ParticipantsList = () => {
/>
</Div>
)}
<ParticipantsCollapsableList
<ParticipantsCollapsableList<Participant>
heading={t('contributors')}
participants={sortedParticipants}
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": "",
"lowerParticipantHand": "",
"lowerParticipantsHand": ""
"lowerParticipantsHand": "",
"waiting": {
"title": "",
"accept": {
"button": "",
"label": "",
"all": ""
},
"deny": {
"button": "",
"label": "",
"all": ""
}
}
},
"recording": {
"label": ""

View File

@@ -208,7 +208,20 @@
},
"raisedHands": "Raised hands",
"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": {
"label": "Recording"

View File

@@ -208,7 +208,20 @@
},
"raisedHands": "Mains levées",
"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": {
"label": "Enregistrement"