Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 8972c8dbb8 wip allow authenticated user to launch recording/transcription 2026-08-05 17:27:32 +02:00
95 changed files with 1326 additions and 1797 deletions
-15
View File
@@ -8,21 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.25.2] - 2026-08-06
### Fixed
- 🐛(frontend) serve MediaPipe assets under a versioned path
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
## [1.25.1] - 2026-08-06
### Fixed
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
## [1.25.0] - 2026-08-05
### Added
- ✨(summary) report exception type in failure analytics
-5
View File
@@ -65,11 +65,6 @@ server {
sub_filter_once off;
}
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.25.2"
version = "1.24.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.25.2"
version = "1.24.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+34
View File
@@ -198,3 +198,37 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
except ParticipantsManagementException:
return False
class RoomCapabilityPermission(permissions.BasePermission):
"""Wip."""
capability = None
def has_object_permission(self, request, view, obj):
"""Wip."""
user = request.user
if not user or not user.is_authenticated:
return False
if obj.is_administrator_or_owner(user):
return True
if not obj.configuration.get(self.capability, True):
return False
try:
return ParticipantsManagement().check_if_in_meeting(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
class CanRecord(RoomCapabilityPermission):
"""Wip."""
capability = "authenticated_can_record"
+1
View File
@@ -349,6 +349,7 @@ class RoomConfiguration(BaseModel):
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
authenticated_can_record: bool | None = None
model_config = {"extra": "forbid"}
+26 -7
View File
@@ -396,7 +396,7 @@ class RoomViewSet(
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanRecord,
],
)
@FeatureFlag.require("recording")
@@ -421,11 +421,30 @@ class RoomViewSet(
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
if room.is_administrator_or_owner(request.user):
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
else:
# todo - encapsulate it in a clear method which replicate privileges role on obj
accesses = models.ResourceAccess.objects.filter(
resource=room,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).values_list("user_id", "role")
models.RecordingAccess.objects.bulk_create(
[
models.RecordingAccess(
user_id=user_id,
role=role,
recording=recording,
)
for user_id, role in accesses
]
)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
@@ -469,7 +488,7 @@ class RoomViewSet(
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanRecord,
],
)
@FeatureFlag.require("recording")
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.25.2"
version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.25.2"
version = "1.24.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
-5
View File
@@ -4,11 +4,6 @@ server {
server_tokens off;
root /usr/share/nginx/html;
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+20 -4
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.25.2",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.25.2",
"version": "1.24.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
@@ -15,13 +15,14 @@
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.3.1",
@@ -1048,12 +1049,18 @@
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0"
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -4709,6 +4716,15 @@
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.25.2",
"version": "1.24.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -22,13 +22,14 @@
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.3.1",
+1 -5
View File
@@ -2,7 +2,6 @@ import { Button } from '@/primitives'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry'
export const SoundTester = () => {
const { t } = useTranslation('settings')
@@ -16,10 +15,7 @@ export const SoundTester = () => {
try {
await audioRef?.current?.setSinkId(deviceId)
} catch (error) {
reportError(
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
console.error(`Error setting sinkId: ${error}`)
}
}
updateActiveId(activeDeviceId)
@@ -1,8 +1,15 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import { type PostHog } from 'posthog-js'
import { type ApiUser } from '@/features/auth/api/ApiUser'
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) => {
getPosthog().then((ph) => {
@@ -1,140 +0,0 @@
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()) })
}
@@ -1,8 +0,0 @@
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,7 +15,6 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen'
import { reportError } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -161,9 +160,7 @@ const Home = () => {
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
reportError('generic_failure', error, {
context: 'Site is not reachable:',
})
console.error('Site is not reachable:', error)
}
}
}
@@ -4,7 +4,6 @@ import { NotificationDuration } from './NotificationDuration'
import type { Participant } from 'livekit-client'
import type { NotificationPayload } from './NotificationPayload'
import type { RecordingMode } from '@/features/recording'
import { reportError } from '@/features/analytics/telemetry'
export const notifyAutoMutedOnJoin = () => {
toastQueue.add(
@@ -56,9 +55,7 @@ export const decodeNotificationDataReceived = (
return parsed as NotificationPayload
} catch (error) {
// Handle errors appropriately for your application
reportError('generic_failure', error, {
context: 'Failed to decode notification payload:',
})
console.error('Failed to decode notification payload:', error)
return
}
}
@@ -1,6 +1,5 @@
import type { Participant } from 'livekit-client'
import { useLowerHandParticipant } from './lowerHandParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant()
@@ -12,9 +11,7 @@ export const useLowerHandParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('generic_failure', error, {
context: 'An error occurred while lowering hands :',
})
console.error('An error occurred while lowering hands :', error)
throw new Error('An error occurred while lowering hands.', {
cause: error,
})
@@ -1,7 +1,6 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
import { reportError } from '@/features/analytics/telemetry'
export const useParticipantRole = () => {
const data = useRoomData()
@@ -23,11 +22,8 @@ export const useParticipantRole = () => {
}),
})
} catch (error) {
reportError(
'generic_failure',
new Error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -10,7 +10,6 @@ import {
} from '../../participants/api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
export const POLL_INTERVAL_MS = 1000
@@ -88,7 +87,7 @@ export const useWaitingParticipants = () => {
await refetchWaiting()
} catch (e) {
reportError('generic_failure', e)
console.error(e)
setListEnabled(true)
}
}
@@ -7,9 +7,7 @@ import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
sync: true,
})
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
@@ -21,7 +19,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
}
}, [])
if (!container || !container.isConnected) return null
if (!container) return null
return createPortal(
/**
@@ -1,9 +1,7 @@
import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
@@ -75,9 +73,7 @@ export const usePictureInPicture = () => {
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
flushSync(() => {
documentPictureInPictureStore.window = null
})
documentPictureInPictureStore.window = null
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
@@ -87,9 +83,7 @@ export const usePictureInPicture = () => {
documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) {
// Avoid unhandled rejections if the user blocks or closes the request.
reportError('generic_failure', error, {
context: 'Failed to open Picture-in-Picture window',
})
console.error('Failed to open Picture-in-Picture window', error)
return null
}
},
@@ -98,6 +98,7 @@ export const NoAccessView = ({
)}
</Text>
</VStack>
{/*todo - update the message here to adapt to room configuration */}
{!isLoggedIn && (
<LoginPrompt
heading={t(`${i18nKey}.login.heading`)}
@@ -16,6 +16,7 @@ import {
notifyRecordingSaveInProgress,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
@@ -28,7 +29,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { FeatureFlags } from '@/features/analytics/enums'
import { LimitDescription } from './LimitDescription'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
import { useCanRecord } from '@/features/recording/hooks/useCanRecord'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -38,6 +39,7 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const canRecord = useCanRecord()
const isAdminOrOwner = useIsAdminOrOwner()
const hasScreenRecordingAccess = useHasRecordingAccess(
@@ -63,7 +65,7 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingRequested,
})
captureEvent('screen-recording-requested', {})
posthog.capture('screen-recording-requested', {})
}
const handleScreenRecording = async () => {
@@ -100,19 +102,17 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
captureEvent('screen-recording-started', {
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
}
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to handle recording:',
})
console.error('Failed to handle recording:', error)
}
}
if (!isAdminOrOwner) {
if (!canRecord) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
@@ -6,7 +6,7 @@ import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useHasFeatureWithoutAdminRights,
useHasFeatureWithoutRecordingRights,
useRecordingStatuses,
} from '../index'
import { useState } from 'react'
@@ -17,6 +17,7 @@ import {
useNotifyParticipants,
notifyRecordingSaveInProgress,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx'
@@ -34,7 +35,6 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitDescription } from './LimitDescription'
import { openSettingsDialog } from '@/stores/settings'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
@@ -53,7 +53,7 @@ export const TranscriptSidePanel = () => {
FeatureFlags.Transcript
)
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
const hasFeatureWithoutAdminRights = useHasFeatureWithoutRecordingRights(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionRequested,
})
captureEvent('transcript-requested', {})
posthog.capture('transcript-requested', {})
}
const handleTranscript = async () => {
@@ -121,15 +121,13 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
captureEvent('transcript-started', {
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
}
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to handle transcript:',
})
console.error('Failed to handle transcript:', error)
}
}
@@ -0,0 +1,15 @@
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useUser } from '@/features/auth/api/useUser'
export const useCanRecord = () => {
const apiRoomData = useRoomData()
const isAdminOrOwner = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
return (
isAdminOrOwner ||
(!!isLoggedIn &&
apiRoomData?.configuration?.authenticated_can_record !== false)
)
}
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useCanRecord } from './useCanRecord'
import type { FeatureFlags } from '@/features/analytics/enums'
export const useHasFeatureWithoutAdminRights = (
export const useHasFeatureWithoutRecordingRights = (
mode: RecordingMode,
featureFlag: FeatureFlags
) => {
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const isAdminOrOwner = useIsAdminOrOwner()
const canRecord = useCanRecord()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isRecordingModeEnabled &&
!isAdminOrOwner
!canRecord
)
}
@@ -2,8 +2,8 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { FeatureFlags } from '@/features/analytics/enums'
import { useCanRecord } from './useCanRecord'
export const useHasRecordingAccess = (
mode: RecordingMode,
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const isAdminOrOwner = useIsAdminOrOwner()
const canRecord = useCanRecord()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
canRecord &&
isRecordingModeEnabled
)
}
@@ -1,6 +1,5 @@
import { useRoomInfo } from '@livekit/components-react'
import { useMemo } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useRoomMetadata = () => {
const { metadata } = useRoomInfo()
@@ -9,9 +8,7 @@ export const useRoomMetadata = () => {
try {
return JSON.parse(metadata)
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to parse room metadata:',
})
console.error('Failed to parse room metadata:', error)
return undefined
}
} else {
+1 -1
View File
@@ -1,7 +1,7 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights.ts'
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
// api
@@ -16,6 +16,7 @@ export enum ApiAccessLevel {
export type RoomConfiguration = {
can_publish_sources?: Source[] | null
everyone_can_mute?: boolean | null
authenticated_can_record?: boolean | null
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
@@ -10,5 +10,5 @@ export const fetchRoom = ({
}) => {
const query = username ? `?username=${encodeURIComponent(username)}` : ''
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
return fetchApi<ApiRoom>(`/rooms/${roomId}${query}`)
}
@@ -9,7 +9,6 @@ import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipant = () => {
const apiRoomData = useRoomData()
@@ -32,10 +31,7 @@ export const useMuteParticipant = () => {
// Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
reportError(
'participant_mute_api_failure',
new Error('Cannot mute participant: missing auth token')
)
console.error('Cannot mute participant: missing auth token')
return
}
@@ -57,11 +53,8 @@ export const useMuteParticipant = () => {
}
)
} catch (error) {
reportError(
'participant_mute_api_failure',
new Error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
return
}
@@ -72,11 +65,8 @@ export const useMuteParticipant = () => {
destinationIdentities: [participant.identity],
})
} catch (e) {
reportError(
'participant_mute_api_failure',
new Error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
console.error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
}
@@ -1,6 +1,5 @@
import type { Participant } from 'livekit-client'
import { useMuteParticipant } from './muteParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipants = () => {
const { muteParticipant } = useMuteParticipant()
@@ -12,9 +11,7 @@ export const useMuteParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('participant_mute_api_failure', error, {
context: 'An error occurred while muting participants :',
})
console.error('An error occurred while muting participants :', error)
throw new Error('An error occurred while muting participants.', {
cause: error,
})
@@ -1,7 +1,6 @@
import type { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
export const useParticipantPermissions = () => {
@@ -33,11 +32,8 @@ export const useParticipantPermissions = () => {
}),
})
} catch (error) {
reportError(
'permissions_api_failure',
new Error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -1,6 +1,5 @@
import type { Participant, Track } from 'livekit-client'
import { useParticipantPermissions } from './updateParticipantPermissions'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
export const useUpdateParticipantsPermissions = () => {
@@ -16,9 +15,7 @@ export const useUpdateParticipantsPermissions = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('permissions_api_failure', error, {
context: 'An error occurred while updating permissions :',
})
console.error('An error occurred while updating permissions :', error)
throw new Error('An error occurred while updating permissions.', {
cause: error,
})
@@ -26,11 +26,7 @@ import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import {
captureEvent,
reportError,
captureMediaEvent,
} from '@/features/analytics/telemetry'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
@@ -41,10 +37,6 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
import {
PERMISSION_BY_DEVICE_KIND,
notePermissionDeniedFromGum,
} from '@/stores/permissions'
export const Conference = ({
roomId,
@@ -55,6 +47,7 @@ export const Conference = ({
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
@@ -64,8 +57,8 @@ export const Conference = ({
const { username } = useSnapshot(userStore)
useEffect(() => {
captureEvent('visit-room', { slug: roomId })
}, [roomId])
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
const fetchKey = [keys.room, roomId]
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
@@ -242,10 +235,7 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
reportError('livekit_room_error', e, {
path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
})
posthog.captureException(e)
}}
onConnected={async () => {
if (!apiConfig) return
@@ -306,22 +296,8 @@ export const Conference = ({
}
}}
onMediaDeviceFailure={(e, kind) => {
if (!e || !kind) return
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
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
setMediaDeviceError({ error: e, kind })
}
}}
>
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
const roomUrl = getRouteUrl('room', roomData?.slug)
const telephony = useTelephony()
File diff suppressed because it is too large Load Diff
@@ -1,167 +0,0 @@
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,12 +1,13 @@
import { Button, H, Text, TextArea } from '@/primitives'
import { Button, H, Input, Text, TextArea } from '@/primitives'
import { useEffect, useMemo, useState } from 'react'
import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
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 { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { CandidateInfo } from '@/stores/connectionObserver'
import { captureEvent } from '@/features/analytics/telemetry'
const Card = styled('div', {
base: {
@@ -71,9 +72,11 @@ const labelRecipe = cva({
})
const OpenFeedback = ({
posthog,
onNext,
metadata,
}: {
posthog: PostHog
onNext: () => void
metadata?: Record<string, unknown>
}) => {
@@ -87,7 +90,7 @@ const OpenFeedback = ({
const onSubmit = () => {
try {
captureEvent('open-feedback', {
posthog.capture('open-feedback', {
feedback,
...metadata,
})
@@ -138,10 +141,12 @@ const OpenFeedback = ({
}
const RateQuality = ({
posthog,
onNext,
metadata,
maxRating = 5,
}: {
posthog: PostHog
onNext: () => void
metadata?: Record<string, unknown>
maxRating?: number
@@ -155,7 +160,7 @@ const RateQuality = ({
const onSubmit = () => {
try {
captureEvent('quality-rating', {
posthog.capture('quality-rating', {
rating: selectedRating,
...metadata,
})
@@ -238,6 +243,67 @@ 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 = {
room_id?: string
pc_publisher?: CandidateInfo
@@ -252,6 +318,12 @@ export const Rating = ({
metadata: RatingMetadata
}) => {
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const posthog = usePostHog()
const isUserAnonymous = useMemo(() => {
return posthog.get_property('$user_state') == 'anonymous'
}, [posthog])
const [step, setStep] = useState(0)
const sessionId = useMemo(() => crypto.randomUUID(), [])
@@ -267,14 +339,37 @@ export const Rating = ({
if (!isAnalyticsEnabled) return
if (step == 0) {
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
return (
<RateQuality
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
}
if (step == 1) {
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
return (
<OpenFeedback
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
}
if (step == 2) {
return isUserAnonymous ? (
<AuthenticationMessage
posthog={posthog}
onNext={() => setStep(step + 1)}
/>
) : (
<ConfirmationMessage onNext={() => setStep(0)} />
)
}
if (step == 3) {
return <ConfirmationMessage onNext={() => setStep(0)} />
}
}
@@ -1,36 +1,160 @@
import { useEffect } from 'react'
import { syncPermissions } from '@/stores/permissions'
import { permissionsStore } from '@/stores/permissions'
import { isSafari } from '@/utils/livekit'
const POLLING_TIME = 500
export const useWatchPermissions = () => {
useEffect(() => {
const sync = () => void syncPermissions()
sync()
let cleanup: (() => void) | undefined
let intervalId: ReturnType<typeof setTimeout> | undefined
let isCancelled = false
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
window.addEventListener('focus', sync)
const checkPermissions = async () => {
try {
if (!navigator.permissions) {
if (!isCancelled) {
permissionsStore.cameraPermission = 'unavailable'
permissionsStore.microphonePermission = 'unavailable'
}
return
}
let statuses: PermissionStatus[] = []
let cancelled = false
if (navigator.permissions) {
Promise.all([
navigator.permissions.query({ name: 'camera' as PermissionName }),
navigator.permissions.query({ name: 'microphone' as PermissionName }),
])
.then((results) => {
if (cancelled) return
statuses = results
statuses.forEach((s) => s.addEventListener('change', sync))
})
.catch(() => {
// Query unsupported: devicechange/focus + gUM outcomes cover it.
})
const [cameraPermission, microphonePermission] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
/**
* Safari Permission API Limitation Workaround
*
* Safari has a known issue where permission change events are not reliably fired
* when users interact with permission prompts. This is documented in Apple's forums:
* https://developer.apple.com/forums/thread/757353
*
* 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 () => {
cancelled = true
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
window.removeEventListener('focus', sync)
statuses.forEach((s) => s.removeEventListener('change', sync))
isCancelled = true
cleanup?.()
}
}, [])
}
@@ -13,7 +13,6 @@ import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { useEffect } from 'react'
import { closeSidePanel } from '@/stores/layout'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { reportError } from '@/features/analytics/telemetry'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -50,7 +49,12 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
const {
toggleMuting,
isMutingEnabled,
isAuthenticatedCanRecordEnabled,
toggleAuthenticatedCanRecord,
} = usePermissionsManager()
return (
<Div
@@ -155,6 +159,17 @@ export const Admin = () => {
fullWidth: true,
}}
/>
<Field
type="switch"
label={'Authenticated can record'}
description={'Authenticated can record'}
isSelected={isAuthenticatedCanRecordEnabled}
onChange={toggleAuthenticatedCanRecord}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
</div>
<div
@@ -206,7 +221,7 @@ export const Admin = () => {
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
}).catch((e) => reportError('generic_failure', e))
}).catch((e) => console.error(e))
}
items={[
{
@@ -11,10 +11,10 @@ import { DisconnectReason, RoomEvent } from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences'
import { connectionObserverStore } from '@/stores/connectionObserver'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { isMobileBrowser } from '@livekit/components-core'
import { FeatureFlags } from '@/features/analytics/enums'
import { captureEvent } from '@/features/analytics/telemetry'
const CANDIDATE_POLL_INTERVAL_MS = 5000
@@ -182,23 +182,23 @@ export const ConnectionObserver = () => {
// total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now()
captureEvent('connection-event')
posthog.capture('connection-event')
}
const handleReconnect = () => {
captureEvent('reconnect-event')
posthog.capture('reconnect-event')
}
const handleReconnected = () => {
captureEvent('reconnected-event')
posthog.capture('reconnected-event')
}
const handleSignalingConnect = () => {
captureEvent('signaling-connect-event')
posthog.capture('signaling-connect-event')
}
const handleSignalingReconnect = () => {
captureEvent('signaling-reconnect-event')
posthog.capture('signaling-reconnect-event')
}
const handleDisconnect = (
@@ -206,7 +206,7 @@ export const ConnectionObserver = () => {
) => {
const connectionEndTime = Date.now()
captureEvent('disconnect-event', {
posthog.capture('disconnect-event', {
// Calculate total session duration from first connection to final disconnect
// This duration is sensitive to refreshing the page.
sessionDuration: connectionStartTimeRef.current
@@ -14,7 +14,7 @@ export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const data = useRoomData()
const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
const roomUrl = getRouteUrl('room', data?.slug)
const telephony = useTelephony()
@@ -1,20 +0,0 @@
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,4 +1,5 @@
import type { ProcessorOptions, Track } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
ImageSegmenter,
@@ -17,7 +18,6 @@ import {
type ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.'
import { captureEvent } from '@/features/analytics/telemetry.ts'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -100,7 +100,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.initSegmenter()
this._initWorker()
captureEvent('firefox-blurring-init', {})
posthog.capture('firefox-blurring-init')
}
_initVirtualBackgroundImage() {
@@ -1,4 +1,5 @@
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
FaceLandmarker,
@@ -15,7 +16,6 @@ import {
ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.'
import { captureEvent } from '@/features/analytics/telemetry'
const PROCESSING_WIDTH = 256 * 3
const PROCESSING_HEIGHT = 144 * 3
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
await this.initFaceLandmarker()
this._initWorker()
captureEvent('face-landmarks-init', {})
posthog.capture('face-landmarks-init')
}
_initWorker() {
@@ -1,7 +1,4 @@
import {
ProcessorWrapper,
supportsBackgroundProcessors,
} from '@livekit/track-processors'
import { ProcessorWrapper } from '@livekit/track-processors'
import type { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
@@ -13,7 +10,7 @@ export const SELFIE_SEGMENTER_MODEL_PATH =
export const FACE_LANDMARKS_MODEL_PATH =
'/assets/mediapipe/models/face_landmarker.task'
export const MEDIAPIPE_PATH_WASM = `/assets/mediapipe/wasm/${__MEDIAPIPE_VERSION__}`
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
export enum ProcessorType {
BLUR = 'blur',
@@ -37,9 +34,7 @@ export class BackgroundProcessorFactory {
}
static isSupported() {
return (
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
)
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
}
static getProcessor(
@@ -50,7 +45,7 @@ export class BackgroundProcessorFactory {
if (!isBlur && !isVirtual) return undefined
if (supportsBackgroundProcessors()) {
if (ProcessorWrapper.isSupported) {
return new UnifiedBackgroundTrackProcessor(config)
}
@@ -4,7 +4,6 @@ import { RiCameraSwitchLine } from '@remixicon/react'
import { useEffect, useState } from 'react'
import type { ButtonProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
enum FacingMode {
USER = 'user',
@@ -104,11 +103,7 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
setActiveMediaDevice(device.deviceId)
setFacingMode(target)
} else {
reportError(
'device_switch_failure',
new Error('Cannot get user device with facingMode ' + target),
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
)
console.error('Cannot get user device with facingMode ' + target)
}
}
return (
@@ -1,12 +1,8 @@
import { useTranslation } from 'react-i18next'
import {
useLocalParticipant,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import { LocalAudioTrack, Track } from 'livekit-client'
import { Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
@@ -55,12 +51,6 @@ export const AudioDevicesControl = ({
...props,
})
const { microphoneTrack } = useLocalParticipant()
const localAudioTrack =
microphoneTrack?.track instanceof LocalAudioTrack
? microphoneTrack.track
: undefined
const kind = 'audioinput'
const cannotUseDevice = useCannotUseDevice(kind)
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
@@ -121,7 +111,6 @@ export const AudioDevicesControl = ({
context="room"
kind={kind}
id={audioDeviceId}
track={localAudioTrack}
onSubmit={saveAudioInputDeviceId}
/>
</div>
@@ -1,154 +0,0 @@
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>
)
}
@@ -1,89 +0,0 @@
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,10 +5,6 @@ import { Select, SelectProps } from '@/primitives/Select'
import type { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
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 }>
@@ -22,7 +18,6 @@ type SelectDeviceProps = {
onSubmit?: (id: string) => void
kind: MediaDeviceKind
context?: 'join' | 'room'
track?: LocalAudioTrack
}
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
@@ -33,7 +28,6 @@ const SelectDevicePermissions = <T extends string | number>({
kind,
onSubmit,
iconComponent,
track,
...props
}: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -80,16 +74,6 @@ const SelectDevicePermissions = <T extends string | number>({
await setActiveMediaDevice(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}
/>
)
@@ -100,7 +84,6 @@ export const SelectDevice = ({
onSubmit,
kind,
context = 'join',
track,
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -133,7 +116,6 @@ export const SelectDevice = ({
id={id}
onSubmit={onSubmit}
kind={kind}
track={track}
iconComponent={deviceIcons.select}
{...contextProps}
/>
@@ -1,7 +1,7 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
@@ -16,7 +16,6 @@ import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type {
@@ -94,27 +93,6 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceShortcut = useDeviceShortcut(kind)
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({
id: deviceShortcut?.id,
handler: async () => {
@@ -169,7 +147,12 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
}
shySelected
onPress={onPress}
onPress={() => {
if (cannotUseDevice) {
openPermissionsDialog(kind)
}
toggle()
}}
aria-label={toggleLabel}
tooltip={
cannotUseDevice
@@ -3,7 +3,6 @@ import { Button } from '@/primitives'
import { RiPhoneFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ConnectionState } from 'livekit-client'
import { reportError } from '@/features/analytics/telemetry'
export const LeaveButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
@@ -16,11 +15,11 @@ export const LeaveButton = () => {
tooltip={t('leave')}
aria-label={t('leave')}
onPress={() => {
room.disconnect(true).catch((e) =>
reportError('disconnect_failure', e, {
context: 'An error occurred while disconnecting:',
})
)
room
.disconnect(true)
.catch((e) =>
console.error('An error occurred while disconnecting:', e)
)
}}
data-attr="controls-leave"
>
@@ -33,7 +33,6 @@ import { useConfig } from '@/api/useConfig.ts'
import { proxy, useSnapshot } from 'valtio'
import { Spinner } from '@/primitives/Spinner.tsx'
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius {
NONE = 0,
@@ -239,9 +238,7 @@ export const EffectsConfiguration = ({
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
} catch (error) {
reportError('effects_processor_failure', error, {
context: 'Error applying effect:',
})
console.error('Error applying effect:', error)
} finally {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
@@ -5,7 +5,6 @@ import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
import type { LocalVideoTrack } from 'livekit-client'
import { reportError } from '@/features/analytics/telemetry'
export type FunnyEffectsProps = {
videoTrack: LocalVideoTrack
@@ -56,9 +55,7 @@ export const FunnyEffects = ({
await videoTrack.setProcessor(newProcessor)
}
} catch (e) {
reportError('effects_processor_failure', e, {
context: 'could not update processor',
})
console.error('could not update processor', e)
} finally {
onPending(false)
}
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { reportError } from '@/features/analytics/telemetry'
const COPY_SUCCESS_TIMEOUT = 3000
@@ -59,7 +58,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(content)
setIsCopied(true)
} catch (error) {
reportError('clipboard_failure', error)
console.error(error)
}
}
@@ -68,7 +67,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(roomUrl)
setIsRoomUrlCopied(true)
} catch (error) {
reportError('clipboard_failure', error)
console.error(error)
}
}
@@ -4,7 +4,6 @@
import { useMemo, useState } from 'react'
import { type TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { reportError } from '@/features/analytics/telemetry'
export function useFullScreen({
trackRef,
@@ -57,9 +56,7 @@ export function useFullScreen({
await docEl.msRequestFullscreen()
}
} catch (error) {
reportError('fullscreen_failure', error, {
context: 'Error entering fullscreen:',
})
console.error('Error entering fullscreen:', error)
}
}
@@ -73,9 +70,7 @@ export function useFullScreen({
await document.msExitFullscreen()
}
} catch (error) {
reportError('fullscreen_failure', error, {
context: 'Error exiting fullscreen:',
})
console.error('Error exiting fullscreen:', error)
}
}
@@ -1,256 +0,0 @@
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,7 +1,6 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
@@ -11,6 +10,8 @@ export const usePermissionsManager = () => {
const roomId = data?.slug
const isMutingEnabled = configuration?.everyone_can_mute ?? true
const isAuthenticatedCanRecordEnabled =
configuration?.authenticated_can_record ?? true
const toggleMuting = useCallback(
async (enabled: boolean) => {
@@ -29,9 +30,33 @@ export const usePermissionsManager = () => {
return { configuration: newConfiguration }
} catch (error) {
reportError('permissions_api_failure', error, {
context: 'Failed to update muting permission:',
console.error('Failed to update muting permission:', error)
return { success: false, error }
}
},
[configuration, roomId, patchRoom]
)
const toggleAuthenticatedCanRecord = useCallback(
async (enabled: boolean) => {
if (!roomId) return
try {
const newConfiguration = {
...configuration,
authenticated_can_record: enabled,
}
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
return { configuration: newConfiguration }
} catch (error) {
console.error('Failed to update recording permission:', error)
return { success: false, error }
}
},
@@ -41,5 +66,7 @@ export const usePermissionsManager = () => {
return {
toggleMuting,
isMutingEnabled,
isAuthenticatedCanRecordEnabled,
toggleAuthenticatedCanRecord,
}
}
@@ -12,7 +12,6 @@ import {
NotificationType,
useNotifyParticipants,
} from '@/features/notifications'
import { reportError } from '@/features/analytics/telemetry'
export const updatePublishSources = (
currentSources: Source[],
@@ -109,9 +108,7 @@ export const usePublishSourcesManager = () => {
return { configuration: newConfiguration }
} catch (error) {
reportError('publish_sources_failure', error, {
context: `Failed to update ${sources}:`,
})
console.error(`Failed to update ${sources}:`, error)
return { success: false, error }
}
},
@@ -8,7 +8,6 @@ import {
import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react'
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
import { reportError } from '@/features/analytics/telemetry'
type useRaisedHandProps = {
participant: Participant
@@ -80,11 +79,8 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
try {
await raiseHand(!isHandRaised)
} catch (e) {
reportError(
'generic_failure',
new Error(
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
)
console.error(
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
)
}
}
@@ -0,0 +1,22 @@
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])
}
@@ -1,44 +0,0 @@
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,7 +11,6 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { ConnectionObserver } from '../components/ConnectionObserver'
import { reportError } from '@/features/analytics/telemetry'
import { MediaStateObserver } from '../components/MediaStateObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
@@ -26,7 +25,6 @@ import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder
import { StageLayout } from '@/features/layout/components/StageLayout'
import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
/**
* @public
@@ -66,7 +64,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<>
<RoomMetadataSynchronizer />
<ConnectionObserver />
<SyncDevicePreferences />
<MediaStateObserver />
<ChatProvider />
<VideoResolutionSubscription />
@@ -94,10 +91,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
</RoomContentArea>
<ControlBar
onDeviceError={(e) => {
reportError('device_switch_failure', e.error, {
at: 'ControlBar.onDeviceError',
source: e.source,
})
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
@@ -1,3 +0,0 @@
export const canTestAudioOutput = () =>
typeof HTMLMediaElement !== 'undefined' &&
'setSinkId' in HTMLMediaElement.prototype // Safari: no output routing
@@ -5,7 +5,6 @@ import { useUser } from '@/features/auth/api/useUser'
import { Spinner } from '@/primitives/Spinner'
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
import { PopupWindow } from '../utils/PopupWindow'
import { reportError } from '@/features/analytics/telemetry'
const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
@@ -53,9 +52,7 @@ const CreatePopup = () => {
popupWindow.close()
})
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to create meeting room:',
})
console.error('Failed to create meeting room:', error)
}
}
if (isLoggedIn && callbackId) {
@@ -15,7 +15,6 @@ import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
@@ -109,7 +108,7 @@ const SettingsPopup = () => {
patchRoom({
roomId: roomSlug,
room: { configuration: newConfiguration },
}).catch((e) => reportError('generic_failure', e))
}).catch((e) => console.error(e))
}
const updateSource = (sources: Source[], enabled: boolean) => {
@@ -330,7 +329,7 @@ const SettingsPopup = () => {
patchRoom({
roomId: roomSlug,
room: { access_level: value as ApiAccessLevel },
}).catch((e) => reportError('generic_failure', e))
}).catch((e) => console.error(e))
}
items={[
{
@@ -1,6 +1,5 @@
import { authUrl } from '@/features/auth/utils/authUrl'
import { PopupMessageType, CallbackCreationRoomData } from './types'
import { reportError } from '@/features/analytics/telemetry'
export class PopupWindow {
private sendMessageToManager(
@@ -10,7 +9,7 @@ export class PopupWindow {
callback?: () => void
) {
if (!window.opener) {
reportError('generic_failure', new Error('No manager window found'))
console.error('No manager window found')
window.close()
return
}
@@ -11,7 +11,6 @@ import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
import { saveUsername } from '@/stores/user'
import { logout } from '@/features/auth/utils/logout'
import { useConfig } from '@/api/useConfig'
import { reportError } from '@/features/analytics/telemetry'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -36,11 +35,8 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
saveUsername(name)
onOpenChange?.(false) // only close on success
} catch (error) {
reportError(
'generic_failure',
new Error(
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper'
import { useSnapshot } from 'valtio'
import {
@@ -19,7 +20,6 @@ import {
saveNoiseReductionEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { captureEvent } from '@/features/analytics/telemetry'
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -128,7 +128,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
isSelected={noiseReductionEnabled}
onChange={(v) => {
saveNoiseReductionEnabled(v)
if (v) captureEvent('noise-reduction-init')
if (v) posthog.capture('noise-reduction-init')
}}
>
{t('audio.noiseReduction.label')}
+2 -5
View File
@@ -29,8 +29,6 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"level": "Mikrofon-Eingangspegel",
"muteTest": "Aktivieren Sie Ihr Mikrofon, um es zu testen",
"permissionsNeeded": "Mikrofon auswählen Berechtigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
@@ -40,9 +38,7 @@
},
"audiooutput": {
"choose": "Audioausgabe auswählen",
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich",
"test": "Lautsprecher testen",
"testing": "Testton wird abgespielt…"
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich"
}
},
"join": {
@@ -330,6 +326,7 @@
}
}
},
"faceLandmarks": {
"title": "Visuelle Effekte",
"glasses": {
+1 -5
View File
@@ -29,8 +29,6 @@
},
"audioinput": {
"choose": "Select microphone",
"level": "Microphone input level",
"muteTest": "Turn on your microphone to test it",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
@@ -40,9 +38,7 @@
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed",
"test": "Test speakers",
"testing": "Playing test sound…"
"permissionsNeeded": "Select speaker - permission needed"
}
},
"join": {
+1 -5
View File
@@ -29,8 +29,6 @@
},
"audioinput": {
"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",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
@@ -40,9 +38,7 @@
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires",
"test": "Tester les haut-parleurs",
"testing": "Lecture du son de test…"
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
}
},
"join": {
+1 -5
View File
@@ -29,8 +29,6 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"level": "Microfoon-ingangsniveau",
"muteTest": "Zet je microfoon aan om deze te testen",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
@@ -40,9 +38,7 @@
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist",
"test": "Luidsprekers testen",
"testing": "Testgeluid wordt afgespeeld…"
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
}
},
"join": {
+2 -6
View File
@@ -8,7 +8,6 @@ import { type StyledVariantProps } from '@/styled-system/types'
import { styled } from '@/styled-system/jsx'
import { FieldErrors } from './FieldErrors'
import { FieldDescription } from './FieldDescription'
import { reportError } from '@/features/analytics/telemetry'
// styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html
const StyledCheckbox = styled(RACCheckbox, {
@@ -113,11 +112,8 @@ export const Checkbox = ({
const descriptionId = useId()
if (isInvalid !== undefined) {
reportError(
'generic_failure',
new Error(
'Checkbox: passing isInvalid is not supported, use the validate prop instead'
)
console.error(
'Checkbox: passing isInvalid is not supported, use the validate prop instead'
)
return null
}
-3
View File
@@ -99,7 +99,6 @@ export type SelectProps<T> = Omit<
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
menuFooter?: ReactNode
}
export const Select = <T extends string | number>({
@@ -109,7 +108,6 @@ export const Select = <T extends string | number>({
errors,
placement,
variant = 'light',
menuFooter,
...props
}: SelectProps<T>) => {
const IconComponent = iconComponent
@@ -157,7 +155,6 @@ export const Select = <T extends string | number>({
</ListBoxItem>
))}
</ListBox>
{menuFooter}
</Box>
</StyledPopover>
{errors}
+4 -4
View File
@@ -1,7 +1,6 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
import { reportError } from '@/features/analytics/telemetry'
export type UiFont =
| 'default'
@@ -155,9 +154,10 @@ function getAccessibilityState(): AccessibilityState {
return DEFAULT_STATE
} catch (error: unknown) {
reportError('generic_failure', error, {
context: '[AccessibilityStore] Failed to parse stored settings:',
})
console.error(
'[AccessibilityStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
+4 -4
View File
@@ -3,7 +3,6 @@ import { proxyMap } from 'valtio/utils'
import { deserializeToProxyMap, serializeProxyMap } from '@/utils/valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
type State = {
soundNotifications: Map<NotificationType, boolean>
@@ -42,9 +41,10 @@ function getNotificationsState(): State {
),
}
} catch (error: unknown) {
reportError('generic_failure', error, {
context: '[NotificationsStore] Failed to parse stored settings:',
})
console.error(
'[NotificationsStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
+33 -102
View File
@@ -1,13 +1,22 @@
import { proxy } from 'valtio'
import { derive } from 'derive-valtio'
type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
type PermissionState =
| undefined
| 'granted'
| 'prompt'
| 'denied'
| 'unavailable'
type State = {
type BaseState = {
cameraPermission: PermissionState
microphonePermission: PermissionState
isLoading: boolean
isPermissionDialogOpen: boolean
requestOrigin?: 'audioinput' | 'videoinput'
}
type DerivedState = {
isCameraGranted: boolean
isMicrophoneGranted: boolean
isCameraDenied: boolean
@@ -16,31 +25,34 @@ type State = {
isMicrophonePrompted: boolean
}
export const permissionsStore = proxy<State>({
type State = BaseState & DerivedState
export const permissionsStore = proxy<BaseState>({
cameraPermission: undefined,
microphonePermission: undefined,
isLoading: true,
isPermissionDialogOpen: false,
requestOrigin: undefined,
get isCameraGranted() {
return this.cameraPermission === 'granted'
}) as State
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() {
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'
},
})
{
proxy: permissionsStore,
}
)
export const openPermissionsDialog = (
requestOrigin?: 'audioinput' | 'videoinput'
@@ -52,84 +64,3 @@ export const openPermissionsDialog = (
export const closePermissionsDialog = () => {
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,6 +1,5 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { reportError } from '@/features/analytics/telemetry'
type State = {
username: string
@@ -19,9 +18,10 @@ function getUserState(): State {
...parsed,
}
} catch (error: unknown) {
reportError('generic_failure', error, {
context: '[UserPreferencesStore] Failed to parse stored settings:',
})
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
+4 -4
View File
@@ -1,6 +1,5 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { reportError } from '@/features/analytics/telemetry'
type State = {
is_idle_disconnect_modal_enabled: boolean
@@ -22,9 +21,10 @@ function getUserPreferencesState(): State {
...parsed,
}
} catch (error: unknown) {
reportError('generic_failure', error, {
context: '[UserPreferencesStore] Failed to parse stored settings:',
})
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
+1 -2
View File
@@ -13,7 +13,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import clsx from 'clsx'
import { reportError } from '@/features/analytics/telemetry'
/**
* Calls all functions in the order they were chained with the same arguments.
@@ -26,7 +25,7 @@ export function chain(...callbacks: any[]): (...args: any[]) => void {
try {
callback(...args)
} catch (e) {
reportError('generic_failure', e)
console.error(e)
}
}
}
+14 -2
View File
@@ -1,8 +1,20 @@
import { isMobileBrowser } from '@livekit/components-core'
import { useState } from 'react'
import { useEffect, useState } from 'react'
export const useIsMobile = () => {
const [isMobile] = useState(() => isMobileBrowser())
const [isMobile, setIsMobile] = useState(isMobileBrowser())
useEffect(() => {
const handleResize = () => {
setIsMobile(isMobileBrowser())
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
return isMobile
}
-4
View File
@@ -1,8 +1,4 @@
/// <reference types="vite/client" />
// Version of @mediapipe/tasks-vision, injected at build time (vite.config.ts).
declare const __MEDIAPIPE_VERSION__: string
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
+1 -15
View File
@@ -1,27 +1,13 @@
import { readFileSync } from 'node:fs'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'
import svgr from 'vite-plugin-svgr'
import { viteStaticCopy } from 'vite-plugin-static-copy'
const mediapipeVersion: string = JSON.parse(
readFileSync(
new URL(
'./node_modules/@mediapipe/tasks-vision/package.json',
import.meta.url
),
'utf-8'
)
).version
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
define: {
__MEDIAPIPE_VERSION__: JSON.stringify(mediapipeVersion),
},
plugins: [
react(),
svgr({
@@ -37,7 +23,7 @@ export default defineConfig(({ mode }) => {
targets: [
{
src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
dest: `assets/mediapipe/wasm/${mediapipeVersion}`,
dest: 'assets/mediapipe/wasm',
rename: { stripBase: 4 },
},
{
@@ -73,11 +73,6 @@ data:
sub_filter_once off;
}
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.25.2",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.25.2",
"version": "1.24.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.25.2",
"version": "1.24.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.25.2",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.25.2",
"version": "1.24.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.25.2",
"version": "1.24.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.25.2"
version = "1.24.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",