Compare commits

...

25 Commits

Author SHA1 Message Date
lebaudantoine 18ef94dc10 wip fix build 2026-08-10 13:05:17 +02:00
lebaudantoine a9be74a5a4 wip try triggering a request permission on toggle 2026-08-10 13:04:14 +02:00
lebaudantoine dff8c5696e fixup! wip soundtester 2026-08-10 12:56:57 +02:00
lebaudantoine db1010ce29 fixup! wip add an audio gauge in the input menu 2026-08-10 12:29:07 +02:00
lebaudantoine ded3e17dcb fixup! wip add an audio gauge in the input menu 2026-08-10 12:07:25 +02:00
lebaudantoine 977abadcc0 wip handle permission handling in join tracks 2026-08-10 11:37:45 +02:00
lebaudantoine d559d53deb wip reorganize join 2026-08-10 00:16:09 +02:00
lebaudantoine ac5da2a3df fixup! wip refactor the lobby in a dedicated component 2026-08-09 23:34:38 +02:00
lebaudantoine 7efc6b3fdd fixup! huge refacto of the permissions handling + fix various regression 2026-08-09 23:22:22 +02:00
lebaudantoine 1684ef85ec wip refactor the lobby in a dedicated component 2026-08-09 23:21:47 +02:00
lebaudantoine 38530facba wip soundtester 2026-08-09 16:17:08 +02:00
lebaudantoine 1fca767855 wip add an audio gauge in the input menu 2026-08-09 16:17:08 +02:00
lebaudantoine 71ea6421dd wip sync again devices preferences 2026-08-09 16:17:08 +02:00
lebaudantoine e93bc91b24 revert hot fix now the permission should be synced 2026-08-09 16:17:08 +02:00
lebaudantoine d4df0df027 huge refacto of the permissions handling + fix various regression
/**
 * Keeps permissionsStore aligned with the browser. The store is a cache,
 * never a second source of truth: every signal re-reads the browser via
 * syncPermissions() (single writer).
 *
 * Re-sync triggers, all event-driven — no polling:
 *  - devicechange: granting permission reveals device labels/ids, so this
 *    fires on grant in every browser INCLUDING Safari — replacing the
 *    previous 500ms Safari polling. (A denial is caught by the concurrent
 *    getUserMedia rejection → notePermissionDeniedFromGum.)
 *  - window focus: returning from the browser/system permission UI.
 *  - Permissions API 'change' events, where the query is supported.
 */
2026-08-09 16:17:07 +02:00
lebaudantoine ddcc4062b9 wip remove hook that's failing 2026-08-09 15:48:56 +02:00
lebaudantoine c7889c14b5 wip observe media device error on room 2026-08-09 15:48:56 +02:00
lebaudantoine dd4a104e3a wip remove exact constraint 2026-08-09 15:48:55 +02:00
lebaudantoine 7514bff7c1 wip try to encapsulate captureEvent calls in telemetry 2026-08-09 15:48:55 +02:00
lebaudantoine 993efec027 wip encapsulate exception capture 2026-08-09 15:48:55 +02:00
lebaudantoine 46a4955fe0 🐛(frontend) drop resize listener in useIsMobileBrowser
`isMobileBrowser()` only reads `navigator.userAgent`, which does
not change during the lifetime of the document, so the previous
`resize` listener never had anything meaningful to update.

It did, however, dispatch `setIsMobile` on components rendered into
a Document Picture-in-Picture window (e.g. the reactions toolbar).
When the PiP window had already been closed, Firefox threw
"can't access dead object".

Compute the value once and skip the listener entirely.

Fix 019cb315-d827-73f2-b1cc-74e4dd71e982
2026-08-07 12:03:10 +02:00
lebaudantoine b01a46b345 🐛(frontend) gate blur on WebGL2 transformer support
`ProcessorWrapper.isSupported` reports pipeline support but not
whether the WebGL2 transformer is available. On browsers where it
is not (e.g. Chrome/Edge on Windows with WebGL2 disabled by a GPU
blocklist), toggling blur throws at runtime.

Update `supportsBackgroundProcessors()` to check both, so the UI
only exposes blur when it can actually run.

fix 019f8e3b-f035-73e2-9d6a-d0dd2d0a1163
2026-08-07 12:03:10 +02:00
lebaudantoine 45e1a68fec 🐛(frontend) guard getRouteUrl('room', slug) against missing slug
InviteDialog.tsx and Info.tsx were the last call sites calling
getRouteUrl('room', slug) without a slug guard, unlike every other
caller (e.g. useCopyRoomToClipboard).

Compute roomUrl only when the slug exists (undefined in
InviteDialog, '' in Info to keep its unguarded .replace safe).
Guarding at the call site preserves the "no room data yet" state
instead of returning a bogus "/" URL from room.to.

Fix 019fd616-f158-7771-8cff-bac3090b8449
2026-08-07 12:03:10 +02:00
lebaudantoine 261ab8c7cf 🐛(frontend) unmount PiP portal synchronously on pagehide
When the PiP window closes, the browser destroys its document right
after `pagehide`. If the portal unmount is left to React's async
scheduling, it commits against a dead document and `removeChild`
throws "NotFoundError", crashing the app.

Subscribe `PictureInPicturePortal` to the Valtio store with
`sync: true`, and use `flushSync` in `usePictureInPicture` on
teardown so React unmounts the portal while the PiP document is
still alive.

Fix 019f42cf-86a9-7ad2-8e64-81b004ddc5de
2026-08-07 12:03:10 +02:00
lebaudantoine 7022becc78 🐛(frontend) normalize thrown values into proper Error instances
LiveKit can surface raw DOM events (for example WebSocket "error"
events, whose only enumerable key is `isTrusted`) instead of Error
instances.

When such a value ends up being captured, our error reporting logs
it as "Event: Event captured as exception with keys: isTrusted",
which is unhelpful and hides the real cause.

Add a small helper that normalizes any unknown thrown or emitted
value into a proper Error, preserving the original payload as
context.

