From a48501bc02cdc086fb932a631fd7cd279c390b45 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 18 Feb 2025 23:55:48 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20update=20Join=20component?= =?UTF-8?q?=20to=20support=20lobby=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Join component to integrate with the newly introduced lobby system. Current implementation has functional UX but UI elements and copywriting still need refinement and will be polished in upcoming commits. --- src/frontend/src/api/queryKeys.ts | 1 + .../src/features/rooms/api/ApiRoom.ts | 19 +- .../src/features/rooms/api/fetchRoom.ts | 8 +- .../src/features/rooms/api/requestEntry.ts | 32 ++++ .../src/features/rooms/components/Join.tsx | 172 ++++++++++++++---- .../src/features/rooms/hooks/useLobby.ts | 77 ++++++++ .../src/features/rooms/routes/Room.tsx | 2 +- src/frontend/src/locales/de/rooms.json | 14 +- src/frontend/src/locales/en/rooms.json | 14 +- src/frontend/src/locales/fr/rooms.json | 14 +- 10 files changed, 304 insertions(+), 49 deletions(-) create mode 100644 src/frontend/src/features/rooms/api/requestEntry.ts create mode 100644 src/frontend/src/features/rooms/hooks/useLobby.ts 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": {