Compare commits

...

4 Commits

Author SHA1 Message Date
lebaudantoine 1d3281da7f 🔥(frontend) drop unused vendored ConnectionObserver
The vendored ConnectionObserver collected connection data that never
turned out to be useful for debugging.

Remove it to reduce dead code, and re-add a targeted observer later
if a concrete debugging need shows up.
2026-08-13 10:26:55 +02:00
lebaudantoine 52f119db02 🐛(frontend) harden speaker test against missing sinks and play errors
- Only call `setSinkId` when supported and the device is actually
  enumerated: LiveKit can fall back to a stale id on browsers (e.g.
  WebKit) that expose no such device, making `setSinkId` throw
  `NotFoundError`.
- Await `audio.play()` and reset the playing state on failure, to
  avoid a stuck button and an unhandled rejection.
- Use an absolute `/sounds/uprise.mp3` URL so the asset resolves
  regardless of the current SPA route.
2026-08-13 10:25:16 +02:00
lebaudantoine 387ae17c22 🐛(frontend) handle 401 responses when syncing user preferences
401 responses were not handled by the user preferences sync, which
could leave the app in an inconsistent state when the session had
expired.

Handle the 401 case explicitly and report the error through the
telemetry module so it stays visible without crashing the flow.
2026-08-13 10:25:16 +02:00
lebaudantoine 1eb6f0b9e7 📈(frontend) downgrade unreachable external home URL from error to event
The "unreachable external home URL" check was reporting failures as
errors. In practice, it fired a lot for users behind corporate
networks that cannot reach our public landing page, which is
expected behavior and not something to investigate.

