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
31 changed files with 216 additions and 209 deletions
-13
View File
@@ -8,24 +8,11 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
## [1.27.0] - 2026-08-14
### 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
- 🐛(frontend) stop reporting screen-share denials as errors
- 🐛(frontend) generalize screen-share error modal beyond macOS
- 📈(frontend) stop double-reporting media device failures
## [1.26.0] - 2026-08-12 ## [1.26.0] - 2026-08-12
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.27.0" version = "1.26.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.7", "livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.27.0" version = "1.26.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "livekit-agents" }, { name = "livekit-agents" },
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.27.0" version = "1.26.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.27.0" version = "1.26.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.27.0", "version": "1.26.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.27.0", "version": "1.26.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.27.0", "version": "1.26.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -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])
@@ -23,7 +23,6 @@ export type LogCode =
| 'livekit_room_error' | 'livekit_room_error'
| 'device_switch_failure' | 'device_switch_failure'
| 'permission_poll_failure' | 'permission_poll_failure'
| 'media_devices_error_event'
// non-media families // non-media families
| 'participant_mute_api_failure' | 'participant_mute_api_failure'
| 'permissions_api_failure' | 'permissions_api_failure'
@@ -138,7 +137,6 @@ export const captureMediaEvent = async (
| 'media-device-success' | 'media-device-success'
| 'device-not-found' | 'device-not-found'
| 'permissions-denied' | 'permissions-denied'
| 'screen-share-permission-denied'
| 'silent-mic-detected' | 'silent-mic-detected'
| 'silent-mic-analyser-unavailable' | 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered' | 'silent-mic-recovered'
@@ -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'
@@ -225,10 +226,9 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important', backgroundColor: 'primaryDark.50 !important',
})} })}
onError={(e) => { onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
reportError('livekit_room_error', e, { reportError('livekit_room_error', e, {
path: 'connect_publish', path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
}) })
}} }}
onConnected={async () => { onConnected={async () => {
@@ -247,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
@@ -1,13 +1,6 @@
import { A, Button, Dialog, P } from '@/primitives' import { A, Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { getOS, type OS } from '@/utils/os'
const SCREEN_CAPTURE_SETTINGS_LINKS: Partial<Record<OS, string>> = {
macos:
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
windows: 'ms-settings:privacy-graphicscaptureprogrammatic',
}
// todo - refactor it into a generic system // todo - refactor it into a generic system
export const ScreenShareErrorModal = ({ export const ScreenShareErrorModal = ({
@@ -18,8 +11,7 @@ export const ScreenShareErrorModal = ({
onClose: () => void onClose: () => void
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' }) const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
const os = getOS() const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
return ( return (
<Dialog <Dialog
@@ -34,16 +26,15 @@ export const ScreenShareErrorModal = ({
<> <>
<P> <P>
{t('message')}{' '} {t('message')}{' '}
{settingsHref && ( {isMac && (
<> <>
{t('settingsInstructions')}{' '} {t('macInstructions')}{' '}
<A <A
href={settingsHref} href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
target="_blank"
color="primary" color="primary"
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')} aria-label={t('macSystemPreferences') + '-' + t('newTab')}
> >
{t(`settingsLabel.${os}`)} {t('macSystemPreferences')}
</A> </A>
.{' '} .{' '}
</> </>
@@ -50,16 +50,15 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
useEffect(() => { useEffect(() => {
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => { const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
const failure = MediaDeviceFailure.getFailure(error) const failure = MediaDeviceFailure.getFailure(error)
if (!failure) return if (!failure || !kind) return
if (failure != MediaDeviceFailure.Other) {
void captureMediaEvent('media-device-error', { void captureMediaEvent('media-device-error', {
log_code: 'media_devices_error_event', log_code: 'media_devices_error_event',
path: 'connect_publish', path: 'connect_publish',
failure, failure,
kind: kind ?? 'unknown', kind,
}) })
}
if (!kind) return
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) { switch (failure) {
case MediaDeviceFailure.DeviceInUse: case MediaDeviceFailure.DeviceInUse:
@@ -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>
) )
} }
@@ -11,9 +11,7 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording' import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { ConnectionObserver } from '../components/ConnectionObserver' import { ConnectionObserver } from '../components/ConnectionObserver'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
import { isFireFox } from '@/utils/livekit'
import { MediaStateObserver } from '../components/MediaStateObserver' import { MediaStateObserver } from '../components/MediaStateObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer' import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
@@ -38,20 +36,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
SettingsComponent?: React.ComponentType SettingsComponent?: React.ComponentType
} }
const getScreenSharePermissionDeniedScope = (
error: Error
): 'system' | 'user' | 'browser' | null => {
if (error.name === 'NotAllowedError') {
if (/by system/i.test(error.message)) return 'system'
if (/by user/i.test(error.message)) return 'user'
return 'browser'
}
if (error.name === 'NotFoundError' && isFireFox() && getOS() === 'macos') {
return 'system'
}
return null
}
/** /**
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application. * The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers * It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
@@ -77,34 +61,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false) const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
const handleDeviceError = ({
source,
error,
}: {
source: Track.Source
error: Error
}) => {
if (source === Track.Source.ScreenShare) {
const scope = getScreenSharePermissionDeniedScope(error)
if (scope) {
if (scope === 'system') setIsShareErrorVisible(true)
void captureMediaEvent('screen-share-permission-denied', {
at: 'ControlBar.onDeviceError',
source,
error_name: error.name,
error_message: error.message,
denied_scope: scope,
os: getOS(),
})
return
}
}
reportError('device_switch_failure', error, {
at: 'ControlBar.onDeviceError',
source,
})
}
return ( return (
<> <>
<RoomMetadataSynchronizer /> <RoomMetadataSynchronizer />
@@ -136,7 +92,21 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<StageLayout /> <StageLayout />
)} )}
</RoomContentArea> </RoomContentArea>
<ControlBar onDeviceError={handleDeviceError} /> <ControlBar
onDeviceError={(e) => {
reportError('device_switch_failure', e.error, {
at: 'ControlBar.onDeviceError',
source: e.source,
})
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel /> <SidePanel />
</> </>
)} )}
@@ -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,
} }
}, []) }, [])
+2 -5
View File
@@ -214,11 +214,8 @@
"title": "Bildschirmfreigabe nicht möglich", "title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich", "ariaLabel": "Bildschirmfreigabe nicht möglich",
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.", "message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
"settingsInstructions": "Gehe zu den", "macInstructions": "Gehe zu den",
"settingsLabel": { "macSystemPreferences": "Systemeinstellungen",
"macos": "Systemeinstellungen",
"windows": "Windows-Datenschutzeinstellungen"
},
"helpLinkText": "Weitere Informationen findest du unter", "helpLinkText": "Weitere Informationen findest du unter",
"helpLinkLabel": "Bildschirmfreigabeproblem", "helpLinkLabel": "Bildschirmfreigabeproblem",
"closeButton": "Schließen", "closeButton": "Schließen",
+2 -5
View File
@@ -214,11 +214,8 @@
"title": "Unable to share your screen", "title": "Unable to share your screen",
"ariaLabel": "Unable to share your screen", "ariaLabel": "Unable to share your screen",
"message": "Your browser may not be allowed to record the screen on your computer.", "message": "Your browser may not be allowed to record the screen on your computer.",
"settingsInstructions": "Go to your", "macInstructions": "Go to your",
"settingsLabel": { "macSystemPreferences": "System Preferences",
"macos": "System Preferences",
"windows": "Windows privacy settings"
},
"helpLinkText": "To learn more, see", "helpLinkText": "To learn more, see",
"helpLinkLabel": "Presentation issue", "helpLinkLabel": "Presentation issue",
"closeButton": "Dismiss", "closeButton": "Dismiss",
+2 -5
View File
@@ -214,11 +214,8 @@
"title": "Impossible de partager votre écran", "title": "Impossible de partager votre écran",
"ariaLabel": "Impossible de partager votre écran", "ariaLabel": "Impossible de partager votre écran",
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.", "message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
"settingsInstructions": "Accédez à vos", "macInstructions": "Accèdez à vos",
"settingsLabel": { "macSystemPreferences": "Préférences système",
"macos": "Préférences système",
"windows": "paramètres de confidentialité Windows"
},
"helpLinkText": "Pour en savoir plus, consulter", "helpLinkText": "Pour en savoir plus, consulter",
"helpLinkLabel": "Problème de présentation", "helpLinkLabel": "Problème de présentation",
"closeButton": "Ignorer", "closeButton": "Ignorer",
+2 -5
View File
@@ -214,11 +214,8 @@
"title": "Kan uw scherm niet delen", "title": "Kan uw scherm niet delen",
"ariaLabel": "Kan uw scherm niet delen", "ariaLabel": "Kan uw scherm niet delen",
"message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.", "message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.",
"settingsInstructions": "Ga naar uw", "macInstructions": "Ga naar uw",
"settingsLabel": { "macSystemPreferences": "Systeemvoorkeuren",
"macos": "Systeemvoorkeuren",
"windows": "Windows-privacyinstellingen"
},
"helpLinkText": "Meer informatie, zie", "helpLinkText": "Meer informatie, zie",
"helpLinkLabel": "Presentatieprobleem", "helpLinkLabel": "Presentatieprobleem",
"closeButton": "Negeren", "closeButton": "Negeren",
@@ -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,
}) })
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.27.0", "version": "1.26.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.27.0", "version": "1.26.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.0", "@html-to/text-cli": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.27.0", "version": "1.26.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.27.0", "version": "1.26.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.27.0", "version": "1.26.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.27.0", "version": "1.26.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.27.0" version = "1.26.0"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",