(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',
room: 'room',
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 = {
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
}

View File

@@ -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<ApiRoom>(
`/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 { 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 (
<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 (
<Screen footer={false}>
<div
@@ -353,35 +489,7 @@ export const Join = ({
},
})}
>
<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>
{renderWaitingState()}
</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) {
return (
<UserAware>
<Join onSubmit={setUserConfig} />
<Join onSubmit={setUserConfig} roomId={roomId} />
</UserAware>
)
}

View File

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

View File

@@ -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": {

View File

@@ -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": {