✨(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:
committed by
aleb_the_flash
parent
4d961ed162
commit
a48501bc02
@@ -2,4 +2,5 @@ export const keys = {
|
||||
user: 'user',
|
||||
room: 'room',
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
32
src/frontend/src/features/rooms/api/requestEntry.ts
Normal file
32
src/frontend/src/features/rooms/api/requestEntry.ts
Normal 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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
77
src/frontend/src/features/rooms/hooks/useLobby.ts
Normal file
77
src/frontend/src/features/rooms/hooks/useLobby.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export const Room = () => {
|
||||
if (!userConfig && !skipJoinScreen) {
|
||||
return (
|
||||
<UserAware>
|
||||
<Join onSubmit={setUserConfig} />
|
||||
<Join onSubmit={setUserConfig} roomId={roomId} />
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,19 @@
|
||||
"usernameEmpty": ""
|
||||
},
|
||||
"cameraDisabled": "",
|
||||
"cameraStarting": ""
|
||||
"cameraStarting": "",
|
||||
"waiting": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"denied": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"timeoutInvite": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user