From 65ddf2e2a1a6cd90032c060c7751e704e4bede26 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 19 Feb 2025 17:09:28 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20waiting=20participa?= =?UTF-8?q?nts=20list=20for=20room=20admins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/frontend/src/api/queryKeys.ts | 1 + .../notifications/NotificationType.ts | 1 + .../src/features/rooms/api/enterRoom.ts | 37 +++++++ .../rooms/api/listWaitingParticipants.ts | 52 ++++++++++ .../rooms/hooks/useWaitingParticipants.ts | 96 +++++++++++++++++++ .../ParticipantsCollapsableList.tsx | 11 +-- .../Participants/ParticipantsList.tsx | 50 +++++++++- .../WaitingParticipantListItem.tsx | 78 +++++++++++++++ src/frontend/src/locales/de/rooms.json | 15 ++- src/frontend/src/locales/en/rooms.json | 15 ++- src/frontend/src/locales/fr/rooms.json | 15 ++- 11 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 src/frontend/src/features/rooms/api/enterRoom.ts create mode 100644 src/frontend/src/features/rooms/api/listWaitingParticipants.ts create mode 100644 src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx diff --git a/src/frontend/src/api/queryKeys.ts b/src/frontend/src/api/queryKeys.ts index 1af695d8..96c01d77 100644 --- a/src/frontend/src/api/queryKeys.ts +++ b/src/frontend/src/api/queryKeys.ts @@ -3,4 +3,5 @@ export const keys = { room: 'room', config: 'config', requestEntry: 'requestEntry', + waitingParticipants: 'waitingParticipants', } diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index 2f0ecf3d..74974ecf 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -5,4 +5,5 @@ export enum NotificationType { MessageReceived = 'messageReceived', LowerHand = 'lowerHand', ReactionReceived = 'reactionReceived', + ParticipantWaiting = 'participantWaiting', } diff --git a/src/frontend/src/features/rooms/api/enterRoom.ts b/src/frontend/src/features/rooms/api/enterRoom.ts new file mode 100644 index 00000000..a0564818 --- /dev/null +++ b/src/frontend/src/features/rooms/api/enterRoom.ts @@ -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 => { + return await fetchApi(`/rooms/${roomId}/enter/`, { + method: 'POST', + body: JSON.stringify({ + participant_id: participantId, + allow_entry: allowEntry, + }), + }) +} + +export function useEnterRoom( + options?: UseMutationOptions +) { + return useMutation({ + mutationFn: enterRoom, + onSuccess: options?.onSuccess, + ...options, + }) +} diff --git a/src/frontend/src/features/rooms/api/listWaitingParticipants.ts b/src/frontend/src/features/rooms/api/listWaitingParticipants.ts new file mode 100644 index 00000000..43c07a44 --- /dev/null +++ b/src/frontend/src/features/rooms/api/listWaitingParticipants.ts @@ -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 => { + return fetchApi( + `/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, + }) +} diff --git a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts new file mode 100644 index 00000000..7b83f55a --- /dev/null +++ b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts @@ -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 => { + 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, + } +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx index 46699875..093559bf 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx @@ -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 = { heading: string - participants: Array - renderParticipant: (participant: Participant) => JSX.Element + participants: Array + renderParticipant: (participant: T) => JSX.Element action?: () => JSX.Element } -export const ParticipantsCollapsableList = ({ +export function ParticipantsCollapsableList({ heading, participants, renderParticipant, action, -}: ParticipantsCollapsableListProps) => { +}: ParticipantsCollapsableListProps) { const { t } = useTranslation('rooms') const [isOpen, setIsOpen] = useState(true) const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, { diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx index 9453e6a1..19b85a3a 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx @@ -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 (
@@ -50,9 +61,42 @@ export const ParticipantsList = () => { > {t('subheading').toUpperCase()} + {waitingParticipants?.length > 0 && ( +
+ + heading={t('waiting.title')} + participants={waitingParticipants} + renderParticipant={(participant) => ( + + )} + action={() => ( + + + + + )} + /> +
+ )} {raisedHandParticipants.length > 0 && (
- heading={t('raisedHands')} participants={raisedHandParticipants} renderParticipant={(participant) => ( @@ -67,7 +111,7 @@ export const ParticipantsList = () => { />
)} - heading={t('contributors')} participants={sortedParticipants} renderParticipant={(participant) => ( diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx new file mode 100644 index 00000000..0acc38cb --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx @@ -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 ( + + + + + + {participant.username} + + + + {/* FIXME - flex layout is broken when the participant name is long */} + + + + + + ) +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index fc853d0f..4ea6442b 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -209,7 +209,20 @@ }, "raisedHands": "", "lowerParticipantHand": "", - "lowerParticipantsHand": "" + "lowerParticipantsHand": "", + "waiting": { + "title": "", + "accept": { + "button": "", + "label": "", + "all": "" + }, + "deny": { + "button": "", + "label": "", + "all": "" + } + } }, "recording": { "label": "" diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 06445ce8..6c596702 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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" diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index efb619db..d3795976 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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"