Fixes 01997b9a-db63-7fc2-8fe4-f21dd7fd608d.
2026-08-06 17:30:26 +02:00
70 changed files with 1711 additions and 1163 deletions
-10
View File
@@ -22,7 +22,6 @@
"@tanstack/react-query": "5.101.1", "@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.1",
@@ -4710,15 +4709,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-1
View File
@@ -29,7 +29,6 @@
"@tanstack/react-query": "5.101.1", "@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.1",
+5 -1
View File
@@ -2,6 +2,7 @@ import { Button } from '@/primitives'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react' import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry'
export const SoundTester = () => { export const SoundTester = () => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
@@ -15,7 +16,10 @@ export const SoundTester = () => {
try { try {
await audioRef?.current?.setSinkId(deviceId) await audioRef?.current?.setSinkId(deviceId)
} catch (error) { } catch (error) {
console.error(`Error setting sinkId: ${error}`) reportError(
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
} }
} }
updateActiveId(activeDeviceId) updateActiveId(activeDeviceId)
@@ -1,15 +1,8 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useLocation } from 'wouter' import { useLocation } from 'wouter'
import { type PostHog } from 'posthog-js'
import { type ApiUser } from '@/features/auth/api/ApiUser' import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser' import { useUser } from '@/features/auth/api/useUser'
import { getPosthog } from '../utils'
let posthog: PostHog | null = null
const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
export const startAnalyticsSession = (data: ApiUser) => { export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => { getPosthog().then((ph) => {
@@ -0,0 +1,140 @@
import { getPosthog } from './utils'
export const captureEvent = (
event: string,
props?: Record<string, unknown>
) => {
void getPosthog()
.then((ph) => {
ph.capture(event, props)
})
.catch(() => {
/* telemetry must never break the app */
})
if (import.meta.env.DEV) {
console.warn(`[telemetry] ${event}`, props)
}
}
export type LogCode =
// media
| 'join_preview_failure'
| 'livekit_room_error'
| 'device_switch_failure'
| 'permission_poll_failure'
// non-media families
| 'participant_mute_api_failure'
| 'permissions_api_failure'
| 'effects_processor_failure'
| 'clipboard_failure'
| 'fullscreen_failure'
| 'publish_sources_failure'
| 'disconnect_failure'
| 'generic_failure'
export const reportError = (
logCode: LogCode,
error: unknown,
extraInfo: Record<string, unknown> = {}
): void => {
const e = error instanceof Error ? error : new Error(String(error))
void getPosthog()
.then((ph) => {
ph.captureException(e, {
log_code: logCode,
error_name: e.name,
error_message: e.message,
...extraInfo,
})
})
.catch(() => {})
if (import.meta.env.DEV) {
console.warn(`[${logCode}]`, e, extraInfo)
}
}
export interface DeviceSnapshot {
cam_count: number
mic_count: number
out_count: number
labels_visible: boolean
saved_cam_present: boolean | null
saved_mic_present: boolean | null
saved_video_device_id_set: boolean
saved_audio_device_id_set: boolean
audio_enabled: boolean | null
video_enabled: boolean | null
cam_permission: PermissionState | 'unknown'
mic_permission: PermissionState | 'unknown'
}
/** Reads the persisted LiveKit user choices without importing the store. */
const readPersistedChoices = (): {
videoDeviceId?: string
audioDeviceId?: string
videoEnabled?: boolean
audioEnabled?: boolean
} => {
try {
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
} catch {
return {}
}
}
const queryPermission = async (
name: 'camera' | 'microphone'
): Promise<PermissionState | 'unknown'> => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return 'unknown'
}
}
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
const choices = readPersistedChoices()
let devices: MediaDeviceInfo[] = []
try {
devices = await navigator.mediaDevices.enumerateDevices()
} catch {
/* snapshot stays partial */
}
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
const present = (k: MediaDeviceKind, id?: string) =>
id ? ofKind(k).some((d) => d.deviceId === id) : null
const [cam_permission, mic_permission] = await Promise.all([
queryPermission('camera'),
queryPermission('microphone'),
])
return {
cam_count: ofKind('videoinput').length,
mic_count: ofKind('audioinput').length,
out_count: ofKind('audiooutput').length,
labels_visible: devices.some((d) => !!d.label),
saved_cam_present: present('videoinput', choices.videoDeviceId),
saved_mic_present: present('audioinput', choices.audioDeviceId),
saved_video_device_id_set: !!choices.videoDeviceId,
saved_audio_device_id_set: !!choices.audioDeviceId,
audio_enabled: choices.audioEnabled ?? null,
video_enabled: choices.videoEnabled ?? null,
cam_permission,
mic_permission,
}
}
export const captureMediaEvent = async (
event:
| 'media-device-error'
| 'media-acquisition'
| 'media-device-topology'
| 'media-device-success',
props: Record<string, unknown>
) => {
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
}
@@ -0,0 +1,8 @@
import type { PostHog } from 'posthog-js'
let posthog: PostHog | null = null
export const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
@@ -15,6 +15,7 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen' import { LoadingScreen } from '@/components/LoadingScreen'
import { reportError } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => { const Columns = ({ children }: { children?: ReactNode }) => {
return ( return (
@@ -160,7 +161,9 @@ const Home = () => {
window.location.replace(data.external_home_url) window.location.replace(data.external_home_url)
} catch (error) { } catch (error) {
setRedirectFailed(true) setRedirectFailed(true)
console.error('Site is not reachable:', error) reportError('generic_failure', error, {
context: 'Site is not reachable:',
})
} }
} }
} }
@@ -4,6 +4,7 @@ import { NotificationDuration } from './NotificationDuration'
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import type { NotificationPayload } from './NotificationPayload' import type { NotificationPayload } from './NotificationPayload'
import type { RecordingMode } from '@/features/recording' import type { RecordingMode } from '@/features/recording'
import { reportError } from '@/features/analytics/telemetry'
export const notifyAutoMutedOnJoin = () => { export const notifyAutoMutedOnJoin = () => {
toastQueue.add( toastQueue.add(
@@ -55,7 +56,9 @@ export const decodeNotificationDataReceived = (
return parsed as NotificationPayload return parsed as NotificationPayload
} catch (error) { } catch (error) {
// Handle errors appropriately for your application // Handle errors appropriately for your application
console.error('Failed to decode notification payload:', error) reportError('generic_failure', error, {
context: 'Failed to decode notification payload:',
})
return return
} }
} }
@@ -1,5 +1,6 @@
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useLowerHandParticipant } from './lowerHandParticipant' import { useLowerHandParticipant } from './lowerHandParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useLowerHandParticipants = () => { export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant() const { lowerHandParticipant } = useLowerHandParticipant()
@@ -11,7 +12,9 @@ export const useLowerHandParticipants = () => {
) )
return Promise.all(promises) return Promise.all(promises)
} catch (error) { } catch (error) {
console.error('An error occurred while lowering hands :', error) reportError('generic_failure', error, {
context: 'An error occurred while lowering hands :',
})
throw new Error('An error occurred while lowering hands.', { throw new Error('An error occurred while lowering hands.', {
cause: error, cause: error,
}) })
@@ -1,6 +1,7 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom' import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
import { reportError } from '@/features/analytics/telemetry'
export const useParticipantRole = () => { export const useParticipantRole = () => {
const data = useRoomData() const data = useRoomData()
@@ -22,8 +23,11 @@ export const useParticipantRole = () => {
}), }),
}) })
} catch (error) { } catch (error) {
console.error( reportError(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}` 'generic_failure',
new Error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
) )
} }
} }
@@ -10,6 +10,7 @@ import {
} from '../../participants/api/listWaitingParticipants' } from '../../participants/api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils' import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType' import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 1000
@@ -87,7 +88,7 @@ export const useWaitingParticipants = () => {
await refetchWaiting() await refetchWaiting()
} catch (e) { } catch (e) {
console.error(e) reportError('generic_failure', e)
setListEnabled(true) setListEnabled(true)
} }
} }
@@ -7,7 +7,9 @@ import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext' import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => { const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore) const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
sync: true,
})
const container = useMemo(() => { const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root') return pipStoreSnap?.window?.document.getElementById('root')
@@ -19,7 +21,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
} }
}, []) }, [])
if (!container) return null if (!container || !container.isConnected) return null
return createPortal( return createPortal(
/** /**
@@ -1,7 +1,9 @@
import { ref, useSnapshot } from 'valtio' import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture' import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
export const IS_PIP_SUPPORTED = export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
@@ -73,7 +75,9 @@ export const usePictureInPicture = () => {
const cleanUp = () => { const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) { if (documentPictureInPictureStore.window === pipWindow) {
documentPictureInPictureStore.window = null flushSync(() => {
documentPictureInPictureStore.window = null
})
} }
} }
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true }) pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
@@ -83,7 +87,9 @@ export const usePictureInPicture = () => {
documentPictureInPictureStore.window = ref(pipWindow) documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) { } catch (error) {
// Avoid unhandled rejections if the user blocks or closes the request. // Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error) reportError('generic_failure', error, {
context: 'Failed to open Picture-in-Picture window',
})
return null return null
} }
}, },
@@ -16,7 +16,6 @@ import {
notifyRecordingSaveInProgress, notifyRecordingSaveInProgress,
useNotifyParticipants, useNotifyParticipants,
} from '@/features/notifications' } from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { NoAccessView } from './NoAccessView' import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton' import { ControlsButton } from './ControlsButton'
@@ -29,6 +28,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { FeatureFlags } from '@/features/analytics/enums' import { FeatureFlags } from '@/features/analytics/enums'
import { LimitDescription } from './LimitDescription' import { LimitDescription } from './LimitDescription'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
export const ScreenRecordingSidePanel = () => { export const ScreenRecordingSidePanel = () => {
const { data } = useConfig() const { data } = useConfig()
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.ScreenRecordingRequested, type: NotificationType.ScreenRecordingRequested,
}) })
posthog.capture('screen-recording-requested', {}) captureEvent('screen-recording-requested', {})
} }
const handleScreenRecording = async () => { const handleScreenRecording = async () => {
@@ -100,13 +100,15 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.ScreenRecordingStarted, type: NotificationType.ScreenRecordingStarted,
}) })
posthog.capture('screen-recording-started', { captureEvent('screen-recording-started', {
includeTranscript: includeTranscript, includeTranscript: includeTranscript,
language: selectedLanguageKey, language: selectedLanguageKey,
}) })
} }
} catch (error) { } catch (error) {
console.error('Failed to handle recording:', error) reportError('generic_failure', error, {
context: 'Failed to handle recording:',
})
} }
} }
@@ -17,7 +17,6 @@ import {
useNotifyParticipants, useNotifyParticipants,
notifyRecordingSaveInProgress, notifyRecordingSaveInProgress,
} from '@/features/notifications' } from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { VStack } from '@/styled-system/jsx' import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx' import { Checkbox } from '@/primitives/Checkbox.tsx'
@@ -35,6 +34,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitDescription } from './LimitDescription' import { LimitDescription } from './LimitDescription'
import { openSettingsDialog } from '@/stores/settings' import { openSettingsDialog } from '@/stores/settings'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
export const TranscriptSidePanel = () => { export const TranscriptSidePanel = () => {
const { data } = useConfig() const { data } = useConfig()
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.TranscriptionRequested, type: NotificationType.TranscriptionRequested,
}) })
posthog.capture('transcript-requested', {}) captureEvent('transcript-requested', {})
} }
const handleTranscript = async () => { const handleTranscript = async () => {
@@ -121,13 +121,15 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.TranscriptionStarted, type: NotificationType.TranscriptionStarted,
}) })
posthog.capture('transcript-started', { captureEvent('transcript-started', {
includeScreenRecording: includeScreenRecording, includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey, language: selectedLanguageKey,
}) })
} }
} catch (error) { } catch (error) {
console.error('Failed to handle transcript:', error) reportError('generic_failure', error, {
context: 'Failed to handle transcript:',
})
} }
} }
@@ -1,5 +1,6 @@
import { useRoomInfo } from '@livekit/components-react' import { useRoomInfo } from '@livekit/components-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useRoomMetadata = () => { export const useRoomMetadata = () => {
const { metadata } = useRoomInfo() const { metadata } = useRoomInfo()
@@ -8,7 +9,9 @@ export const useRoomMetadata = () => {
try { try {
return JSON.parse(metadata) return JSON.parse(metadata)
} catch (error) { } catch (error) {
console.error('Failed to parse room metadata:', error) reportError('generic_failure', error, {
context: 'Failed to parse room metadata:',
})
return undefined return undefined
} }
} else { } else {
@@ -9,6 +9,7 @@ import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react' import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipant = () => { export const useMuteParticipant = () => {
const apiRoomData = useRoomData() const apiRoomData = useRoomData()
@@ -31,7 +32,10 @@ export const useMuteParticipant = () => {
// Guard against undefined token for non-admin users // Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) { if (!isAdminOrOwner && !apiRoomData.livekit.token) {
console.error('Cannot mute participant: missing auth token') reportError(
'participant_mute_api_failure',
new Error('Cannot mute participant: missing auth token')
)
return return
} }
@@ -53,8 +57,11 @@ export const useMuteParticipant = () => {
} }
) )
} catch (error) { } catch (error) {
console.error( reportError(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}` 'participant_mute_api_failure',
new Error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
) )
return return
} }
@@ -65,8 +72,11 @@ export const useMuteParticipant = () => {
destinationIdentities: [participant.identity], destinationIdentities: [participant.identity],
}) })
} catch (e) { } catch (e) {
console.error( reportError(
`Failed to notify muted participant ${participant.identity}: ${e}` 'participant_mute_api_failure',
new Error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
) )
} }
@@ -1,5 +1,6 @@
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useMuteParticipant } from './muteParticipant' import { useMuteParticipant } from './muteParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipants = () => { export const useMuteParticipants = () => {
const { muteParticipant } = useMuteParticipant() const { muteParticipant } = useMuteParticipant()
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
) )
return Promise.all(promises) return Promise.all(promises)
} catch (error) { } catch (error) {
console.error('An error occurred while muting participants :', error) reportError('participant_mute_api_failure', error, {
context: 'An error occurred while muting participants :',
})
throw new Error('An error occurred while muting participants.', { throw new Error('An error occurred while muting participants.', {
cause: error, cause: error,
}) })
@@ -1,6 +1,7 @@
import type { Participant, Track } from 'livekit-client' import type { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source type Source = Track.Source
export const useParticipantPermissions = () => { export const useParticipantPermissions = () => {
@@ -32,8 +33,11 @@ export const useParticipantPermissions = () => {
}), }),
}) })
} catch (error) { } catch (error) {
console.error( reportError(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}` 'permissions_api_failure',
new Error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
) )
} }
} }
@@ -1,5 +1,6 @@
import type { Participant, Track } from 'livekit-client' import type { Participant, Track } from 'livekit-client'
import { useParticipantPermissions } from './updateParticipantPermissions' import { useParticipantPermissions } from './updateParticipantPermissions'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source type Source = Track.Source
export const useUpdateParticipantsPermissions = () => { export const useUpdateParticipantsPermissions = () => {
@@ -15,7 +16,9 @@ export const useUpdateParticipantsPermissions = () => {
) )
return Promise.all(promises) return Promise.all(promises)
} catch (error) { } catch (error) {
console.error('An error occurred while updating permissions :', error) reportError('permissions_api_failure', error, {
context: 'An error occurred while updating permissions :',
})
throw new Error('An error occurred while updating permissions.', { throw new Error('An error occurred while updating permissions.', {
cause: error, cause: error,
}) })
@@ -26,7 +26,11 @@ import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert' import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react' import {
captureEvent,
reportError,
captureMediaEvent,
} from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
@@ -37,6 +41,10 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences' import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user' import { userStore } from '@/stores/user'
import {
PERMISSION_BY_DEVICE_KIND,
notePermissionDeniedFromGum,
} from '@/stores/permissions'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -47,7 +55,6 @@ export const Conference = ({
mode?: 'join' | 'create' mode?: 'join' | 'create'
initialRoomData?: ApiRoom initialRoomData?: ApiRoom
}) => { }) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig() const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as { const { userChoices: userConfig } = usePersistentUserChoices() as {
@@ -57,8 +64,8 @@ export const Conference = ({
const { username } = useSnapshot(userStore) const { username } = useSnapshot(userStore)
useEffect(() => { useEffect(() => {
posthog.capture('visit-room', { slug: roomId }) captureEvent('visit-room', { slug: roomId })
}, [roomId, posthog]) }, [roomId])
const fetchKey = [keys.room, roomId] const fetchKey = [keys.room, roomId]
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false) const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
@@ -235,7 +242,10 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important', backgroundColor: 'primaryDark.50 !important',
})} })}
onError={(e) => { onError={(e) => {
posthog.captureException(e) reportError('livekit_room_error', e, {
path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
})
}} }}
onConnected={async () => { onConnected={async () => {
if (!apiConfig) return if (!apiConfig) return
@@ -296,8 +306,22 @@ export const Conference = ({
} }
}} }}
onMediaDeviceFailure={(e, kind) => { onMediaDeviceFailure={(e, kind) => {
if (e == MediaDeviceFailure.DeviceInUse && !!kind) { if (!e || !kind) return
setMediaDeviceError({ error: e, kind }) void captureMediaEvent('media-device-error', {
log_code: 'media_devices_error_event',
path: 'connect_publish',
failure: e,
kind,
})
switch (e) {
case MediaDeviceFailure.DeviceInUse:
setMediaDeviceError({ error: e, kind })
break
case MediaDeviceFailure.PermissionDenied:
notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind])
break
default:
break
} }
}} }}
> >
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' }) const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData() const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug) const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
const telephony = useTelephony() const telephony = useTelephony()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Form, Text } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
import { saveUsername, userStore } from '@/stores/user'
import { fetchRoom } from '../api/fetchRoom'
import { ApiAccessLevel } from '../api/ApiRoom'
import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
import { useLobby } from '../hooks/useLobby'
export const Lobby = ({
roomId,
enterRoom,
}: {
roomId: string
enterRoom: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { data: configData } = useConfig()
const { isLoggedIn, user } = useUser()
const { username } = useSnapshot(userStore)
// 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
const {
data: roomData,
error,
isError,
refetch: refetchRoom,
} = useQuery({
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
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: username || user?.full_name || 'anonymous',
onAccepted: handleAccepted,
})
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
const { data } = await refetchRoom()
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
if (data?.access_level == ApiAccessLevel.TRUSTED) {
openLoginHint()
}
startWaiting()
return
}
enterRoom()
}
switch (status) {
case ApiLobbyStatus.TIMEOUT:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('timeoutInvite.title')}
</H>
<Text as="p" variant="note">
{t('timeoutInvite.body')}
</Text>
</VStack>
)
case ApiLobbyStatus.DENIED:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('denied.title')}
</H>
<Text as="p" variant="note">
{t('denied.body')}
</Text>
</VStack>
)
case ApiLobbyStatus.WAITING:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('waiting.title')}
</H>
<Text
as="p"
variant="note"
className={css({ marginBottom: '1.5rem' })}
>
{t('waiting.body')}
</Text>
<Spinner />
</VStack>
)
default:
return (
<Form
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
{(!isLoggedIn ||
configData?.authenticated_users_can_edit_display_name) && (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username || user?.full_name}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
</VStack>
</Form>
)
}
}
@@ -1,13 +1,12 @@
import { Button, H, Input, Text, TextArea } from '@/primitives' import { Button, H, Text, TextArea } from '@/primitives'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { cva } from '@/styled-system/css' import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx' import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import type { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled' import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { CandidateInfo } from '@/stores/connectionObserver' import type { CandidateInfo } from '@/stores/connectionObserver'
import { captureEvent } from '@/features/analytics/telemetry'
const Card = styled('div', { const Card = styled('div', {
base: { base: {
@@ -72,11 +71,9 @@ const labelRecipe = cva({
}) })
const OpenFeedback = ({ const OpenFeedback = ({
posthog,
onNext, onNext,
metadata, metadata,
}: { }: {
posthog: PostHog
onNext: () => void onNext: () => void
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
}) => { }) => {
@@ -90,7 +87,7 @@ const OpenFeedback = ({
const onSubmit = () => { const onSubmit = () => {
try { try {
posthog.capture('open-feedback', { captureEvent('open-feedback', {
feedback, feedback,
...metadata, ...metadata,
}) })
@@ -141,12 +138,10 @@ const OpenFeedback = ({
} }
const RateQuality = ({ const RateQuality = ({
posthog,
onNext, onNext,
metadata, metadata,
maxRating = 5, maxRating = 5,
}: { }: {
posthog: PostHog
onNext: () => void onNext: () => void
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
maxRating?: number maxRating?: number
@@ -160,7 +155,7 @@ const RateQuality = ({
const onSubmit = () => { const onSubmit = () => {
try { try {
posthog.capture('quality-rating', { captureEvent('quality-rating', {
rating: selectedRating, rating: selectedRating,
...metadata, ...metadata,
}) })
@@ -243,67 +238,6 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
) )
} }
const AuthenticationMessage = ({
onNext,
posthog,
}: {
onNext: () => void
posthog: PostHog
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'authenticationMessage' })
const [email, setEmail] = useState('')
const onSubmit = () => {
posthog.people.set({ unsafe_email: email })
onNext()
}
return (
<Card
style={{
maxWidth: '380px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<H lvl={3}>{t('heading')}</H>
<Input
id="emailInput"
name="email"
placeholder={t('placeholder')}
required
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
marginBottom: '1rem',
}}
/>
<VStack gap="0.5">
<Button
variant="primary"
size="sm"
fullWidth
isDisabled={!email}
onPress={onSubmit}
>
{t('submit')}
</Button>
<Button
invisible
variant="secondary"
size="sm"
fullWidth
onPress={onNext}
>
{t('ignore')}
</Button>
</VStack>
</Card>
)
}
type RatingMetadata = { type RatingMetadata = {
room_id?: string room_id?: string
pc_publisher?: CandidateInfo pc_publisher?: CandidateInfo
@@ -318,12 +252,6 @@ export const Rating = ({
metadata: RatingMetadata metadata: RatingMetadata
}) => { }) => {
const isAnalyticsEnabled = useIsAnalyticsEnabled() const isAnalyticsEnabled = useIsAnalyticsEnabled()
const posthog = usePostHog()
const isUserAnonymous = useMemo(() => {
return posthog.get_property('$user_state') == 'anonymous'
}, [posthog])
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const sessionId = useMemo(() => crypto.randomUUID(), []) const sessionId = useMemo(() => crypto.randomUUID(), [])
@@ -339,37 +267,14 @@ export const Rating = ({
if (!isAnalyticsEnabled) return if (!isAnalyticsEnabled) return
if (step == 0) { if (step == 0) {
return ( return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
<RateQuality
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
} }
if (step == 1) { if (step == 1) {
return ( return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
<OpenFeedback
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
} }
if (step == 2) { if (step == 2) {
return isUserAnonymous ? (
<AuthenticationMessage
posthog={posthog}
onNext={() => setStep(step + 1)}
/>
) : (
<ConfirmationMessage onNext={() => setStep(0)} />
)
}
if (step == 3) {
return <ConfirmationMessage onNext={() => setStep(0)} /> return <ConfirmationMessage onNext={() => setStep(0)} />
} }
} }
@@ -1,160 +1,36 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { permissionsStore } from '@/stores/permissions' import { syncPermissions } from '@/stores/permissions'
import { isSafari } from '@/utils/livekit'
const POLLING_TIME = 500
export const useWatchPermissions = () => { export const useWatchPermissions = () => {
useEffect(() => { useEffect(() => {
let cleanup: (() => void) | undefined const sync = () => void syncPermissions()
let intervalId: ReturnType<typeof setTimeout> | undefined sync()
let isCancelled = false
const checkPermissions = async () => { navigator.mediaDevices?.addEventListener?.('devicechange', sync)
try { window.addEventListener('focus', sync)
if (!navigator.permissions) {
if (!isCancelled) {
permissionsStore.cameraPermission = 'unavailable'
permissionsStore.microphonePermission = 'unavailable'
}
return
}
const [cameraPermission, microphonePermission] = await Promise.all([ let statuses: PermissionStatus[] = []
navigator.permissions.query({ name: 'camera' }), let cancelled = false
navigator.permissions.query({ name: 'microphone' }), if (navigator.permissions) {
]) Promise.all([
navigator.permissions.query({ name: 'camera' as PermissionName }),
if (isCancelled) return navigator.permissions.query({ name: 'microphone' as PermissionName }),
])
/** .then((results) => {
* Safari Permission API Limitation Workaround if (cancelled) return
* statuses = results
* Safari has a known issue where permission change events are not reliably fired statuses.forEach((s) => s.addEventListener('change', sync))
* when users interact with permission prompts. This is documented in Apple's forums: })
* https://developer.apple.com/forums/thread/757353 .catch(() => {
* // Query unsupported: devicechange/focus + gUM outcomes cover it.
* The problem: })
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
* - This leaves the UI in an inconsistent state showing outdated permission status
*
* The solution:
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
* - Continue polling until both permissions are no longer in 'prompt' state
* - This ensures we catch permission changes even when Safari fails to fire events
*
* This polling is Safari-specific and only activates when needed to minimize performance impact.
*/
if (
isSafari() &&
(cameraPermission.state === 'prompt' ||
microphonePermission.state === 'prompt')
) {
// Start polling every 1 second if either permission is in 'prompt' state
if (!intervalId) {
intervalId = setInterval(async () => {
try {
const [updatedCamera, updatedMicrophone] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
const cameraChanged =
permissionsStore.cameraPermission !== updatedCamera.state
const microphoneChanged =
permissionsStore.microphonePermission !==
updatedMicrophone.state
if (cameraChanged) {
permissionsStore.cameraPermission = updatedCamera.state
}
if (microphoneChanged) {
permissionsStore.microphonePermission =
updatedMicrophone.state
}
if (
updatedCamera.state !== 'prompt' &&
updatedMicrophone.state !== 'prompt'
) {
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error polling permissions:', error)
}
}
}, POLLING_TIME)
}
}
permissionsStore.cameraPermission = cameraPermission.state
permissionsStore.microphonePermission = microphonePermission.state
const handleCameraChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.cameraPermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
const handleMicrophoneChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.microphonePermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
cameraPermission.addEventListener('change', handleCameraChange)
microphonePermission.addEventListener('change', handleMicrophoneChange)
cleanup = () => {
cameraPermission.removeEventListener('change', handleCameraChange)
microphonePermission.removeEventListener(
'change',
handleMicrophoneChange
)
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error checking permissions:', error)
}
} finally {
if (!isCancelled) {
permissionsStore.isLoading = false
}
}
} }
checkPermissions()
return () => { return () => {
isCancelled = true cancelled = true
cleanup?.() navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
window.removeEventListener('focus', sync)
statuses.forEach((s) => s.removeEventListener('change', sync))
} }
}, []) }, [])
} }
@@ -13,6 +13,7 @@ import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { useEffect } from 'react' import { useEffect } from 'react'
import { closeSidePanel } from '@/stores/layout' import { closeSidePanel } from '@/stores/layout'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { reportError } from '@/features/analytics/telemetry'
export const Admin = () => { export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -205,7 +206,7 @@ export const Admin = () => {
patchRoom({ patchRoom({
roomId, roomId,
room: { access_level: value as ApiAccessLevel }, room: { access_level: value as ApiAccessLevel },
}).catch((e) => console.error(e)) }).catch((e) => reportError('generic_failure', e))
} }
items={[ items={[
{ {
@@ -11,10 +11,10 @@ import { DisconnectReason, RoomEvent } from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences' import { userPreferencesStore } from '@/stores/userPreferences'
import { connectionObserverStore } from '@/stores/connectionObserver' import { connectionObserverStore } from '@/stores/connectionObserver'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react' import { useFeatureFlagEnabled } from 'posthog-js/react'
import { isMobileBrowser } from '@livekit/components-core' import { isMobileBrowser } from '@livekit/components-core'
import { FeatureFlags } from '@/features/analytics/enums' import { FeatureFlags } from '@/features/analytics/enums'
import { captureEvent } from '@/features/analytics/telemetry'
const CANDIDATE_POLL_INTERVAL_MS = 5000 const CANDIDATE_POLL_INTERVAL_MS = 5000
@@ -182,23 +182,23 @@ export const ConnectionObserver = () => {
// total session duration from first connect to final disconnect. // total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now() connectionStartTimeRef.current = Date.now()
posthog.capture('connection-event') captureEvent('connection-event')
} }
const handleReconnect = () => { const handleReconnect = () => {
posthog.capture('reconnect-event') captureEvent('reconnect-event')
} }
const handleReconnected = () => { const handleReconnected = () => {
posthog.capture('reconnected-event') captureEvent('reconnected-event')
} }
const handleSignalingConnect = () => { const handleSignalingConnect = () => {
posthog.capture('signaling-connect-event') captureEvent('signaling-connect-event')
} }
const handleSignalingReconnect = () => { const handleSignalingReconnect = () => {
posthog.capture('signaling-reconnect-event') captureEvent('signaling-reconnect-event')
} }
const handleDisconnect = ( const handleDisconnect = (
@@ -206,7 +206,7 @@ export const ConnectionObserver = () => {
) => { ) => {
const connectionEndTime = Date.now() const connectionEndTime = Date.now()
posthog.capture('disconnect-event', { captureEvent('disconnect-event', {
// Calculate total session duration from first connection to final disconnect // Calculate total session duration from first connection to final disconnect
// This duration is sensitive to refreshing the page. // This duration is sensitive to refreshing the page.
sessionDuration: connectionStartTimeRef.current sessionDuration: connectionStartTimeRef.current
@@ -14,7 +14,7 @@ export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' }) const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const data = useRoomData() const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug) const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
const telephony = useTelephony() const telephony = useTelephony()
@@ -0,0 +1,20 @@
import { useLocalParticipant } from '@livekit/components-react'
import type { LocalTrack } from 'livekit-client'
import { useSyncTrackDeviceId } from '../hooks/useSyncTrackDeviceId'
import {
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} from '@/stores/userChoices'
export const SyncDevicePreferences = () => {
const { cameraTrack, microphoneTrack } = useLocalParticipant()
useSyncTrackDeviceId(
cameraTrack?.track as LocalTrack | undefined,
saveVideoInputDeviceId
)
useSyncTrackDeviceId(
microphoneTrack?.track as LocalTrack | undefined,
saveAudioInputDeviceId
)
return null
}
@@ -1,5 +1,4 @@
import type { ProcessorOptions, Track } from 'livekit-client' import type { ProcessorOptions, Track } from 'livekit-client'
import posthog from 'posthog-js'
import { import {
FilesetResolver, FilesetResolver,
ImageSegmenter, ImageSegmenter,
@@ -18,6 +17,7 @@ import {
type ProcessorType, type ProcessorType,
MEDIAPIPE_PATH_WASM, MEDIAPIPE_PATH_WASM,
} from '.' } from '.'
import { captureEvent } from '@/features/analytics/telemetry.ts'
const PROCESSING_WIDTH = 256 const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144 const PROCESSING_HEIGHT = 144
@@ -100,7 +100,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.initSegmenter() await this.initSegmenter()
this._initWorker() this._initWorker()
posthog.capture('firefox-blurring-init') captureEvent('firefox-blurring-init', {})
} }
_initVirtualBackgroundImage() { _initVirtualBackgroundImage() {
@@ -1,5 +1,4 @@
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client' import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
import posthog from 'posthog-js'
import { import {
FilesetResolver, FilesetResolver,
FaceLandmarker, FaceLandmarker,
@@ -16,6 +15,7 @@ import {
ProcessorType, ProcessorType,
MEDIAPIPE_PATH_WASM, MEDIAPIPE_PATH_WASM,
} from '.' } from '.'
import { captureEvent } from '@/features/analytics/telemetry'
const PROCESSING_WIDTH = 256 * 3 const PROCESSING_WIDTH = 256 * 3
const PROCESSING_HEIGHT = 144 * 3 const PROCESSING_HEIGHT = 144 * 3
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
await this.initFaceLandmarker() await this.initFaceLandmarker()
this._initWorker() this._initWorker()
posthog.capture('face-landmarks-init') captureEvent('face-landmarks-init', {})
} }
_initWorker() { _initWorker() {
@@ -1,4 +1,7 @@
import { ProcessorWrapper } from '@livekit/track-processors' import {
ProcessorWrapper,
supportsBackgroundProcessors,
} from '@livekit/track-processors'
import type { Track, TrackProcessor } from 'livekit-client' import type { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor' import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor' import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
@@ -34,7 +37,9 @@ export class BackgroundProcessorFactory {
} }
static isSupported() { static isSupported() {
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported return (
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
)
} }
static getProcessor( static getProcessor(
@@ -45,7 +50,7 @@ export class BackgroundProcessorFactory {
if (!isBlur && !isVirtual) return undefined if (!isBlur && !isVirtual) return undefined
if (ProcessorWrapper.isSupported) { if (supportsBackgroundProcessors()) {
return new UnifiedBackgroundTrackProcessor(config) return new UnifiedBackgroundTrackProcessor(config)
} }
@@ -4,6 +4,7 @@ import { RiCameraSwitchLine } from '@remixicon/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { ButtonProps } from 'react-aria-components' import type { ButtonProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
enum FacingMode { enum FacingMode {
USER = 'user', USER = 'user',
@@ -103,7 +104,11 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
setActiveMediaDevice(device.deviceId) setActiveMediaDevice(device.deviceId)
setFacingMode(target) setFacingMode(target)
} else { } else {
console.error('Cannot get user device with facingMode ' + target) reportError(
'device_switch_failure',
new Error('Cannot get user device with facingMode ' + target),
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
)
} }
} }
return ( return (
@@ -1,8 +1,12 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react' import {
useLocalParticipant,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Popover } from '@/primitives' import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react' import { RiArrowUpSLine } from '@remixicon/react'
import { Track } from 'livekit-client' import { LocalAudioTrack, Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice' import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
@@ -51,6 +55,12 @@ export const AudioDevicesControl = ({
...props, ...props,
}) })
const { microphoneTrack } = useLocalParticipant()
const localAudioTrack =
microphoneTrack?.track instanceof LocalAudioTrack
? microphoneTrack.track
: undefined
const kind = 'audioinput' const kind = 'audioinput'
const cannotUseDevice = useCannotUseDevice(kind) const cannotUseDevice = useCannotUseDevice(kind)
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`) const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
context="room" context="room"
kind={kind} kind={kind}
id={audioDeviceId} id={audioDeviceId}
track={localAudioTrack}
onSubmit={saveAudioInputDeviceId} onSubmit={saveAudioInputDeviceId}
/> />
</div> </div>
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react'
import { LocalAudioTrack, TrackEvent } from 'livekit-client'
import { useTrackVolume } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Text } from '@/primitives'
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.625rem 0.75rem',
marginTop: '0.25rem',
borderTop: '1px solid',
minHeight: '2.5rem',
},
variants: {
theme: {
light: {
borderColor: 'greyscale.250',
color: 'greyscale.600',
},
dark: {
borderColor: 'rgba(255 255 255 / 0.2)',
color: 'rgba(255 255 255 / 0.7)',
},
},
},
})
const StyledGaugeContainer = styled('div', {
base: {
flexGrow: 1,
height: '0.375rem',
borderRadius: '0.1875rem',
overflow: 'hidden',
},
variants: {
theme: {
light: {
backgroundColor: 'greyscale.250',
},
dark: {
backgroundColor: 'rgba(255 255 255 / 0.25)',
},
},
},
})
const StyledGauge = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: 'inherit',
transformOrigin: 'left center',
transform: 'scaleX(0)',
transition: 'transform 0.06s linear',
},
variants: {
theme: {
light: {
backgroundColor: 'primary.500',
},
dark: {
backgroundColor: 'primaryDark.800',
},
},
},
})
type Theme = 'light' | 'dark'
type AudioLevelGaugeProps = {
track?: LocalAudioTrack
variant?: Theme
}
const useIsTrackMuted = (track?: LocalAudioTrack) => {
const [isMuted, setIsMuted] = useState(() => track?.isMuted ?? true)
useEffect(() => {
if (!track) {
setIsMuted(true)
return
}
setIsMuted(track.isMuted)
const onMuted = () => setIsMuted(true)
const onUnmuted = () => setIsMuted(false)
track.on(TrackEvent.Muted, onMuted)
track.on(TrackEvent.Unmuted, onUnmuted)
return () => {
track.off(TrackEvent.Muted, onMuted)
track.off(TrackEvent.Unmuted, onUnmuted)
}
}, [track])
return isMuted
}
const LevelBar = ({
track,
theme,
}: {
track: LocalAudioTrack
theme: Theme
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const volume = useTrackVolume(track, {
fftSize: 256,
smoothingTimeConstant: 0.7,
})
const level = Math.min(1, volume)
return (
<>
<RiMicLine size={18} aria-hidden="true" />
<StyledGaugeContainer
theme={theme}
role="img"
aria-label={t('audioinput.level')}
>
<StyledGauge theme={theme} style={{ transform: `scaleX(${level})` }} />
</StyledGaugeContainer>
</>
)
}
export const AudioLevelGauge = ({
track,
variant = 'light',
}: AudioLevelGaugeProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const isMuted = useIsTrackMuted(track)
const showMutedHint = !track || isMuted
return (
<StyledContainer theme={variant}>
{showMutedHint ? (
<>
<RiMicOffLine size={18} aria-hidden="true" />
<Text variant="sm">{t('audioinput.muteTest')}</Text>
</>
) : (
<LevelBar
key={track.mediaStreamTrack?.id}
track={track}
theme={variant}
/>
)}
</StyledContainer>
)
}
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiVolumeUpLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { reportError } from '@/features/analytics/telemetry'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
// no track: the test plays a bundled file through the selected sink, and
// following `sinkId` mid-playback re-routes it live. No permission involved.
type Theme = 'light' | 'dark'
const BUTTON_VARIANT = {
light: 'quaternaryText',
dark: 'primaryTextDark',
} as const
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
paddingTop: '0.5rem',
borderTop: '1px solid',
},
variants: {
theme: {
light: {
borderColor: 'gray.200',
},
dark: {
borderColor: 'primaryDark.300',
},
},
},
})
type OutputSoundTesterProps = {
/** The device the test should play through (the select's current key). */
sinkId?: string
variant?: Theme
}
export const OutputSoundTester = ({
sinkId,
variant = 'light',
}: OutputSoundTesterProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
useEffect(() => {
if (!sinkId || !canTestAudioOutput()) return
audioRef.current?.setSinkId(sinkId).catch((error) => {
reportError('device_switch_failure', error, {
kind: 'audiooutput',
context: 'test sound setSinkId',
})
})
}, [sinkId])
return (
<StyledContainer theme={variant}>
<Button
variant={BUTTON_VARIANT[variant]}
size="sm"
fullWidth
isDisabled={isPlaying}
onPress={() => {
audioRef.current
?.play()
.then(() => setIsPlaying(true))
.catch(() => {})
}}
>
<RiVolumeUpLine size={18} aria-hidden />
{isPlaying ? t('audiooutput.testing') : t('audiooutput.test')}
</Button>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</StyledContainer>
)
}
@@ -5,6 +5,10 @@ import { Select, SelectProps } from '@/primitives/Select'
import type { Placement } from '@react-types/overlays' import type { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons' import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
import type { LocalAudioTrack } from 'livekit-client'
import { AudioLevelGauge } from './AudioLevelGauge'
import { OutputSoundTester } from './OutputSoundTester'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
type DeviceItems = Array<{ value: string; label: string }> type DeviceItems = Array<{ value: string; label: string }>
@@ -18,6 +22,7 @@ type SelectDeviceProps = {
onSubmit?: (id: string) => void onSubmit?: (id: string) => void
kind: MediaDeviceKind kind: MediaDeviceKind
context?: 'join' | 'room' context?: 'join' | 'room'
track?: LocalAudioTrack
} }
type SelectDevicePermissionsProps<T> = SelectDeviceProps & type SelectDevicePermissionsProps<T> = SelectDeviceProps &
@@ -28,6 +33,7 @@ const SelectDevicePermissions = <T extends string | number>({
kind, kind,
onSubmit, onSubmit,
iconComponent, iconComponent,
track,
...props ...props
}: SelectDevicePermissionsProps<T>) => { }: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -74,6 +80,16 @@ const SelectDevicePermissions = <T extends string | number>({
await setActiveMediaDevice(key as string) await setActiveMediaDevice(key as string)
onSubmit?.(key as string) onSubmit?.(key as string)
}} }}
menuFooter={
kind === 'audioinput' ? (
<AudioLevelGauge track={track} variant={props.variant} />
) : kind === 'audiooutput' && canTestAudioOutput() ? (
<OutputSoundTester
sinkId={selectedKey as string}
variant={props.variant}
/>
) : undefined
}
{...props} {...props}
/> />
) )
@@ -84,6 +100,7 @@ export const SelectDevice = ({
onSubmit, onSubmit,
kind, kind,
context = 'join', context = 'join',
track,
}: SelectDeviceProps) => { }: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -116,6 +133,7 @@ export const SelectDevice = ({
id={id} id={id}
onSubmit={onSubmit} onSubmit={onSubmit}
kind={kind} kind={kind}
track={track}
iconComponent={deviceIcons.select} iconComponent={deviceIcons.select}
{...contextProps} {...contextProps}
/> />
@@ -1,7 +1,7 @@
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils' import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton' import { PermissionNeededButton } from './PermissionNeededButton'
@@ -16,6 +16,7 @@ import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import type { ToggleButtonProps } from '@/primitives/ToggleButton' import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions' import { openPermissionsDialog } from '@/stores/permissions'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type { import type {
@@ -93,6 +94,27 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceShortcut = useDeviceShortcut(kind) const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false)
const onPress = async () => {
if (!cannotUseDevice) {
toggle()
return
}
if (isRequestingPermission.current) return
isRequestingPermission.current = true
try {
const granted = await requestDevicePermission(kind)
if (granted) {
toggle()
} else {
openPermissionsDialog(kind)
}
} finally {
isRequestingPermission.current = false
}
}
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: deviceShortcut?.id, id: deviceShortcut?.id,
handler: async () => { handler: async () => {
@@ -147,12 +169,7 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
} }
shySelected shySelected
onPress={() => { onPress={onPress}
if (cannotUseDevice) {
openPermissionsDialog(kind)
}
toggle()
}}
aria-label={toggleLabel} aria-label={toggleLabel}
tooltip={ tooltip={
cannotUseDevice cannotUseDevice
@@ -3,6 +3,7 @@ import { Button } from '@/primitives'
import { RiPhoneFill } from '@remixicon/react' import { RiPhoneFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ConnectionState } from 'livekit-client' import { ConnectionState } from 'livekit-client'
import { reportError } from '@/features/analytics/telemetry'
export const LeaveButton = () => { export const LeaveButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
@@ -15,11 +16,11 @@ export const LeaveButton = () => {
tooltip={t('leave')} tooltip={t('leave')}
aria-label={t('leave')} aria-label={t('leave')}
onPress={() => { onPress={() => {
room room.disconnect(true).catch((e) =>
.disconnect(true) reportError('disconnect_failure', e, {
.catch((e) => context: 'An error occurred while disconnecting:',
console.error('An error occurred while disconnecting:', e) })
) )
}} }}
data-attr="controls-leave" data-attr="controls-leave"
> >
@@ -33,6 +33,7 @@ import { useConfig } from '@/api/useConfig.ts'
import { proxy, useSnapshot } from 'valtio' import { proxy, useSnapshot } from 'valtio'
import { Spinner } from '@/primitives/Spinner.tsx' import { Spinner } from '@/primitives/Spinner.tsx'
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices' import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius { enum BlurRadius {
NONE = 0, NONE = 0,
@@ -238,7 +239,9 @@ export const EffectsConfiguration = ({
updateEffectStatusMessage(config, wasSelectedBeforeToggle) updateEffectStatusMessage(config, wasSelectedBeforeToggle)
} catch (error) { } catch (error) {
console.error('Error applying effect:', error) reportError('effects_processor_failure', error, {
context: 'Error applying effect:',
})
} finally { } finally {
// Without setTimeout the DOM is not refreshing when updating the options. // Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false)) setTimeout(() => setProcessorPending(false))
@@ -5,6 +5,7 @@ import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor' import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
import type { LocalVideoTrack } from 'livekit-client' import type { LocalVideoTrack } from 'livekit-client'
import { reportError } from '@/features/analytics/telemetry'
export type FunnyEffectsProps = { export type FunnyEffectsProps = {
videoTrack: LocalVideoTrack videoTrack: LocalVideoTrack
@@ -55,7 +56,9 @@ export const FunnyEffects = ({
await videoTrack.setProcessor(newProcessor) await videoTrack.setProcessor(newProcessor)
} }
} catch (e) { } catch (e) {
console.error('could not update processor', e) reportError('effects_processor_failure', e, {
context: 'could not update processor',
})
} finally { } finally {
onPending(false) onPending(false)
} }
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony' import { formatPinCode } from '@/features/rooms/utils/telephony'
import type { ApiRoom } from '@/features/rooms/api/ApiRoom' import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl' import { getRouteUrl } from '@/navigation/getRouteUrl'
import { reportError } from '@/features/analytics/telemetry'
const COPY_SUCCESS_TIMEOUT = 3000 const COPY_SUCCESS_TIMEOUT = 3000
@@ -58,7 +59,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(content) await navigator.clipboard.writeText(content)
setIsCopied(true) setIsCopied(true)
} catch (error) { } catch (error) {
console.error(error) reportError('clipboard_failure', error)
} }
} }
@@ -67,7 +68,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(roomUrl) await navigator.clipboard.writeText(roomUrl)
setIsRoomUrlCopied(true) setIsRoomUrlCopied(true)
} catch (error) { } catch (error) {
console.error(error) reportError('clipboard_failure', error)
} }
} }
@@ -4,6 +4,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { type TrackReferenceOrPlaceholder } from '@livekit/components-core' import { type TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { reportError } from '@/features/analytics/telemetry'
export function useFullScreen({ export function useFullScreen({
trackRef, trackRef,
@@ -56,7 +57,9 @@ export function useFullScreen({
await docEl.msRequestFullscreen() await docEl.msRequestFullscreen()
} }
} catch (error) { } catch (error) {
console.error('Error entering fullscreen:', error) reportError('fullscreen_failure', error, {
context: 'Error entering fullscreen:',
})
} }
} }
@@ -70,7 +73,9 @@ export function useFullScreen({
await document.msExitFullscreen() await document.msExitFullscreen()
} }
} catch (error) { } catch (error) {
console.error('Error exiting fullscreen:', error) reportError('fullscreen_failure', error, {
context: 'Error exiting fullscreen:',
})
} }
} }
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSnapshot } from 'valtio'
import {
createLocalAudioTrack,
createLocalVideoTrack,
type LocalAudioTrack,
type LocalVideoTrack,
MediaDeviceFailure,
TrackEvent,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '../components/blur'
import {
notePermissionDeniedFromGum,
type PermissionKind,
} from '@/stores/permissions'
import { reportError } from '@/features/analytics/telemetry'
import {
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveVideoInputDeviceId,
saveVideoInputEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
const VOICE_AUDIO_CONSTRAINTS = {
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
sampleRate: 48000,
channelCount: 1,
sampleSize: 16,
} as const
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
audioinput: 'microphone',
videoinput: 'camera',
}
export const onJoinPreviewError = (e: Error, kind?: PermissionKind) => {
reportError('join_preview_failure', e, { path: 'join_preview' })
if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
) {
notePermissionDeniedFromGum(kind)
}
}
// Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false)
const stopAll = (stream: MediaStream) =>
stream.getTracks().forEach((track) => track.stop())
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
return true
} catch (error) {
onJoinPreviewError(error as Error, PERMISSION_KIND[kind])
return false
}
}
/**
* Requests camera and microphone once on mount (one combined call → at
* most one browser dialog) and releases them immediately. Returns true
* once settled; track acquisition must wait for it to avoid a second
* dialog.
*/
function useWarmupPermissions(): boolean {
const [done, setDone] = useState(false)
const started = useRef(false)
useEffect(() => {
if (started.current) {
return
}
started.current = true
const warmup = async () => {
try {
stopAll(
await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
})
)
} catch (error) {
if (
MediaDeviceFailure.getFailure(error as Error) ===
MediaDeviceFailure.PermissionDenied
) {
// Retrying after a dismissal would show a second dialog.
onJoinPreviewError(error as Error)
return
}
// Combined requests fail atomically (e.g. missing webcam fails the
// mic too) — retry per kind; permission is settled, no dialog risk.
try {
stopAll(await navigator.mediaDevices.getUserMedia({ audio: true }))
} catch (e) {
onJoinPreviewError(e as Error, 'microphone')
}
try {
stopAll(await navigator.mediaDevices.getUserMedia({ video: true }))
} catch (e) {
onJoinPreviewError(e as Error, 'camera')
}
} finally {
setDone(true)
}
}
warmup()
}, [])
return done
}
function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
ready,
enabled,
create,
permissionKind,
onFailure,
}: {
ready: boolean
enabled: boolean
create: () => Promise<T>
permissionKind: PermissionKind
onFailure: () => void
}): T | null {
const [track, setTrack] = useState<T | null>(null)
// Acquire.
useEffect(() => {
if (!ready || !enabled || track) {
return
}
let cancelled = false
create()
.then((newTrack) => {
if (cancelled) {
newTrack.stop()
return
}
setTrack(newTrack)
})
.catch((error) => {
onJoinPreviewError(error as Error, permissionKind)
onFailure()
})
return () => {
cancelled = true
}
}, [ready, enabled, track, create, permissionKind, onFailure])
// Release on toggle-off so the LED turns off.
useEffect(() => {
if (!enabled && track) {
track.stop()
setTrack(null)
}
}, [enabled, track])
// Track ended externally (permission revoked, device unplugged):
// disable instead of re-acquiring, so no unsolicited dialog.
useEffect(() => {
if (!track) {
return
}
const handleEnded = () => {
setTrack(null)
onFailure()
}
track.on(TrackEvent.Ended, handleEnded)
return () => {
track.off(TrackEvent.Ended, handleEnded)
}
}, [track, onFailure])
// Release on unmount or replacement.
useEffect(() => {
return () => {
track?.stop()
}
}, [track])
return track
}
export function useJoinTracks(): {
audioTrack: LocalAudioTrack | undefined
videoTrack: LocalVideoTrack | undefined
} {
const {
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
processorConfig,
} = useSnapshot(userChoicesStore)
const ready = useWarmupPermissions()
const createAudio = useCallback(
() =>
createLocalAudioTrack({
deviceId: audioDeviceId,
...VOICE_AUDIO_CONSTRAINTS,
}),
[audioDeviceId]
)
const createVideo = useCallback(
() =>
createLocalVideoTrack({
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
}),
[videoDeviceId, processorConfig]
)
const audioTrack = useLocalTrack({
ready,
enabled: audioEnabled,
create: createAudio,
permissionKind: 'microphone',
onFailure: disableAudio,
})
const videoTrack = useLocalTrack({
ready,
enabled: videoEnabled,
create: createVideo,
permissionKind: 'camera',
onFailure: disableVideo,
})
useSyncTrackDeviceId(audioTrack ?? undefined, saveAudioInputDeviceId)
useSyncTrackDeviceId(videoTrack ?? undefined, saveVideoInputDeviceId)
return {
audioTrack: audioTrack ?? undefined,
videoTrack: videoTrack ?? undefined,
}
}
@@ -1,6 +1,7 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom' import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react' import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const usePermissionsManager = () => { export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom() const { mutateAsync: patchRoom } = usePatchRoom()
@@ -28,7 +29,9 @@ export const usePermissionsManager = () => {
return { configuration: newConfiguration } return { configuration: newConfiguration }
} catch (error) { } catch (error) {
console.error('Failed to update muting permission:', error) reportError('permissions_api_failure', error, {
context: 'Failed to update muting permission:',
})
return { success: false, error } return { success: false, error }
} }
}, },
@@ -12,6 +12,7 @@ import {
NotificationType, NotificationType,
useNotifyParticipants, useNotifyParticipants,
} from '@/features/notifications' } from '@/features/notifications'
import { reportError } from '@/features/analytics/telemetry'
export const updatePublishSources = ( export const updatePublishSources = (
currentSources: Source[], currentSources: Source[],
@@ -108,7 +109,9 @@ export const usePublishSourcesManager = () => {
return { configuration: newConfiguration } return { configuration: newConfiguration }
} catch (error) { } catch (error) {
console.error(`Failed to update ${sources}:`, error) reportError('publish_sources_failure', error, {
context: `Failed to update ${sources}:`,
})
return { success: false, error } return { success: false, error }
} }
}, },
@@ -8,6 +8,7 @@ import {
import { isLocal } from '@/utils/livekit' import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand' import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
import { reportError } from '@/features/analytics/telemetry'
type useRaisedHandProps = { type useRaisedHandProps = {
participant: Participant participant: Participant
@@ -79,8 +80,11 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
try { try {
await raiseHand(!isHandRaised) await raiseHand(!isHandRaised)
} catch (e) { } catch (e) {
console.error( reportError(
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}` 'generic_failure',
new Error(
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
)
) )
} }
} }
@@ -1,22 +0,0 @@
import { useEffect, useRef } from 'react'
export const useResolveInitiallyDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
const isInitiated = useRef(false)
useEffect(() => {
if (currentId !== 'default' || !track || isInitiated.current) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') {
isInitiated.current = true
save(actualDeviceId)
}
}
resolveDefaultDeviceId()
}, [currentId, track, save])
}
@@ -0,0 +1,44 @@
import { useEffect } from 'react'
import { TrackEvent, type LocalTrack } from 'livekit-client'
/**
* Keeps the persisted device preference aligned with the device the track
* is ACTUALLY using — the track is the ground truth, not the store.
*
* Syncs on mount and on every TrackEvent.Restarted, which covers all the
* moments the underlying device can change on the join screen:
* - initial acquisition where LiveKit resolved the 'default' alias
* (getDeviceId(normalize=true) resolves it to the concrete id via
* livekit's DeviceManager — this replaces the former
* useResolveInitiallyDefaultDeviceId, which never fired after init),
* - a device switch via setDeviceId,
* - an unmute re-acquisition,
* - the browser falling back to another device.
*/
export const useSyncTrackDeviceId = (
track: LocalTrack | undefined,
save: (deviceId: string) => void
) => {
useEffect(() => {
if (!track) return
let cancelled = false
const sync = () => {
track
.getDeviceId()
.then((deviceId) => {
if (!cancelled && deviceId && deviceId !== 'default') {
save(deviceId)
}
})
.catch(() => {
// A track without settings (ended, screen share) has no id to sync.
})
}
sync()
track.on(TrackEvent.Restarted, sync)
return () => {
cancelled = true
track.off(TrackEvent.Restarted, sync)
}
}, [track, save])
}
@@ -11,6 +11,7 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording' import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { ConnectionObserver } from '../components/ConnectionObserver' import { ConnectionObserver } from '../components/ConnectionObserver'
import { reportError } from '@/features/analytics/telemetry'
import { MediaStateObserver } from '../components/MediaStateObserver' import { MediaStateObserver } from '../components/MediaStateObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer' import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useRoomPageTitle } from '../hooks/useRoomPageTitle' import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
@@ -25,6 +26,7 @@ import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder
import { StageLayout } from '@/features/layout/components/StageLayout' import { StageLayout } from '@/features/layout/components/StageLayout'
import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer' import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider' import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
/** /**
* @public * @public
@@ -64,6 +66,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<> <>
<RoomMetadataSynchronizer /> <RoomMetadataSynchronizer />
<ConnectionObserver /> <ConnectionObserver />
<SyncDevicePreferences />
<MediaStateObserver /> <MediaStateObserver />
<ChatProvider /> <ChatProvider />
<VideoResolutionSubscription /> <VideoResolutionSubscription />
@@ -91,7 +94,10 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
</RoomContentArea> </RoomContentArea>
<ControlBar <ControlBar
onDeviceError={(e) => { onDeviceError={(e) => {
console.error(e) reportError('device_switch_failure', e.error, {
at: 'ControlBar.onDeviceError',
source: e.source,
})
if ( if (
e.source == Track.Source.ScreenShare && e.source == Track.Source.ScreenShare &&
e.error.toString() == e.error.toString() ==
@@ -0,0 +1,3 @@
export const canTestAudioOutput = () =>
typeof HTMLMediaElement !== 'undefined' &&
'setSinkId' in HTMLMediaElement.prototype // Safari: no output routing
@@ -5,6 +5,7 @@ import { useUser } from '@/features/auth/api/useUser'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { CallbackIdHandler } from '../utils/CallbackIdHandler' import { CallbackIdHandler } from '../utils/CallbackIdHandler'
import { PopupWindow } from '../utils/PopupWindow' import { PopupWindow } from '../utils/PopupWindow'
import { reportError } from '@/features/analytics/telemetry'
const callbackIdHandler = new CallbackIdHandler() const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow() const popupWindow = new PopupWindow()
@@ -52,7 +53,9 @@ const CreatePopup = () => {
popupWindow.close() popupWindow.close()
}) })
} catch (error) { } catch (error) {
console.error('Failed to create meeting room:', error) reportError('generic_failure', error, {
context: 'Failed to create meeting room:',
})
} }
} }
if (isLoggedIn && callbackId) { if (isLoggedIn && callbackId) {
@@ -15,6 +15,7 @@ import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom' import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf' import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source type Source = Track.Source
@@ -108,7 +109,7 @@ const SettingsPopup = () => {
patchRoom({ patchRoom({
roomId: roomSlug, roomId: roomSlug,
room: { configuration: newConfiguration }, room: { configuration: newConfiguration },
}).catch((e) => console.error(e)) }).catch((e) => reportError('generic_failure', e))
} }
const updateSource = (sources: Source[], enabled: boolean) => { const updateSource = (sources: Source[], enabled: boolean) => {
@@ -329,7 +330,7 @@ const SettingsPopup = () => {
patchRoom({ patchRoom({
roomId: roomSlug, roomId: roomSlug,
room: { access_level: value as ApiAccessLevel }, room: { access_level: value as ApiAccessLevel },
}).catch((e) => console.error(e)) }).catch((e) => reportError('generic_failure', e))
} }
items={[ items={[
{ {
@@ -1,5 +1,6 @@
import { authUrl } from '@/features/auth/utils/authUrl' import { authUrl } from '@/features/auth/utils/authUrl'
import { PopupMessageType, CallbackCreationRoomData } from './types' import { PopupMessageType, CallbackCreationRoomData } from './types'
import { reportError } from '@/features/analytics/telemetry'
export class PopupWindow { export class PopupWindow {
private sendMessageToManager( private sendMessageToManager(
@@ -9,7 +10,7 @@ export class PopupWindow {
callback?: () => void callback?: () => void
) { ) {
if (!window.opener) { if (!window.opener) {
console.error('No manager window found') reportError('generic_failure', new Error('No manager window found'))
window.close() window.close()
return return
} }
@@ -11,6 +11,7 @@ import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
import { saveUsername } from '@/stores/user' import { saveUsername } from '@/stores/user'
import { logout } from '@/features/auth/utils/logout' import { logout } from '@/features/auth/utils/logout'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { reportError } from '@/features/analytics/telemetry'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> & export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'> Pick<TabPanelProps, 'id'>
@@ -35,8 +36,11 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
saveUsername(name) saveUsername(name)
onOpenChange?.(false) // only close on success onOpenChange?.(false) // only close on success
} catch (error) { } catch (error) {
console.error( reportError(
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}` 'generic_failure',
new Error(
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}`
)
) )
} }
} }
@@ -11,7 +11,6 @@ import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester' import { SoundTester } from '@/components/SoundTester'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker' import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable' import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper' import { RowWrapper } from './layout/RowWrapper'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { import {
@@ -20,6 +19,7 @@ import {
saveNoiseReductionEnabled, saveNoiseReductionEnabled,
userChoicesStore, userChoicesStore,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { captureEvent } from '@/features/analytics/telemetry'
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> & export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'> Pick<TabPanelProps, 'id'>
@@ -128,7 +128,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
isSelected={noiseReductionEnabled} isSelected={noiseReductionEnabled}
onChange={(v) => { onChange={(v) => {
saveNoiseReductionEnabled(v) saveNoiseReductionEnabled(v)
if (v) posthog.capture('noise-reduction-init') if (v) captureEvent('noise-reduction-init')
}} }}
> >
{t('audio.noiseReduction.label')} {t('audio.noiseReduction.label')}
+5 -2
View File
@@ -29,6 +29,8 @@
}, },
"audioinput": { "audioinput": {
"choose": "Mikrofon auswählen", "choose": "Mikrofon auswählen",
"level": "Mikrofon-Eingangspegel",
"muteTest": "Aktivieren Sie Ihr Mikrofon, um es zu testen",
"permissionsNeeded": "Mikrofon auswählen Berechtigung erforderlich", "permissionsNeeded": "Mikrofon auswählen Berechtigung erforderlich",
"disable": "Mikrofon deaktivieren", "disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren", "enable": "Mikrofon aktivieren",
@@ -38,7 +40,9 @@
}, },
"audiooutput": { "audiooutput": {
"choose": "Audioausgabe auswählen", "choose": "Audioausgabe auswählen",
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich" "permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich",
"test": "Lautsprecher testen",
"testing": "Testton wird abgespielt…"
} }
}, },
"join": { "join": {
@@ -326,7 +330,6 @@
} }
} }
}, },
"faceLandmarks": { "faceLandmarks": {
"title": "Visuelle Effekte", "title": "Visuelle Effekte",
"glasses": { "glasses": {
+5 -1
View File
@@ -29,6 +29,8 @@
}, },
"audioinput": { "audioinput": {
"choose": "Select microphone", "choose": "Select microphone",
"level": "Microphone input level",
"muteTest": "Turn on your microphone to test it",
"permissionsNeeded": "Select microphone - permission needed", "permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone", "disable": "Disable microphone",
"enable": "Enable microphone", "enable": "Enable microphone",
@@ -38,7 +40,9 @@
}, },
"audiooutput": { "audiooutput": {
"choose": "Select speaker", "choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed" "permissionsNeeded": "Select speaker - permission needed",
"test": "Test speakers",
"testing": "Playing test sound…"
} }
}, },
"join": { "join": {
+5 -1
View File
@@ -29,6 +29,8 @@
}, },
"audioinput": { "audioinput": {
"choose": "Choisir le micro", "choose": "Choisir le micro",
"level": "Niveau d'entrée du micro",
"muteTest": "Activez votre micro pour le tester",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires", "permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro", "disable": "Désactiver le micro",
"enable": "Activer le micro", "enable": "Activer le micro",
@@ -38,7 +40,9 @@
}, },
"audiooutput": { "audiooutput": {
"choose": "Choisir le haut-parleur", "choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires" "permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires",
"test": "Tester les haut-parleurs",
"testing": "Lecture du son de test…"
} }
}, },
"join": { "join": {
+5 -1
View File
@@ -29,6 +29,8 @@
}, },
"audioinput": { "audioinput": {
"choose": "Selecteer microfoon", "choose": "Selecteer microfoon",
"level": "Microfoon-ingangsniveau",
"muteTest": "Zet je microfoon aan om deze te testen",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist", "permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen", "disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen", "enable": "Microfoon dempen opheffen",
@@ -38,7 +40,9 @@
}, },
"audiooutput": { "audiooutput": {
"choose": "Selecteer luidspreker", "choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist" "permissionsNeeded": "Selecteer luidspreker - Toestemming vereist",
"test": "Luidsprekers testen",
"testing": "Testgeluid wordt afgespeeld…"
} }
}, },
"join": { "join": {
+6 -2
View File
@@ -8,6 +8,7 @@ import { type StyledVariantProps } from '@/styled-system/types'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { FieldErrors } from './FieldErrors' import { FieldErrors } from './FieldErrors'
import { FieldDescription } from './FieldDescription' import { FieldDescription } from './FieldDescription'
import { reportError } from '@/features/analytics/telemetry'
// styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html // styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html
const StyledCheckbox = styled(RACCheckbox, { const StyledCheckbox = styled(RACCheckbox, {
@@ -112,8 +113,11 @@ export const Checkbox = ({
const descriptionId = useId() const descriptionId = useId()
if (isInvalid !== undefined) { if (isInvalid !== undefined) {
console.error( reportError(
'Checkbox: passing isInvalid is not supported, use the validate prop instead' 'generic_failure',
new Error(
'Checkbox: passing isInvalid is not supported, use the validate prop instead'
)
) )
return null return null
} }
+3
View File
@@ -99,6 +99,7 @@ export type SelectProps<T> = Omit<
errors?: ReactNode errors?: ReactNode
placement?: Placement placement?: Placement
variant?: 'light' | 'dark' variant?: 'light' | 'dark'
menuFooter?: ReactNode
} }
export const Select = <T extends string | number>({ export const Select = <T extends string | number>({
@@ -108,6 +109,7 @@ export const Select = <T extends string | number>({
errors, errors,
placement, placement,
variant = 'light', variant = 'light',
menuFooter,
...props ...props
}: SelectProps<T>) => { }: SelectProps<T>) => {
const IconComponent = iconComponent const IconComponent = iconComponent
@@ -155,6 +157,7 @@ export const Select = <T extends string | number>({
</ListBoxItem> </ListBoxItem>
))} ))}
</ListBox> </ListBox>
{menuFooter}
</Box> </Box>
</StyledPopover> </StyledPopover>
{errors} {errors}
+4 -4
View File
@@ -1,6 +1,7 @@
import { proxy, subscribe } from 'valtio' import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys' import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio' import { deserializeToProxyMap } from '@/utils/valtio'
import { reportError } from '@/features/analytics/telemetry'
export type UiFont = export type UiFont =
| 'default' | 'default'
@@ -154,10 +155,9 @@ function getAccessibilityState(): AccessibilityState {
return DEFAULT_STATE return DEFAULT_STATE
} catch (error: unknown) { } catch (error: unknown) {
console.error( reportError('generic_failure', error, {
'[AccessibilityStore] Failed to parse stored settings:', context: '[AccessibilityStore] Failed to parse stored settings:',
error })
)
return DEFAULT_STATE return DEFAULT_STATE
} }
} }
+4 -4
View File
@@ -3,6 +3,7 @@ import { proxyMap } from 'valtio/utils'
import { deserializeToProxyMap, serializeProxyMap } from '@/utils/valtio' import { deserializeToProxyMap, serializeProxyMap } from '@/utils/valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys' import { STORAGE_KEYS } from '@/utils/storageKeys'
import { NotificationType } from '@/features/notifications/NotificationType' import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
type State = { type State = {
soundNotifications: Map<NotificationType, boolean> soundNotifications: Map<NotificationType, boolean>
@@ -41,10 +42,9 @@ function getNotificationsState(): State {
), ),
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error( reportError('generic_failure', error, {
'[NotificationsStore] Failed to parse stored settings:', context: '[NotificationsStore] Failed to parse stored settings:',
error })
)
return DEFAULT_STATE return DEFAULT_STATE
} }
} }
+102 -33
View File
@@ -1,22 +1,13 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
import { derive } from 'derive-valtio'
type PermissionState = type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
| undefined
| 'granted'
| 'prompt'
| 'denied'
| 'unavailable'
type BaseState = { type State = {
cameraPermission: PermissionState cameraPermission: PermissionState
microphonePermission: PermissionState microphonePermission: PermissionState
isLoading: boolean isLoading: boolean
isPermissionDialogOpen: boolean isPermissionDialogOpen: boolean
requestOrigin?: 'audioinput' | 'videoinput' requestOrigin?: 'audioinput' | 'videoinput'
}
type DerivedState = {
isCameraGranted: boolean isCameraGranted: boolean
isMicrophoneGranted: boolean isMicrophoneGranted: boolean
isCameraDenied: boolean isCameraDenied: boolean
@@ -25,34 +16,31 @@ type DerivedState = {
isMicrophonePrompted: boolean isMicrophonePrompted: boolean
} }
type State = BaseState & DerivedState export const permissionsStore = proxy<State>({
export const permissionsStore = proxy<BaseState>({
cameraPermission: undefined, cameraPermission: undefined,
microphonePermission: undefined, microphonePermission: undefined,
isLoading: true, isLoading: true,
isPermissionDialogOpen: false, isPermissionDialogOpen: false,
requestOrigin: undefined, requestOrigin: undefined,
}) as State get isCameraGranted() {
return this.cameraPermission === 'granted'
derive(
{
isCameraGranted: (get) =>
get(permissionsStore).cameraPermission == 'granted',
isMicrophoneGranted: (get) =>
get(permissionsStore).microphonePermission == 'granted',
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
isMicrophoneDenied: (get) =>
get(permissionsStore).microphonePermission == 'denied',
isCameraPrompted: (get) =>
get(permissionsStore).cameraPermission == 'prompt',
isMicrophonePrompted: (get) =>
get(permissionsStore).microphonePermission == 'prompt',
}, },
{ get isMicrophoneGranted() {
proxy: permissionsStore, return this.microphonePermission === 'granted'
} },
) get isCameraDenied() {
return this.cameraPermission === 'denied'
},
get isMicrophoneDenied() {
return this.microphonePermission === 'denied'
},
get isCameraPrompted() {
return this.cameraPermission === 'prompt'
},
get isMicrophonePrompted() {
return this.microphonePermission === 'prompt'
},
})
export const openPermissionsDialog = ( export const openPermissionsDialog = (
requestOrigin?: 'audioinput' | 'videoinput' requestOrigin?: 'audioinput' | 'videoinput'
@@ -64,3 +52,84 @@ export const openPermissionsDialog = (
export const closePermissionsDialog = () => { export const closePermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = false permissionsStore.isPermissionDialogOpen = false
} }
export type PermissionKind = 'camera' | 'microphone'
const KIND_MAP = {
camera: 'videoinput',
microphone: 'audioinput',
} as const
export const PERMISSION_BY_DEVICE_KIND: Partial<
Record<MediaDeviceKind, PermissionKind>
> = {
videoinput: 'camera',
audioinput: 'microphone',
}
export const setPermissions = (
p: Partial<Record<PermissionKind, PermissionState>>
) => {
if (p.camera && p.camera !== permissionsStore.cameraPermission) {
permissionsStore.cameraPermission = p.camera
}
if (p.microphone && p.microphone !== permissionsStore.microphonePermission) {
permissionsStore.microphonePermission = p.microphone
}
permissionsStore.isLoading = false
}
const queryPermission = async (name: PermissionKind) => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return undefined
}
}
const labelsVisible = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
return (kind: PermissionKind) =>
devices.some((d) => d.kind === KIND_MAP[kind] && !!d.label)
} catch {
return (_kind: PermissionKind) => false
}
}
export const syncPermissions = async () => {
const [camera, microphone] = await Promise.all([
queryPermission('camera'),
queryPermission('microphone'),
])
if (camera && microphone) {
setPermissions({ camera, microphone })
return
}
const granted = await labelsVisible()
const resolve = (kind: PermissionKind, queried?: PermissionState) => {
if (queried) return queried
const current =
kind === 'camera'
? permissionsStore.cameraPermission
: permissionsStore.microphonePermission
if (current === 'denied') return 'denied'
return granted(kind) ? 'granted' : 'prompt'
}
setPermissions({
camera: resolve('camera', camera),
microphone: resolve('microphone', microphone),
})
}
export const notePermissionDeniedFromGum = (kind?: PermissionKind) => {
if (kind) {
setPermissions({ [kind]: 'denied' })
void syncPermissions()
return
}
void syncPermissions()
}
+4 -4
View File
@@ -1,5 +1,6 @@
import { proxy, subscribe } from 'valtio' import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys' import { STORAGE_KEYS } from '@/utils/storageKeys'
import { reportError } from '@/features/analytics/telemetry'
type State = { type State = {
username: string username: string
@@ -18,10 +19,9 @@ function getUserState(): State {
...parsed, ...parsed,
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error( reportError('generic_failure', error, {
'[UserPreferencesStore] Failed to parse stored settings:', context: '[UserPreferencesStore] Failed to parse stored settings:',
error })
)
return DEFAULT_STATE return DEFAULT_STATE
} }
} }
+4 -4
View File
@@ -1,5 +1,6 @@
import { proxy, subscribe } from 'valtio' import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys' import { STORAGE_KEYS } from '@/utils/storageKeys'
import { reportError } from '@/features/analytics/telemetry'
type State = { type State = {
is_idle_disconnect_modal_enabled: boolean is_idle_disconnect_modal_enabled: boolean
@@ -21,10 +22,9 @@ function getUserPreferencesState(): State {
...parsed, ...parsed,
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error( reportError('generic_failure', error, {
'[UserPreferencesStore] Failed to parse stored settings:', context: '[UserPreferencesStore] Failed to parse stored settings:',
error })
)
return DEFAULT_STATE return DEFAULT_STATE
} }
} }
+2 -1
View File
@@ -13,6 +13,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import clsx from 'clsx' import clsx from 'clsx'
import { reportError } from '@/features/analytics/telemetry'
/** /**
* Calls all functions in the order they were chained with the same arguments. * Calls all functions in the order they were chained with the same arguments.
@@ -25,7 +26,7 @@ export function chain(...callbacks: any[]): (...args: any[]) => void {
try { try {
callback(...args) callback(...args)
} catch (e) { } catch (e) {
console.error(e) reportError('generic_failure', e)
} }
} }
} }
+2 -14
View File
@@ -1,20 +1,8 @@
import { isMobileBrowser } from '@livekit/components-core' import { isMobileBrowser } from '@livekit/components-core'
import { useEffect, useState } from 'react' import { useState } from 'react'
export const useIsMobile = () => { export const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(isMobileBrowser()) const [isMobile] = useState(() => isMobileBrowser())
useEffect(() => {
const handleResize = () => {
setIsMobile(isMobileBrowser())
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
return isMobile return isMobile
} }