mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
✨(frontend) add connection test feature
Introduce a new connection test page to allow users to verify their device and network compatibility with the application. The feature also supports generating and downloading a detailed report of the test results.
This commit is contained in:
@@ -12,6 +12,7 @@ and this project adheres to
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
- ✨(frontend) add connection test feature
|
||||
|
||||
## Fixed
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type ConnectionTestTokenResponse = {
|
||||
livekit: {
|
||||
url: string
|
||||
room: string
|
||||
token: string
|
||||
expires_in: number
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchConnectionTestToken = () =>
|
||||
fetchApi<ConnectionTestTokenResponse>('/connection-test/')
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
CheckStatus,
|
||||
ConnectionCheck,
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
getBrowser,
|
||||
type CheckInfo,
|
||||
type LocalVideoTrack,
|
||||
} from 'livekit-client'
|
||||
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
|
||||
import {
|
||||
createInitialSteps,
|
||||
type ConnectionTestLog,
|
||||
type ConnectionTestStepId,
|
||||
type ConnectionTestStepResult,
|
||||
type ConnectionTestStepStatus,
|
||||
} from '../types'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
|
||||
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
|
||||
'websocket',
|
||||
'webrtc',
|
||||
'turn',
|
||||
'reconnect',
|
||||
'publishAudio',
|
||||
'publishVideo',
|
||||
]
|
||||
|
||||
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
|
||||
[CheckStatus.IDLE]: 'pending',
|
||||
[CheckStatus.RUNNING]: 'running',
|
||||
[CheckStatus.SUCCESS]: 'success',
|
||||
[CheckStatus.FAILED]: 'failed',
|
||||
[CheckStatus.SKIPPED]: 'skipped',
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||
error instanceof Error ? error.message : fallback
|
||||
|
||||
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||
summary: info.description,
|
||||
logs: info.logs,
|
||||
})
|
||||
|
||||
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||
const grouped: Record<MediaDeviceKind, string[]> = {
|
||||
audioinput: [],
|
||||
audiooutput: [],
|
||||
videoinput: [],
|
||||
}
|
||||
for (const device of devices) {
|
||||
grouped[device.kind].push(device.label || device.deviceId)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
export const useConnectionTestRunner = () => {
|
||||
const [steps, setSteps] = useState(createInitialSteps)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
|
||||
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const updateStep = (
|
||||
id: ConnectionTestStepId,
|
||||
patch: Partial<ConnectionTestStepResult>
|
||||
) => {
|
||||
setSteps((current) =>
|
||||
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
|
||||
)
|
||||
}
|
||||
|
||||
const stopVideoTrack = () => {
|
||||
videoTrackRef.current?.stop()
|
||||
videoTrackRef.current = null
|
||||
setVideoTrack(null)
|
||||
}
|
||||
|
||||
const skipSteps = (
|
||||
ids: ConnectionTestStepId[],
|
||||
summary: string,
|
||||
logs?: ConnectionTestLog[]
|
||||
) => {
|
||||
for (const id of ids) {
|
||||
updateStep(id, { status: 'skipped', summary, logs })
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true on success, false on failure, null if aborted. */
|
||||
const runStep = async (
|
||||
id: ConnectionTestStepId,
|
||||
signal: AbortSignal,
|
||||
fn: () => Promise<Partial<ConnectionTestStepResult>>
|
||||
): Promise<boolean | null> => {
|
||||
if (signal.aborted) return null
|
||||
|
||||
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
if (signal.aborted) return null
|
||||
// `result.status` overrides when set (LiveKit checks map their own status)
|
||||
updateStep(id, { status: 'success', ...result })
|
||||
return true
|
||||
} catch (error) {
|
||||
if (signal.aborted) return null
|
||||
updateStep(id, {
|
||||
status: 'failed',
|
||||
summary: getErrorMessage(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const { signal } = controller
|
||||
|
||||
setIsRunning(true)
|
||||
setSteps(createInitialSteps())
|
||||
stopVideoTrack()
|
||||
|
||||
try {
|
||||
await runStep('browser', signal, async () => {
|
||||
const browser = getBrowser()
|
||||
if (!browser) throw new Error('Browser not detected')
|
||||
return {
|
||||
summary: `${browser.name} ${browser.version}`,
|
||||
data: {
|
||||
name: browser.name,
|
||||
version: browser.version,
|
||||
os: browser.os,
|
||||
osVersion: browser.osVersion,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
|
||||
const microphoneOk = await runStep('microphone', signal, async () => {
|
||||
const track = await createLocalAudioTrack()
|
||||
const label =
|
||||
track.mediaStreamTrack.label ||
|
||||
track.mediaStreamTrack.getSettings().deviceId ||
|
||||
''
|
||||
track.stop()
|
||||
return { summary: label, data: { label } }
|
||||
})
|
||||
if (signal.aborted) return
|
||||
if (!microphoneOk) openPermissionsDialog('audioinput')
|
||||
|
||||
const cameraOk = await runStep('camera', signal, async () => {
|
||||
const track = await createLocalVideoTrack()
|
||||
videoTrackRef.current = track
|
||||
setVideoTrack(track)
|
||||
const settings = track.mediaStreamTrack.getSettings()
|
||||
const label = track.mediaStreamTrack.label || ''
|
||||
return {
|
||||
summary: label,
|
||||
data: {
|
||||
label,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
if (!cameraOk) openPermissionsDialog('videoinput')
|
||||
|
||||
await runStep('devices', signal, async () => {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
return {
|
||||
summary: String(devices.length),
|
||||
data: groupDevicesByKind(devices),
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
|
||||
let checker: ConnectionCheck
|
||||
try {
|
||||
const { livekit } = await fetchConnectionTestToken()
|
||||
checker = new ConnectionCheck(livekit.url, livekit.token)
|
||||
} catch (error) {
|
||||
skipSteps(
|
||||
LIVEKIT_STEP_IDS,
|
||||
getErrorMessage(error, 'Failed to fetch test token')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await runStep('websocket', signal, async () =>
|
||||
fromCheckInfo(await checker.checkWebsocket())
|
||||
)
|
||||
await runStep('webrtc', signal, async () =>
|
||||
fromCheckInfo(await checker.checkWebRTC())
|
||||
)
|
||||
await runStep('turn', signal, async () =>
|
||||
fromCheckInfo(await checker.checkTURN())
|
||||
)
|
||||
await runStep('reconnect', signal, async () =>
|
||||
fromCheckInfo(await checker.checkReconnect())
|
||||
)
|
||||
|
||||
if (!microphoneOk) {
|
||||
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||
} else {
|
||||
await runStep('publishAudio', signal, async () =>
|
||||
fromCheckInfo(await checker.checkPublishAudio())
|
||||
)
|
||||
}
|
||||
|
||||
if (!cameraOk) {
|
||||
skipSteps(['publishVideo'], 'Camera permission required')
|
||||
} else {
|
||||
stopVideoTrack()
|
||||
await runStep('publishVideo', signal, async () =>
|
||||
fromCheckInfo(await checker.checkPublishVideo())
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
stopVideoTrack()
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
abortRef.current?.abort()
|
||||
stopVideoTrack()
|
||||
setSteps(createInitialSteps())
|
||||
setIsRunning(false)
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
isRunning,
|
||||
videoTrack,
|
||||
runTest,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Box, Button, Text, Ul } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Center, HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Permissions } from '@/features/rooms/components/Permissions'
|
||||
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
||||
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
|
||||
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
|
||||
|
||||
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
|
||||
|
||||
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const hasLogs = Boolean(step.logs?.length)
|
||||
const statusLabel = t(`status.${step.status}`)
|
||||
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
|
||||
|
||||
const statusVariant =
|
||||
step.status === 'failed'
|
||||
? 'warning'
|
||||
: step.status === 'success'
|
||||
? 'body'
|
||||
: 'smNote'
|
||||
|
||||
return (
|
||||
<VStack gap="0.25rem" alignItems="stretch" width="100%">
|
||||
<HStack
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
width="100%"
|
||||
>
|
||||
<Text variant="bodyXsMedium">{stepLabel}</Text>
|
||||
{step.status === 'running' ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<Text variant={statusVariant}>{statusLabel}</Text>
|
||||
)}
|
||||
</HStack>
|
||||
{step.summary && (
|
||||
<Text variant="smNote" margin={false}>
|
||||
{step.summary}
|
||||
</Text>
|
||||
)}
|
||||
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size="sm"
|
||||
onPress={() => setShowDetails((open) => !open)}
|
||||
>
|
||||
{t('details')}
|
||||
</Button>
|
||||
)}
|
||||
{showDetails && step.logs && (
|
||||
<Ul>
|
||||
{step.logs.map((log, index) => (
|
||||
<li key={index}>
|
||||
<Text variant="xsNote" as="span">
|
||||
{log.message}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</Ul>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectionTest = () => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
const hasStarted = steps.some((step) => step.status !== 'pending')
|
||||
const hasFailed = steps.some((step) => step.status === 'failed')
|
||||
const isPublishVideoRunning = steps.some(
|
||||
(step) => step.id === 'publishVideo' && step.status === 'running'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const element = videoRef.current
|
||||
if (!element || !videoTrack) return
|
||||
|
||||
videoTrack.attach(element)
|
||||
return () => {
|
||||
videoTrack.detach(element)
|
||||
}
|
||||
}, [videoTrack])
|
||||
|
||||
// LiveKit appends a bare <video> to document.body during publishVideo.
|
||||
// Keep it in the DOM (so the frame check still works) but hide it visually.
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle(
|
||||
HIDE_LIVEKIT_VIDEO_CLASS,
|
||||
isPublishVideoRunning
|
||||
)
|
||||
return () => {
|
||||
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
|
||||
}
|
||||
}, [isPublishVideoRunning])
|
||||
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<Permissions />
|
||||
<CenteredContent title={t('title')} withBackButton>
|
||||
<Center>
|
||||
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
|
||||
<Text as="p" variant="paragraph" centered last>
|
||||
{t('intro')}
|
||||
</Text>
|
||||
|
||||
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
|
||||
{hasStarted ? t('reset') : t('runTest')}
|
||||
</Button>
|
||||
|
||||
{videoTrack && (
|
||||
<Center>
|
||||
<video ref={videoRef} autoPlay playsInline muted />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{hasStarted && (
|
||||
<Box variant="light" width="100%">
|
||||
<VStack gap="1rem" alignItems="stretch">
|
||||
{steps.map((step) => (
|
||||
<TestStepItem key={step.id} step={step} />
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasFailed && !isRunning && (
|
||||
<Text variant="smNote" centered>
|
||||
{t('help.firewall')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasStarted && !isRunning && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={() => downloadConnectionTestReport(steps)}
|
||||
>
|
||||
{t('downloadReport')}
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectionTest
|
||||
@@ -0,0 +1,47 @@
|
||||
export type ConnectionTestStepId =
|
||||
| 'browser'
|
||||
| 'microphone'
|
||||
| 'camera'
|
||||
| 'devices'
|
||||
| 'websocket'
|
||||
| 'webrtc'
|
||||
| 'turn'
|
||||
| 'reconnect'
|
||||
| 'publishAudio'
|
||||
| 'publishVideo'
|
||||
|
||||
export type ConnectionTestStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
|
||||
export type ConnectionTestLog = {
|
||||
level: 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConnectionTestStepResult = {
|
||||
id: ConnectionTestStepId
|
||||
status: ConnectionTestStepStatus
|
||||
summary?: string
|
||||
logs?: ConnectionTestLog[]
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
|
||||
'browser',
|
||||
'microphone',
|
||||
'camera',
|
||||
'devices',
|
||||
'websocket',
|
||||
'webrtc',
|
||||
'turn',
|
||||
'reconnect',
|
||||
'publishAudio',
|
||||
'publishVideo',
|
||||
]
|
||||
|
||||
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ConnectionTestStepResult } from '../types'
|
||||
|
||||
export type ConnectionTestReport = {
|
||||
generatedAt: string
|
||||
userAgent: string
|
||||
steps: Record<
|
||||
string,
|
||||
{
|
||||
status: ConnectionTestStepResult['status']
|
||||
summary?: string
|
||||
logs?: ConnectionTestStepResult['logs']
|
||||
data?: ConnectionTestStepResult['data']
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export const buildConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestReport => ({
|
||||
generatedAt: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
steps: Object.fromEntries(
|
||||
steps.map(({ id, status, summary, logs, data }) => [
|
||||
id,
|
||||
{
|
||||
status,
|
||||
...(summary !== undefined ? { summary } : {}),
|
||||
...(logs?.length ? { logs } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
},
|
||||
])
|
||||
),
|
||||
})
|
||||
|
||||
export const downloadConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
) => {
|
||||
const report = buildConnectionTestReport(steps)
|
||||
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `connection-test-${timestamp}.json`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -266,6 +266,16 @@ export const Footer = () => {
|
||||
{t('links.accessibility')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
to="/test-connection"
|
||||
aria-label={t('links.connectionTest')}
|
||||
>
|
||||
{t('links.connectionTest')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"title": "Test your configuration",
|
||||
"intro": "Check that your device works with Visio: browser, media devices, and server connectivity.",
|
||||
"runTest": "Run test",
|
||||
"reset": "Run again",
|
||||
"details": "Details",
|
||||
"downloadReport": "Download report",
|
||||
"homeLink": "Test your configuration",
|
||||
"steps": {
|
||||
"browser": "Browser",
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
"devices": "Media devices",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconnect",
|
||||
"publishAudio": "Audio publishing",
|
||||
"publishVideo": "Video publishing"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"running": "Running…",
|
||||
"success": "Passed",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"help": {
|
||||
"firewall": "If network tests fail, check your browser permissions and network filtering rules (WebRTC, WebSocket, TURN) with your IT department."
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"legalsTerms": "Legal Notice",
|
||||
"data": "Personal Data and Cookies",
|
||||
"accessibility": "Accessibility: non-compliant",
|
||||
"connectionTest": "Test your configuration",
|
||||
"ariaLabel": "new window",
|
||||
"codeAnnotation": "Our code is open and available on this",
|
||||
"code": "Open Source Code Repository",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||
"moreLink": "Learn more",
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
"connectionTestLink": "Test your configuration",
|
||||
"createMenu": {
|
||||
"laterOption": "Create a meeting for a later date",
|
||||
"instantOption": "Start an instant meeting"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"title": "Tester votre configuration",
|
||||
"intro": "Vérifiez la compatibilité de votre poste avec Visio : navigateur, périphériques médias et connexion au serveur.",
|
||||
"runTest": "Lancer le test",
|
||||
"reset": "Relancer",
|
||||
"details": "Détails",
|
||||
"downloadReport": "Télécharger le rapport",
|
||||
"homeLink": "Tester votre configuration",
|
||||
"steps": {
|
||||
"browser": "Navigateur",
|
||||
"microphone": "Microphone",
|
||||
"camera": "Caméra",
|
||||
"devices": "Périphériques médias",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconnexion",
|
||||
"publishAudio": "Publication audio",
|
||||
"publishVideo": "Publication vidéo"
|
||||
},
|
||||
"status": {
|
||||
"pending": "En attente",
|
||||
"running": "En cours…",
|
||||
"success": "Réussi",
|
||||
"failed": "Échec",
|
||||
"skipped": "Ignoré"
|
||||
},
|
||||
"help": {
|
||||
"firewall": "En cas d'échec des tests réseau, vérifiez vos permissions navigateur et les règles de filtrage réseau (WebRTC, WebSocket, TURN) auprès de votre service informatique."
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"legalsTerms": "Mentions légales",
|
||||
"data": "Données personnelles et cookie",
|
||||
"accessibility": "Accessibilité : non conforme",
|
||||
"connectionTest": "Tester votre configuration",
|
||||
"ariaLabel": "nouvelle fenêtre",
|
||||
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
||||
"code": "dépôt de code Open Source",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||
"moreLink": "En savoir plus",
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
"connectionTestLink": "Tester votre configuration",
|
||||
"createMenu": {
|
||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||
"instantOption": "Démarrer une réunion instantanée"
|
||||
|
||||
@@ -20,6 +20,9 @@ const AccessibilityRoute = lazy(
|
||||
)
|
||||
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
||||
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
||||
const ConnectionTestRoute = lazy(
|
||||
() => import('@/features/connection-test/routes/ConnectionTest')
|
||||
)
|
||||
|
||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||
|
||||
@@ -27,6 +30,7 @@ export const routes: Record<
|
||||
| 'home'
|
||||
| 'room'
|
||||
| 'feedback'
|
||||
| 'connectionTest'
|
||||
| 'legalTerms'
|
||||
| 'accessibility'
|
||||
| 'termsOfService'
|
||||
@@ -57,6 +61,11 @@ export const routes: Record<
|
||||
path: '/feedback',
|
||||
Component: FeedbackRoute,
|
||||
},
|
||||
connectionTest: {
|
||||
name: 'connectionTest',
|
||||
path: '/test-connection',
|
||||
Component: ConnectionTestRoute,
|
||||
},
|
||||
legalTerms: {
|
||||
name: 'legalTerms',
|
||||
path: '/mentions-legales',
|
||||
|
||||
@@ -31,6 +31,19 @@ html.font-opendyslexic {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* LiveKit ConnectionCheck appends a temporary <video> to body during publishVideo.
|
||||
Keep it decodable (not display:none) but invisible. */
|
||||
body.connection-test-hide-livekit-video > video {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user