From 943b81676b98892aaedff917b052e5086ac06dcf Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 24 Aug 2026 18:28:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20let=20authenticated=20use?= =?UTF-8?q?rs=20manage=20the=20lobby=20on=20trusted=20rooms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend counterpart of the trusted-lobby backend feature: on `trusted` rooms, any authenticated participant sees the waiting notification and can accept or deny entry requests, not only admins and owners. Gating moves from role to capability: `useCanManageLobby` mirrors the backend permission and derives from `useRoomData()`. Room metadata is already synced into the query cache, so an access-level change mid-meeting recomputes the capability on every client with no new sync mechanism. It is only a UI gate; the backend re-checks everything per request and fails closed. Fetching moves into a single room-level `LobbyProvider`: the hook was previously instantiated by two components and only worked because React Query deduplicated their queries. The provider owns one query and an explicit state machine - ways in (connection established, ParticipantWaiting broadcast, panel opened, rights regained while the panel is open) all arm and fetch; ways out (rights lost, server 401/403) disarm and clear the cached list so nothing stale can render. Polling is tiered by audience since managers grow from a few admins to potentially the whole room: 1s when acting (panel open), 10s when the notification is shown, and zero when the list is empty - decided in the refetchInterval callback because structural sharing suppresses data-keyed effects on identical empty responses. A quiet room costs nothing; fetches are triggered by uncorrelated human events, never synchronized across the room (the rights-regained trigger is panel-gated for this reason). --- .../hooks/useWaitingParticipants.ts | 47 ++------ .../rooms/components/LobbyProvider.tsx | 114 ++++++++++++++++++ .../rooms/livekit/hooks/useCanManageLobby.ts | 17 +++ .../rooms/livekit/prefabs/VideoConference.tsx | 2 + 4 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 src/frontend/src/features/rooms/components/LobbyProvider.tsx create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useCanManageLobby.ts diff --git a/src/frontend/src/features/participants/hooks/useWaitingParticipants.ts b/src/frontend/src/features/participants/hooks/useWaitingParticipants.ts index 96501634..5db3d471 100644 --- a/src/frontend/src/features/participants/hooks/useWaitingParticipants.ts +++ b/src/frontend/src/features/participants/hooks/useWaitingParticipants.ts @@ -1,61 +1,31 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useRoomContext } from '@livekit/components-react' -import { RoomEvent } from 'livekit-client' +import { useMemo } from 'react' + import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' -import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby' import { useEnterRoom } from '../api/enterRoom' import { useListWaitingParticipants, type WaitingParticipant, } from '../../participants/api/listWaitingParticipants' -import { decodeNotificationDataReceived } from '@/features/notifications/utils' -import { NotificationType } from '@/features/notifications/NotificationType' import { reportError } from '@/features/analytics/telemetry' -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 notification = decodeNotificationDataReceived(payload) - if (notification?.type === NotificationType.ParticipantWaiting) { - setListEnabled(true) - } - }, []) - - useEffect(() => { - if (isAdminOrOwner) { - room.on(RoomEvent.DataReceived, handleDataReceived) - } - return () => { - room.off(RoomEvent.DataReceived, handleDataReceived) - } - }, [isAdminOrOwner, room, handleDataReceived]) + const canManageLobby = useCanManageLobby() const { data: waitingData, refetch: refetchWaiting } = useListWaitingParticipants(roomId, { retry: false, - enabled: listEnabled && isAdminOrOwner, - refetchInterval: POLL_INTERVAL_MS, - refetchIntervalInBackground: true, + enabled: false, }) const waitingParticipants = useMemo( - () => waitingData?.participants || [], - [waitingData] + () => (canManageLobby ? waitingData?.participants || [] : []), + [waitingData, canManageLobby] ) - useEffect(() => { - if (!waitingParticipants.length) setListEnabled(false) - }, [waitingParticipants]) - const { mutateAsync: enterRoom } = useEnterRoom() const handleParticipantEntry = async ( @@ -74,8 +44,6 @@ export const useWaitingParticipants = () => { allowEntry: boolean ): Promise => { try { - setListEnabled(false) - await Promise.all( waitingParticipants.map((participant) => enterRoom({ @@ -89,7 +57,6 @@ export const useWaitingParticipants = () => { await refetchWaiting() } catch (e) { reportError('generic_failure', e) - setListEnabled(true) } } diff --git a/src/frontend/src/features/rooms/components/LobbyProvider.tsx b/src/frontend/src/features/rooms/components/LobbyProvider.tsx new file mode 100644 index 00000000..552f81ee --- /dev/null +++ b/src/frontend/src/features/rooms/components/LobbyProvider.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect } from 'react' +import { useConnectionState, useRoomContext } from '@livekit/components-react' +import { ConnectionState, RoomEvent } from 'livekit-client' +import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { useListWaitingParticipants } from '@/features/participants/api/listWaitingParticipants' +import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' +import { decodeNotificationDataReceived } from '@/features/notifications/utils' +import { NotificationType } from '@/features/notifications' +import { usePrevious } from '@/hooks/usePrevious' +import { keys } from '@/api/queryKeys' +import { queryClient } from '@/api/queryClient' +import { ApiError } from '@/api/ApiError' + +export const POLL_INTERVAL_MS = 1000 +export const LAZY_POLL_INTERVAL_MS = 10_000 + +export const LobbyProvider = () => { + const room = useRoomContext() + + const canManageLobby = useCanManageLobby() + const roomData = useRoomData() + const { isParticipantsOpen } = useSidePanel() + const isConnected = useConnectionState(room) === ConnectionState.Connected + + const roomId = roomData?.id || '' // FIXME - bad practice + + const { error: waitingError, refetch: refetchWaiting } = + useListWaitingParticipants(roomId, { + retry: false, + enabled: canManageLobby && isConnected && !!roomId, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchInterval: (query) => { + if (!query.state.data?.participants?.length) return false + if (isParticipantsOpen) return POLL_INTERVAL_MS + return LAZY_POLL_INTERVAL_MS + }, + refetchIntervalInBackground: true, + }) + + // Triggers: each one-shot, idempotent, deduped by React Query if + // concurrent. The interval takes over whenever a fetch finds waiters. + const fetchIfManager = useCallback(() => { + if (canManageLobby) refetchWaiting() + }, [canManageLobby, refetchWaiting]) + + // 1. Connection established (join or reconnect) + useEffect(() => { + room.on(RoomEvent.Connected, fetchIfManager) + room.on(RoomEvent.Reconnected, fetchIfManager) + return () => { + room.off(RoomEvent.Connected, fetchIfManager) + room.off(RoomEvent.Reconnected, fetchIfManager) + } + }, [room, fetchIfManager]) + + // 2. Someone started waiting (LiveKit broadcast). + const handleDataReceived = useCallback( + (payload: Uint8Array) => { + const notification = decodeNotificationDataReceived(payload) + if (notification?.type === NotificationType.ParticipantWaiting) { + fetchIfManager() + } + }, + [fetchIfManager] + ) + + useEffect(() => { + if (canManageLobby) { + room.on(RoomEvent.DataReceived, handleDataReceived) + } + return () => { + room.off(RoomEvent.DataReceived, handleDataReceived) + } + }, [canManageLobby, room, handleDataReceived]) + + // 3. Rights regained. + const prevCanManageLobby = usePrevious(canManageLobby) + useEffect(() => { + if (!prevCanManageLobby && canManageLobby && isConnected) { + fetchIfManager() + } + }, [ + prevCanManageLobby, + canManageLobby, + isParticipantsOpen, + fetchIfManager, + isConnected, + ]) + + const clearWaitingList = useCallback(() => { + const queryKey = [keys.waitingParticipants, roomId] + queryClient.cancelQueries({ queryKey }) + queryClient.setQueryData(queryKey, { participants: [] }) + }, [roomId]) + + // Rights lost mid-meeting (covers trusted -> restricted/public). + useEffect(() => { + if (prevCanManageLobby && !canManageLobby) clearWaitingList() + }, [prevCanManageLobby, canManageLobby, clearWaitingList]) + + useEffect(() => { + if ( + waitingError instanceof ApiError && + [401, 403].includes(waitingError.statusCode) + ) { + clearWaitingList() + } + }, [waitingError, clearWaitingList]) + + return null +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useCanManageLobby.ts b/src/frontend/src/features/rooms/livekit/hooks/useCanManageLobby.ts new file mode 100644 index 00000000..847f075a --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useCanManageLobby.ts @@ -0,0 +1,17 @@ +import { useUser } from '@/features/auth/api/useUser' +import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom' +import { useIsAdminOrOwner } from './useIsAdminOrOwner' +import { useRoomData } from './useRoomData' + +export const useCanManageLobby = () => { + const isAdminOrOwner = useIsAdminOrOwner() + const { isLoggedIn } = useUser() + const roomData = useRoomData() + + return ( + (isAdminOrOwner || + (isLoggedIn === true && + roomData?.access_level === ApiAccessLevel.TRUSTED)) && + roomData?.access_level !== ApiAccessLevel.PUBLIC + ) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 9fd112f7..41a8fe96 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -31,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer' import { ChatProvider } from '@/features/chat/components/ChatProvider' import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences' import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector' +import { LobbyProvider } from '@/features/rooms/components/LobbyProvider' /** * @public @@ -120,6 +121,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { +