diff --git a/src/frontend/src/api/queryKeys.ts b/src/frontend/src/api/queryKeys.ts index 17fa2dc9..1af695d8 100644 --- a/src/frontend/src/api/queryKeys.ts +++ b/src/frontend/src/api/queryKeys.ts @@ -2,4 +2,5 @@ export const keys = { user: 'user', room: 'room', config: 'config', + requestEntry: 'requestEntry', } diff --git a/src/frontend/src/features/rooms/api/ApiRoom.ts b/src/frontend/src/features/rooms/api/ApiRoom.ts index 5676cc99..826647c0 100644 --- a/src/frontend/src/features/rooms/api/ApiRoom.ts +++ b/src/frontend/src/features/rooms/api/ApiRoom.ts @@ -1,14 +1,21 @@ +export type ApiLiveKit = { + url: string + room: string + token: string +} + +export enum ApiAccessLevel { + PUBLIC = 'public', + RESTRICTED = 'restricted', +} + export type ApiRoom = { id: string name: string slug: string is_administrable: boolean - access_level: string - livekit?: { - url: string - room: string - token: string - } + access_level: ApiAccessLevel + livekit?: ApiLiveKit configuration?: { [key: string]: string | number | boolean } diff --git a/src/frontend/src/features/rooms/api/fetchRoom.ts b/src/frontend/src/features/rooms/api/fetchRoom.ts index cd2f9c0d..4c014d27 100644 --- a/src/frontend/src/features/rooms/api/fetchRoom.ts +++ b/src/frontend/src/features/rooms/api/fetchRoom.ts @@ -1,4 +1,3 @@ -import { ApiError } from '@/api/ApiError' import { type ApiRoom } from './ApiRoom' import { fetchApi } from '@/api/fetchApi' @@ -11,10 +10,5 @@ export const fetchRoom = ({ }) => { return fetchApi( `/rooms/${roomId}?username=${encodeURIComponent(username)}` - ).then((room) => { - if (!room.livekit?.token || !room.livekit?.url) { - throw new ApiError(500, 'LiveKit info not found') - } - return room - }) + ) } diff --git a/src/frontend/src/features/rooms/api/requestEntry.ts b/src/frontend/src/features/rooms/api/requestEntry.ts new file mode 100644 index 00000000..9e3017c3 --- /dev/null +++ b/src/frontend/src/features/rooms/api/requestEntry.ts @@ -0,0 +1,32 @@ +import { fetchApi } from '@/api/fetchApi' +import { ApiLiveKit } from '@/features/rooms/api/ApiRoom' + +export interface RequestEntryParams { + roomId: string + username?: string +} + +export enum ApiLobbyStatus { + IDLE = 'idle', + WAITING = 'waiting', + DENIED = 'denied', + TIMEOUT = 'timeout', + ACCEPTED = 'accepted', +} + +export interface ApiRequestEntry { + status: ApiLobbyStatus + livekit?: ApiLiveKit +} + +export const requestEntry = async ({ + roomId, + username = '', +}: RequestEntryParams) => { + return fetchApi(`/rooms/${roomId}/request-entry/`, { + method: 'POST', + body: JSON.stringify({ + username, + }), + }) +} diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 61cbfd39..a4c2a5ed 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -2,12 +2,12 @@ import { useTranslation } from 'react-i18next' import { usePreviewTracks } from '@livekit/components-react' import { css } from '@/styled-system/css' import { Screen } from '@/layout/Screen' -import { useMemo, useEffect, useRef, useState } from 'react' +import { useMemo, useEffect, useRef, useState, useCallback } from 'react' import { LocalVideoTrack, Track } from 'livekit-client' import { H } from '@/primitives/H' import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice' import { Field } from '@/primitives/Field' -import { Button, Dialog, Text, Form } from '@/primitives' +import { Button, Dialog, Text, Form, P } from '@/primitives' import { HStack, VStack } from '@/styled-system/jsx' import { LocalUserChoices } from '../routes/Room' import { Heading } from 'react-aria-components' @@ -19,6 +19,12 @@ import { import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices' import { BackgroundProcessorFactory } from '../livekit/components/blur' import { isMobileBrowser } from '@livekit/components-core' +import { fetchRoom } from '@/features/rooms/api/fetchRoom' +import { keys } from '@/api/queryKeys' +import { useLobby } from '../hooks/useLobby' +import { useQuery } from '@tanstack/react-query' +import { queryClient } from '@/api/queryClient' +import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry' const onError = (e: Error) => console.error('ERROR', e) @@ -99,8 +105,10 @@ const Effects = ({ export const Join = ({ onSubmit, + roomId, }: { onSubmit: (choices: LocalUserChoices) => void + roomId: string }) => { const { t } = useTranslation('rooms', { keyPrefix: 'join' }) @@ -195,7 +203,7 @@ export const Join = ({ } }, [videoTrack, videoEnabled]) - function handleSubmit() { + const enterRoom = useCallback(() => { onSubmit({ audioEnabled, videoEnabled, @@ -204,6 +212,66 @@ export const Join = ({ username, processorSerialized: processor?.serialize(), }) + }, [ + onSubmit, + audioEnabled, + videoEnabled, + audioDeviceId, + videoDeviceId, + username, + processor, + ]) + + // Room data strategy: + // 1. Initial fetch is performed to check access and get LiveKit configuration + // 2. Data remains valid for 6 hours to avoid unnecessary refetches + // 3. State is manually updated via queryClient when a waiting participant is accepted + // 4. No automatic refetching or revalidation occurs during this period + // todo - refactor in a hook + const { + data: roomData, + error, + isError, + refetch: refetchRoom, + } = useQuery({ + /* eslint-disable @tanstack/query/exhaustive-deps */ + queryKey: [keys.room, roomId], + queryFn: () => fetchRoom({ roomId, username }), + staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation + retry: false, + enabled: false, + }) + + useEffect(() => { + if (isError && error?.statusCode == 404) { + // The room component will handle the room creation if the user is authenticated + enterRoom() + } + }, [isError, error, enterRoom]) + + const handleAccepted = (response: ApiRequestEntry) => { + queryClient.setQueryData([keys.room, roomId], { + ...roomData, + livekit: response.livekit, + }) + enterRoom() + } + + const { status, startWaiting } = useLobby({ + roomId, + username, + onAccepted: handleAccepted, + }) + + const handleSubmit = async () => { + const { data } = await refetchRoom() + + if (!data?.livekit) { + startWaiting() + return + } + + enterRoom() } // This hook is used to setup the persisted user choice processor on initialization. @@ -216,6 +284,74 @@ export const Join = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [videoTrack]) + const renderWaitingState = () => { + switch (status) { + case ApiLobbyStatus.TIMEOUT: + return ( + + + {t('timeoutInvite.title')} + +

