Compare commits

..

3 Commits

Author SHA1 Message Date
lebaudantoine 2c1a7e1a53 🐛(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:17:46 +02:00
lebaudantoine fda2508a6e 🐛(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-12 18:38:01 +02:00
lebaudantoine f10bb0d431 📈(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-12 18:32:02 +02:00
12 changed files with 162 additions and 95 deletions
-8
View File
@@ -8,19 +8,11 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
### Fixed ### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event - 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences - 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors - 🐛(frontend) harden speaker test against missing sinks and play errors
- 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
## [1.26.0] - 2026-08-12 ## [1.26.0] - 2026-08-12
@@ -1,24 +0,0 @@
import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
]
const shouldIgnoreException = (value: unknown): boolean =>
typeof value === 'string' &&
IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
export const filterExceptions = (
event: CaptureResult | null
): CaptureResult | null => {
if (event?.event !== '$exception') return event
const exceptionList = event.properties?.['$exception_list']
const values: unknown[] = Array.isArray(exceptionList)
? exceptionList.map((exception) => exception?.value)
: []
values.push(event.properties?.['$exception_message'])
return values.some(shouldIgnoreException) ? null : event
}
@@ -2,7 +2,6 @@ import { useEffect } from 'react'
import { type ApiUser } from '@/features/auth/api/ApiUser' import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser' import { useUser } from '@/features/auth/api/useUser'
import { getPosthog } from '../utils' import { getPosthog } from '../utils'
import { filterExceptions } from '../exceptionFilters'
export const startAnalyticsSession = (data: ApiUser) => { export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => { getPosthog().then((ph) => {
@@ -48,7 +47,6 @@ export const useAnalytics = ({
capture_unhandled_rejections: true, capture_unhandled_rejections: true,
capture_console_errors: true, capture_console_errors: true,
}, },
before_send: filterExceptions,
}) })
}) })
}, [id, host, flags_api_host, isDisabled]) }, [id, host, flags_api_host, isDisabled])
@@ -1,6 +1,6 @@
import { ChatRow } from '@/stores/chat' import { ChatRow } from '@/stores/chat'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { formatChatMessageLinks } from '../utils' import { formatChatMessageLinks } from '@livekit/components-react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Text } from '@/primitives' import { Text } from '@/primitives'
-32
View File
@@ -1,32 +0,0 @@
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
import { ReactNode } from 'react'
const defaultGrammar = Object.freeze(createDefaultGrammar())
export function formatChatMessageLinks(message: string): ReactNode {
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
if (typeof tok === `string`) {
return tok
} else {
const content = tok.content.toString()
const href =
tok.type === `url`
? /^http(s?):\/\//.test(content)
? content
: `https://${content}`
: `mailto:${content}`
return (
<a
className="lk-chat-link"
key={i}
href={href}
target="_blank"
rel="noreferrer"
>
{content}
</a>
)
}
})
}
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
backgroundColor: 'primaryDark.100', backgroundColor: 'primaryDark.100',
maxWidth: '100%', maxWidth: '100%',
opacity: 0, opacity: 0,
translate: '0 3.25rem', transform: 'translateY(3.25rem)',
transition: 'opacity, translate', transition: 'opacity, transform',
transitionDuration: '0.5s', transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none', pointerEvents: 'none',
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
isVisible: { isVisible: {
true: { true: {
opacity: 1, opacity: 1,
translate: '0 0', transform: 'translateY(0)',
pointerEvents: 'auto', pointerEvents: 'auto',
}, },
}, },
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
shouldBeCenteredWithToggleButton, shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton, setShouldBeCenteredWithToggleButton,
] = useState(false) ] = useState(false)
const [offsetX, setOffsetX] = useState(0) const [rightOffset, setRightOffset] = useState(0)
const updateArrows = useCallback(() => { const updateArrows = useCallback(() => {
const el = scrollRef.current const el = scrollRef.current
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) { if (!shouldBeCenteredWithToggleButton || isMobile) {
setOffsetX(0) setRightOffset(0)
return return
} }
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
const containerCenterX = containerRect.left + containerRect.width / 2 const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return if (Math.abs(shift) < 0.5) return
setOffsetX((prev) => prev + shift) setRightOffset((prev) => prev - shift * 2)
} }
const schedule = () => { const schedule = () => {
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
isVisible={isVisible} isVisible={isVisible}
style={ style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
? { transform: `translateX(${offsetX}px)` } ? { marginRight: `${rightOffset}px` }
: { margin: '0 15px' } : { margin: '0 15px' }
} }
> >
@@ -30,6 +30,7 @@ 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'
@@ -246,8 +247,23 @@ 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,6 +5,7 @@ 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', {
@@ -239,6 +240,10 @@ 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,16 +9,28 @@ 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 { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
import { connectionObserverStore } from '@/stores/connectionObserver' 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'
const CANDIDATE_POLL_INTERVAL_MS = 5000
export const ConnectionObserver = () => { export const ConnectionObserver = () => {
const room = useRoomContext() const room = useRoomContext()
const connectionStartTimeRef = useRef<number | null>(null) const connectionStartTimeRef = useRef<number | null>(null)
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<
@@ -68,6 +80,100 @@ 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
@@ -12,8 +12,7 @@ import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const CONTROL_BAR_BREAKPOINT_WIDE = 1100 const CONTROL_BAR_BREAKPOINT = 1100
const CONTROL_BAR_BREAKPOINT_NARROW = 1050
const NavigationControls = ({ const NavigationControls = ({
onPress, onPress,
@@ -66,9 +65,10 @@ export const LateralMenu = () => {
</DialogTrigger> </DialogTrigger>
) )
} }
interface BreakpointObserverProps { interface BreakpointObserverProps {
containerRef: RefObject<HTMLDivElement> containerRef: RefObject<HTMLDivElement>
onWideChange: (isWide: boolean | null) => void onWideChange: (isWide: boolean) => void
} }
const BreakpointObserver = ({ const BreakpointObserver = ({
@@ -76,20 +76,7 @@ const BreakpointObserver = ({
onWideChange, onWideChange,
}: BreakpointObserverProps) => { }: BreakpointObserverProps) => {
const { width } = useSize(containerRef) const { width } = useSize(containerRef)
const [isWide, setIsWide] = useState<boolean | null>(null) const isWide = width > CONTROL_BAR_BREAKPOINT
useEffect(() => {
if (!width) {
return
}
if (width > CONTROL_BAR_BREAKPOINT_WIDE) {
setIsWide(true)
} else if (width <= CONTROL_BAR_BREAKPOINT_NARROW) {
setIsWide(false)
} else {
setIsWide((prev) => (prev === null ? false : prev))
}
}, [width])
useEffect(() => { useEffect(() => {
onWideChange(isWide) onWideChange(isWide)
@@ -103,7 +90,7 @@ export const MoreOptions = ({
}: { }: {
parentElement: RefObject<HTMLDivElement> parentElement: RefObject<HTMLDivElement>
}) => { }) => {
const [isWide, setIsWide] = useState<boolean | null>(null) const [isWide, setIsWide] = useState(false)
return ( return (
<nav <nav
@@ -120,7 +107,7 @@ export const MoreOptions = ({
containerRef={parentElement} containerRef={parentElement}
onWideChange={setIsWide} onWideChange={setIsWide}
/> />
{isWide !== null && (isWide ? <NavigationControls /> : <LateralMenu />)} {isWide ? <NavigationControls /> : <LateralMenu />}
</nav> </nav>
) )
} }
@@ -6,6 +6,7 @@ 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', {
@@ -47,6 +48,10 @@ 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,9 +1,23 @@
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,
}) })