(frontend) update Join component to support lobby system

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.
This commit is contained in:
lebaudantoine
2025-02-18 23:55:48 +01:00
committed by aleb_the_flash
parent 4d961ed162
commit a48501bc02
10 changed files with 304 additions and 49 deletions

View File

@@ -2,4 +2,5 @@ export const keys = {
user: 'user', user: 'user',
room: 'room', room: 'room',
config: 'config', config: 'config',
requestEntry: 'requestEntry',
} }

View File

@@ -1,14 +1,21 @@
export type ApiLiveKit = {
url: string
room: string
token: string
}
export enum ApiAccessLevel {
PUBLIC = 'public',
RESTRICTED = 'restricted',
}
export type ApiRoom = { export type ApiRoom = {
id: string id: string
name: string name: string
slug: string slug: string
is_administrable: boolean is_administrable: boolean
access_level: string access_level: ApiAccessLevel
livekit?: { livekit?: ApiLiveKit
url: string
room: string
token: string
}
configuration?: { configuration?: {
[key: string]: string | number | boolean [key: string]: string | number | boolean
} }

View File

@@ -1,4 +1,3 @@
import { ApiError } from '@/api/ApiError'
import { type ApiRoom } from './ApiRoom' import { type ApiRoom } from './ApiRoom'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
@@ -11,10 +10,5 @@ export const fetchRoom = ({
}) => { }) => {
return fetchApi<ApiRoom>( return fetchApi<ApiRoom>(
`/rooms/${roomId}?username=${encodeURIComponent(username)}` `/rooms/${roomId}?username=${encodeURIComponent(username)}`
).then((room) => { )
if (!room.livekit?.token || !room.livekit?.url) {
throw new ApiError(500, 'LiveKit info not found')
}
return room
})
} }

View File

@@ -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<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
method: 'POST',
body: JSON.stringify({
username,
}),
})
}

View File

@@ -2,12 +2,12 @@ import { useTranslation } from 'react-i18next'
import { usePreviewTracks } from '@livekit/components-react' import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen' 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 { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H' import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice' import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field' 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 { HStack, VStack } from '@/styled-system/jsx'
import { LocalUserChoices } from '../routes/Room' import { LocalUserChoices } from '../routes/Room'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
@@ -19,6 +19,12 @@ import {
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core' 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) const onError = (e: Error) => console.error('ERROR', e)
@@ -99,8 +105,10 @@ const Effects = ({
export const Join = ({ export const Join = ({
onSubmit, onSubmit,
roomId,
}: { }: {
onSubmit: (choices: LocalUserChoices) => void onSubmit: (choices: LocalUserChoices) => void
roomId: string
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -195,7 +203,7 @@ export const Join = ({
} }
}, [videoTrack, videoEnabled]) }, [videoTrack, videoEnabled])
function handleSubmit() { const enterRoom = useCallback(() => {
onSubmit({ onSubmit({
audioEnabled, audioEnabled,
videoEnabled, videoEnabled,
@@ -204,6 +212,66 @@ export const Join = ({
username, username,
processorSerialized: processor?.serialize(), 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. // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack]) }, [videoTrack])
const renderWaitingState = () => {
switch (status) {
case ApiLobbyStatus.TIMEOUT:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false}>
{t('timeoutInvite.title')}
</H>
<P>{t('timeoutInvite.body')}</P>
</VStack>
)
case ApiLobbyStatus.DENIED:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false}>
{t('denied.title')}
</H>
<P>{t('denied.body')}</P>
</VStack>
)
case ApiLobbyStatus.WAITING:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false}>
{t('waiting.title')}
</H>
<P>{t('waiting.body')}</P>
<p>[Loading spinner]</p>
</VStack>
)
default:
return (
<Form
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin={false}>
{t('heading')}
</H>
<Field
type="text"
onChange={setUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
labelProps={{
center: true,
}}
maxLength={50}
/>
</VStack>
</Form>
)
}
}
return ( return (
<Screen footer={false}> <Screen footer={false}>
<div <div
@@ -353,35 +489,7 @@ export const Join = ({
}, },
})} })}
> >
<Form {renderWaitingState()}
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin={false}>
{t('heading')}
</H>
<Field
type="text"
onChange={setUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
labelProps={{
center: true,
}}
maxLength={50}
/>
</VStack>
</Form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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<NodeJS.Timeout | null>(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,
}
}

View File

@@ -47,7 +47,7 @@ export const Room = () => {
if (!userConfig && !skipJoinScreen) { if (!userConfig && !skipJoinScreen) {
return ( return (
<UserAware> <UserAware>
<Join onSubmit={setUserConfig} /> <Join onSubmit={setUserConfig} roomId={roomId} />
</UserAware> </UserAware>
) )
} }

View File

@@ -34,7 +34,19 @@
"usernameEmpty": "" "usernameEmpty": ""
}, },
"cameraDisabled": "", "cameraDisabled": "",
"cameraStarting": "" "cameraStarting": "",
"waiting": {
"title": "",
"body": ""
},
"denied": {
"title": "",
"body": ""
},
"timeoutInvite": {
"title": "",
"body": ""
}
}, },
"leaveRoomPrompt": "", "leaveRoomPrompt": "",
"shareDialog": { "shareDialog": {

View File

@@ -34,7 +34,19 @@
"usernameEmpty": "Your name cannot be empty" "usernameEmpty": "Your name cannot be empty"
}, },
"cameraDisabled": "Camera is disabled.", "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.", "leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": { "shareDialog": {

View File

@@ -34,7 +34,19 @@
"usernameEmpty": "Votre nom ne peut pas être vide" "usernameEmpty": "Votre nom ne peut pas être vide"
}, },
"cameraDisabled": "La caméra est désactivée.", "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.", "leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": { "shareDialog": {