Capture it as a regular telemetry event instead of an error, so it
still gives us visibility on the frequency of the case without
polluting error dashboards.
2026-08-13 10:25:16 +02:00
11 changed files with 49 additions and 169 deletions
+10
View File
@@ -8,6 +8,16 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
## [1.26.0] - 2026-08-12 ## [1.26.0] - 2026-08-12
### Added ### Added
+23 -16
View File
@@ -3,27 +3,30 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react' import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
export const SoundTester = () => { export const SoundTester = () => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' }) const { devices, activeDeviceId } = useMediaDeviceSelect({
kind: 'audiooutput',
})
useEffect(() => { useEffect(() => {
const updateActiveId = async (deviceId: string) => { if (!canTestAudioOutput() || !activeDeviceId) return
try { if (!devices.some((device) => device.deviceId === activeDeviceId)) return
await audioRef?.current?.setSinkId(deviceId) audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
} catch (error) { if (error instanceof DOMException && error.name === 'NotFoundError') {
reportError( return
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
} }
} reportError(
updateActiveId(activeDeviceId) 'device_switch_failure',
}, [activeDeviceId]) new Error(`Error setting sinkId: ${error}`)
)
})
}, [devices, activeDeviceId])
// prevent pausing the sound // prevent pausing the sound
navigator.mediaSession.setActionHandler('pause', function () {}) navigator.mediaSession.setActionHandler('pause', function () {})
@@ -32,9 +35,13 @@ export const SoundTester = () => {
<> <>
<Button <Button
variant="secondaryText" variant="secondaryText"
onPress={() => { onPress={async () => {
audioRef?.current?.play() try {
setIsPlaying(true) await audioRef?.current?.play()
setIsPlaying(true)
} catch {
setIsPlaying(false)
}
}} }}
size="sm" size="sm"
isDisabled={isPlaying} isDisabled={isPlaying}
@@ -48,7 +55,7 @@ export const SoundTester = () => {
{/* eslint-disable jsx-a11y/media-has-caption */} {/* eslint-disable jsx-a11y/media-has-caption */}
<audio <audio
ref={audioRef} ref={audioRef}
src="sounds/uprise.mp3" src="/sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
/> />
</> </>
@@ -6,6 +6,8 @@ import { queryClient } from '@/api/queryClient'
import { updateUserPreferences } from './updateUserPreferences' import { updateUserPreferences } from './updateUserPreferences'
import { convertToBackendLanguage } from '@/utils/languages' import { convertToBackendLanguage } from '@/utils/languages'
import { useUser } from './useUser' import { useUser } from './useUser'
import { ApiError } from '@/api/ApiError.ts'
import { reportError } from '@/features/analytics/telemetry'
/** /**
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings. * Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
@@ -42,6 +44,11 @@ export const useSyncUserPreferencesWithBackend = () => {
} }
} }
syncBrowserPreferencesToBackend() syncBrowserPreferencesToBackend().catch((error) => {
if (error instanceof ApiError && error.statusCode === 401) return
reportError('generic_failure', error, {
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
})
})
}, [i18n.language, isLoggedIn, user, mutateAsync]) }, [i18n.language, isLoggedIn, user, mutateAsync])
} }
@@ -15,7 +15,7 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen' import { LoadingScreen } from '@/components/LoadingScreen'
import { reportError } from '@/features/analytics/telemetry' import { captureEvent } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => { const Columns = ({ children }: { children?: ReactNode }) => {
return ( return (
@@ -161,8 +161,10 @@ const Home = () => {
window.location.replace(data.external_home_url) window.location.replace(data.external_home_url)
} catch (error) { } catch (error) {
setRedirectFailed(true) setRedirectFailed(true)
reportError('generic_failure', error, { captureEvent('external-home-unreachable', {
context: 'Site is not reachable:', error_name: error instanceof Error ? error.name : 'Unknown',
error_message:
error instanceof Error ? error.message : String(error),
}) })
} }
} }
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
// fixme - handle dynamic audio output changes // fixme - handle dynamic audio output changes
export const useNotificationSound = () => { export const useNotificationSound = () => {
const notificationsSnap = useSnapshot(notificationsStore) const notificationsSnap = useSnapshot(notificationsStore)
const [play] = useSound('./sounds/notifications.mp3', { const [play] = useSound('/sounds/notifications.mp3', {
sprite: { sprite: {
participantJoined: [0, 1150], participantJoined: [0, 1150],
handRaised: [1400, 180], handRaised: [1400, 180],
@@ -30,7 +30,6 @@ import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference' import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils' import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
@@ -247,23 +246,8 @@ export const Conference = ({
onDisconnected={(e) => { onDisconnected={(e) => {
const metadata = { const metadata = {
room_id: roomId, room_id: roomId,
pc_publisher: connectionObserverStore.publisher && {
...connectionObserverStore.publisher,
},
pc_subscriber: connectionObserverStore.subscriber && {
...connectionObserverStore.subscriber,
},
pc_publisher_changes_count:
connectionObserverStore.publisherChangesCount,
pc_subscriber_changes_count:
connectionObserverStore.subscriberChangesCount,
} }
connectionObserverStore.publisher = null
connectionObserverStore.publisherChangesCount = 0
connectionObserverStore.subscriber = null
connectionObserverStore.subscriberChangesCount = 0
switch (e) { switch (e) {
case DisconnectReason.CLIENT_INITIATED: case DisconnectReason.CLIENT_INITIATED:
navigateTo( navigateTo(
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx' import { styled, VStack } from '@/styled-system/jsx'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled' import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { CandidateInfo } from '@/stores/connectionObserver'
import { captureEvent } from '@/features/analytics/telemetry' import { captureEvent } from '@/features/analytics/telemetry'
const Card = styled('div', { const Card = styled('div', {
@@ -240,10 +239,6 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
type RatingMetadata = { type RatingMetadata = {
room_id?: string room_id?: string
pc_publisher?: CandidateInfo
pc_subscriber?: CandidateInfo
pc_publisher_changes_count?: number
pc_subscriber_changes_count?: number
} }
export const Rating = ({ export const Rating = ({
@@ -9,14 +9,9 @@ import { useSnapshot } from 'valtio'
import { DisconnectReason, RoomEvent } from 'livekit-client' import { DisconnectReason, RoomEvent } from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences' import { userPreferencesStore } from '@/stores/userPreferences'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { isMobileBrowser } from '@livekit/components-core'
import { FeatureFlags } from '@/features/analytics/enums'
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry' import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
import { connectionObserverStore } from '@/stores/connectionObserver'
const CANDIDATE_POLL_INTERVAL_MS = 5000
export const ConnectionObserver = () => { export const ConnectionObserver = () => {
const room = useRoomContext() const room = useRoomContext()
@@ -24,13 +19,6 @@ export const ConnectionObserver = () => {
const { data } = useConfig() const { data } = useConfig()
const isAnalyticsEnabled = useIsAnalyticsEnabled() const isAnalyticsEnabled = useIsAnalyticsEnabled()
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
const isMobile = isMobileBrowser()
const isAdvancedConnectionObserverEnabled =
!isMobile && isAnalyticsEnabled && featureEnabled
const userPreferencesSnap = useSnapshot(userPreferencesStore) const userPreferencesSnap = useSnapshot(userPreferencesStore)
const idleDisconnectModalTimeoutRef = useRef<ReturnType< const idleDisconnectModalTimeoutRef = useRef<ReturnType<
@@ -80,100 +68,6 @@ export const ConnectionObserver = () => {
userPreferencesSnap.is_idle_disconnect_modal_enabled, userPreferencesSnap.is_idle_disconnect_modal_enabled,
]) ])
useEffect(() => {
if (!isAdvancedConnectionObserverEnabled) return
if (!room) return
let interval: ReturnType<typeof setInterval> | null = null
const pollCandidate = async (
label: 'publisher' | 'subscriber',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pc?: any
) => {
if (!pc) return
let stats: RTCStatsReport
try {
stats = await pc.getStats()
} catch {
return
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stats.forEach((report: any) => {
if (
report.type === 'candidate-pair' &&
report.state === 'succeeded' &&
report.nominated
) {
const remoteCandidate = stats.get(report.remoteCandidateId)
if (!remoteCandidate) return
const next = {
type: remoteCandidate.candidateType,
address: remoteCandidate.address,
protocol: remoteCandidate.protocol,
}
const current = connectionObserverStore[label]
const hasChanged =
current?.type !== next.type ||
current?.address !== next.address ||
current?.protocol !== next.protocol
if (hasChanged) {
connectionObserverStore[label] = next
const key = `${label}ChangesCount` as const
connectionObserverStore[key] =
(connectionObserverStore[key] || 0) + 1
}
}
})
}
const poll = async () => {
const publisher = room.engine?.pcManager?.publisher
const subscriber = room.engine?.pcManager?.subscriber
await Promise.all([
pollCandidate('publisher', publisher),
pollCandidate('subscriber', subscriber),
])
}
const startPolling = async () => {
if (interval) return // prevent duplicates
// Initial snapshot
await poll()
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
}
const stopPolling = () => {
if (!interval) return
clearInterval(interval)
interval = null
}
room.on(RoomEvent.Connected, startPolling)
room.on(RoomEvent.Reconnected, startPolling)
room.on(RoomEvent.Reconnecting, stopPolling)
room.on(RoomEvent.Disconnected, stopPolling)
return () => {
stopPolling()
room.off(RoomEvent.Connected, startPolling)
room.off(RoomEvent.Reconnected, startPolling)
room.off(RoomEvent.Reconnecting, stopPolling)
room.off(RoomEvent.Disconnected, stopPolling)
}
}, [room, isAdvancedConnectionObserverEnabled])
useEffect(() => { useEffect(() => {
if (!isAnalyticsEnabled) return if (!isAnalyticsEnabled) return
@@ -126,7 +126,7 @@ export const OutputSoundTester = ({
{/* eslint-disable-next-line jsx-a11y/media-has-caption */} {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio <audio
ref={audioRef} ref={audioRef}
src="sounds/uprise.mp3" src="/sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
/> />
</StyledContainer> </StyledContainer>
@@ -6,7 +6,6 @@ import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter' import { useLocation } from 'wouter'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client' import { DisconnectReason } from 'livekit-client'
import type { CandidateInfo } from '@/stores/connectionObserver'
// fixme - duplicated with home, refactor in a proper style // fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', { const Heading = styled('h1', {
@@ -48,10 +47,6 @@ const FeedbackRoute = () => {
const state = window.history.state const state = window.history.state
return { return {
room_id: state?.room_id as string, room_id: state?.room_id as string,
pc_publisher: state?.pc_publisher as CandidateInfo,
pc_publisher_changes_count: state?.pc_publisher_changes_count as number,
pc_subscriber: state?.pc_subscriber as CandidateInfo,
pc_subscriber_changes_count: state?.pc_subscriber_changes_count as number,
} }
}, []) }, [])
@@ -1,23 +1,9 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
export type CandidateInfo = {
type: string
address: string
protocol: string
}
type State = { type State = {
isIdleDisconnectModalOpen: boolean isIdleDisconnectModalOpen: boolean
publisher: CandidateInfo | null
publisherChangesCount: number
subscriber: CandidateInfo | null
subscriberChangesCount: number
} }
export const connectionObserverStore = proxy<State>({ export const connectionObserverStore = proxy<State>({
isIdleDisconnectModalOpen: false, isIdleDisconnectModalOpen: false,
publisher: null,
publisherChangesCount: 0,
subscriber: null,
subscriberChangesCount: 0,
}) })