✨(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:
committed by
aleb_the_flash
parent
a48501bc02
commit
65ddf2e2a1
@@ -3,4 +3,5 @@ export const keys = {
|
||||
room: 'room',
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export enum NotificationType {
|
||||
MessageReceived = 'messageReceived',
|
||||
LowerHand = 'lowerHand',
|
||||
ReactionReceived = 'reactionReceived',
|
||||
ParticipantWaiting = 'participantWaiting',
|
||||
}
|
||||
|
||||
37
src/frontend/src/features/rooms/api/enterRoom.ts
Normal file
37
src/frontend/src/features/rooms/api/enterRoom.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'}`, {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -209,7 +209,20 @@
|
||||
},
|
||||
"raisedHands": "",
|
||||
"lowerParticipantHand": "",
|
||||
"lowerParticipantsHand": ""
|
||||
"lowerParticipantsHand": "",
|
||||
"waiting": {
|
||||
"title": "",
|
||||
"accept": {
|
||||
"button": "",
|
||||
"label": "",
|
||||
"all": ""
|
||||
},
|
||||
"deny": {
|
||||
"button": "",
|
||||
"label": "",
|
||||
"all": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user