{t('timeoutInvite.body')}

+
+ ) + + case ApiLobbyStatus.DENIED: + return ( + + + {t('denied.title')} + +

{t('denied.body')}

+
+ ) + + case ApiLobbyStatus.WAITING: + return ( + + + {t('waiting.title')} + +

{t('waiting.body')}

+

[Loading spinner]

+
+ ) + + default: + return ( +
+ + + {t('heading')} + + !value && t('errors.usernameEmpty')} + wrapperProps={{ + noMargin: true, + fullWidth: true, + }} + labelProps={{ + center: true, + }} + maxLength={50} + /> + +
+ ) + } + } + return (
-
- - - {t('heading')} - - !value && t('errors.usernameEmpty')} - wrapperProps={{ - noMargin: true, - fullWidth: true, - }} - labelProps={{ - center: true, - }} - maxLength={50} - /> - -
+ {renderWaitingState()}
diff --git a/src/frontend/src/features/rooms/hooks/useLobby.ts b/src/frontend/src/features/rooms/hooks/useLobby.ts new file mode 100644 index 00000000..c7b59977 --- /dev/null +++ b/src/frontend/src/features/rooms/hooks/useLobby.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { keys } from '@/api/queryKeys' +import { + requestEntry, + ApiLobbyStatus, + ApiRequestEntry, +} from '../api/requestEntry' + +export const WAIT_TIMEOUT_MS = 600000 // 10 minutes +export const POLL_INTERVAL_MS = 1000 + +export const useLobby = ({ + roomId, + username, + onAccepted, +}: { + roomId: string + username: string + onAccepted: (e: ApiRequestEntry) => void +}) => { + const [status, setStatus] = useState(ApiLobbyStatus.IDLE) + const waitingTimeoutRef = useRef(null) + + const clearWaitingTimeout = useCallback(() => { + if (waitingTimeoutRef.current) { + clearTimeout(waitingTimeoutRef.current) + waitingTimeoutRef.current = null + } + }, []) + + const startWaitingTimeout = useCallback(() => { + clearWaitingTimeout() + waitingTimeoutRef.current = setTimeout(() => { + setStatus(ApiLobbyStatus.TIMEOUT) + }, WAIT_TIMEOUT_MS) + }, [clearWaitingTimeout]) + + const { data: waitingData } = useQuery({ + /* eslint-disable @tanstack/query/exhaustive-deps */ + queryKey: [keys.requestEntry, roomId], + queryFn: async () => { + const response = await requestEntry({ + roomId, + username, + }) + if (response.status === ApiLobbyStatus.ACCEPTED) { + clearWaitingTimeout() + setStatus(ApiLobbyStatus.ACCEPTED) + onAccepted(response) + } else if (response.status === ApiLobbyStatus.DENIED) { + clearWaitingTimeout() + setStatus(ApiLobbyStatus.DENIED) + } + return response + }, + refetchInterval: POLL_INTERVAL_MS, + refetchOnWindowFocus: false, + refetchIntervalInBackground: true, + enabled: status === ApiLobbyStatus.WAITING, + }) + + const startWaiting = useCallback(() => { + setStatus(ApiLobbyStatus.WAITING) + startWaitingTimeout() + }, [startWaitingTimeout]) + + useEffect(() => { + return () => clearWaitingTimeout() + }, [clearWaitingTimeout]) + + return { + status, + startWaiting, + waitingData, + } +} diff --git a/src/frontend/src/features/rooms/routes/Room.tsx b/src/frontend/src/features/rooms/routes/Room.tsx index c0c2cd7a..60d48bda 100644 --- a/src/frontend/src/features/rooms/routes/Room.tsx +++ b/src/frontend/src/features/rooms/routes/Room.tsx @@ -47,7 +47,7 @@ export const Room = () => { if (!userConfig && !skipJoinScreen) { return ( - + ) } diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 15d91583..fc853d0f 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -34,7 +34,19 @@ "usernameEmpty": "" }, "cameraDisabled": "", - "cameraStarting": "" + "cameraStarting": "", + "waiting": { + "title": "", + "body": "" + }, + "denied": { + "title": "", + "body": "" + }, + "timeoutInvite": { + "title": "", + "body": "" + } }, "leaveRoomPrompt": "", "shareDialog": { diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 2076fa60..06445ce8 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -34,7 +34,19 @@ "usernameEmpty": "Your name cannot be empty" }, "cameraDisabled": "Camera is disabled.", - "cameraStarting": "Camera is starting." + "cameraStarting": "Camera is starting.", + "waiting": { + "title": "Requesting to join...", + "body": "You will be able to join this call when someone authorizes you" + }, + "denied": { + "title": "You cannot join this call", + "body": "Your request to join has been denied." + }, + "timeoutInvite": { + "title": "You cannot join this call", + "body": "No one responded to your request" + } }, "leaveRoomPrompt": "This will make you leave the meeting.", "shareDialog": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index cae9c09d..efb619db 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -34,7 +34,19 @@ "usernameEmpty": "Votre nom ne peut pas être vide" }, "cameraDisabled": "La caméra est désactivée.", - "cameraStarting": "La caméra va démarrer." + "cameraStarting": "La caméra va démarrer.", + "waiting": { + "title": "Demande de participation…", + "body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera" + }, + "denied": { + "title": "Vous ne pouvez pas participer à cet appel", + "body": "Votre demande de participation a été refusée." + }, + "timeoutInvite": { + "title": "Vous ne pouvez pas participer à cet appel", + "body": "Personne n'a répondu à votre demande de participation à l'appel" + } }, "leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.", "shareDialog": {