Compare commits

...

7 Commits

Author SHA1 Message Date
lebaudantoine a9c9a36ac7 🐛(frontend) fix joined notification tile no longer rendering properly
The toast is enqueued on `ParticipantConnected`, which livekit-client
fires before the participant's track publications exist. At that
point `getTrackPublication(Camera)` is `undefined`, and the track
ref used by the toast is frozen that way — the toast never
re-subscribes to the participant.

Downstream effects:

* LiveKit's `useIsMuted` initializes to `false` when there is no
  publication, so the tile first paints with
  `data-lk-video-muted="false"`, which the stock CSS maps to
  placeholder `opacity: 0` — a blank box.
* Its effect then subscribes to `mutedObserver`, which defaults to
  `true` for the same situation, triggering a second render and
  only then starting the 200 ms fade-in. That render-effect-render
  transition is the visible lag behind the text.
* Because the track ref never updates, no `VideoTrack` ever mounts.
  When the camera is later subscribed, the placeholder fades out
  again, leaving the tile empty.

Re-derive the track ref inside the toast when the participant's
publications become available, so `useIsMuted` sees the real state
from the start and the tile renders the video (or a stable
placeholder) instead of a blank box.
2026-08-21 22:12:23 +02:00
lebaudantoine 35f2d3da86 🐛(frontend) hoist mute confirmation dialog to VideoConference level
`MuteButton` owned the confirmation dialog state locally. On the
participant tile, the button lives inside `FadeOverlay`, which
conditionally mounts its children based on tile mouse activity
(3 s idle timeout). Once the user moved the pointer onto the
react-aria portal, mouse events on the tile stopped, the idle timer
expired, `FadeOverlay` unmounted `MuteButton`, and the dialog was
destroyed with it — hence the "closes randomly" behavior.

Render the dialog once at the VideoConference level via a shared
`MuteAlertDialogProvider`, so its lifetime is independent from any
button that opens it.
2026-08-21 21:26:47 +02:00
lebaudantoine e204cd7f38 💄(frontend) increase the blur intensity
Bump the blur strength applied to virtual backgrounds so it obscures
the background more effectively.

Requested by users.
2026-08-21 19:48:01 +02:00
lebaudantoine 8d28343e80 🐛(frontend) stop init_virtual_background from firing on blur updates
A regression introduced with the custom virtual background feature
(16daf7b8, March 26): every blur init on Firefox, and every update
call, was going through `init_virtual_background`, which raised an
error because there is no virtual background to initialize in that
code path.

The existing guard was incorrect. Replace it with an early return,
so `init_virtual_background` is only executed when there is actually
a virtual background to initialize.

This does not fix the other virtual background issues that currently
prevent some users from activating their camera; those will be
addressed separately.
2026-08-21 19:14:07 +02:00
lebaudantoine e1eb30d798 📈(frontend) track errors when starting or stopping a recording
Send an analytics event whenever starting or stopping a recording
fails, so we can monitor how often it happens.

It also gives support the context needed to understand what went
wrong when inspecting a user's session.
2026-08-21 19:14:07 +02:00
lebaudantoine 1bd2325ad2 🐛(frontend) use state instead of a ref for MoreControls container
After the recent refactoring, `MoreControls` only renders once. On
that first render, the container ref is `null`, and when it later
gets a reference to the div, the ref update does not trigger a
re-render.

As a result, the additional controls were never showing up.

Switch from a ref to a state to track the container element. State
updates do trigger a re-render, so the controls now show as
expected once the container is available.
2026-08-21 19:14:07 +02:00
lebaudantoine ebaf24c795 🐛(frontend) treat client-initiated connect aborts as events
`connect()` calls can be aborted by a `disconnect()` before the
join completes — typically when the user leaves, refreshes,
navigates away early, or when the component remounts. This is
expected behavior, not a failure.

Stop reporting these as errors. Track them as analytics events
instead, so we can still monitor their volume without polluting
error dashboards.

Volume is relatively low, but keeping visibility helps troubleshoot
users who complain about difficulty connecting to a room, and could
also flag a component that is re-rendering and killing the
connection.
2026-08-21 19:14:04 +02:00
12 changed files with 143 additions and 87 deletions
+10
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
### Added
- 📈(frontend) track errors when starting or stopping a recording
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
@@ -15,12 +19,18 @@ and this project adheres to
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
## [1.27.0] - 2026-08-14
@@ -1,5 +1,5 @@
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { useMemo, useRef } from 'react'
import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
import Source = Track.Source
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout'
import { useParticipantTracks } from '@livekit/components-react'
const ClickableToast = styled(RACButton, {
base: {
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
)
const participant = props.toast.content.participant
if (!participant) return
const [cameraTrack] = useParticipantTracks(
[Source.Camera],
participant?.identity
)
const trackReference = {
participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
const trackReference = useMemo(
() => cameraTrack ?? { participant, source: Source.Camera },
[cameraTrack, participant]
)
if (!participant) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
@@ -1,11 +1,9 @@
import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { openMuteDialog } from '@/stores/muteDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
source: Track.Source.Microphone,
})
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => openMuteDialog(participant)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
)
}
@@ -19,13 +19,11 @@ import {
import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { openMuteDialog } from '@/stores/muteDialog'
import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = {
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Source.Microphone,
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const canMute = useCanMute(participant)
const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
const label = isLocal(participant)
@@ -52,46 +48,34 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
})
return (
<>
<Button
square
variant="greyscale"
size="sm"
tooltip={label}
aria-label={label}
isDisabled={isMuted || !canMute}
onPress={async () =>
!isMuted && isLocal(participant)
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
false
)
: setIsAlertOpen(true)
}
data-attr="participants-mute"
>
{isMuted ? (
<RiMicOffFill color={'gray'} aria-hidden={true} />
) : (
<RiMicFill
className={css({
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
animation: isSpeaking
? 'pulse_background 800ms infinite'
: undefined,
})}
aria-hidden={true}
/>
)}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
<Button
square
variant="greyscale"
size="sm"
tooltip={label}
aria-label={label}
isDisabled={isMuted || !canMute}
onPress={async () =>
!isMuted && isLocal(participant)
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
: openMuteDialog(participant)
}
data-attr="participants-mute"
>
{isMuted ? (
<RiMicOffFill color={'gray'} aria-hidden={true} />
) : (
<RiMicFill
className={css({
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
animation: isSpeaking
? 'pulse_background 800ms infinite'
: undefined,
})}
aria-hidden={true}
/>
)}
</Button>
)
}
@@ -1,17 +1,20 @@
import { useStartRecording, useStopRecording } from '@/features/recording'
import { recordingStore } from '@/stores/recording'
import { captureEvent } from '@/features/analytics/telemetry'
export const useMutateRecording = () => {
const { mutateAsync: startRecording, isPending: isPendingToStart } =
useStartRecording({
onError: () => {
recordingStore.isErrorDialogOpen = 'start'
captureEvent('error-starting-recording')
},
})
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
useStopRecording({
onError: () => {
recordingStore.isErrorDialogOpen = 'stop'
captureEvent('error-stopping-recording')
},
})
@@ -6,6 +6,8 @@ import {
usePersistentUserChoices,
} from '@livekit/components-react'
import {
ConnectionError,
ConnectionErrorReason,
DisconnectReason,
MediaDeviceFailure,
Room,
@@ -25,7 +27,11 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import {
captureEvent,
captureMediaEvent,
reportError,
} from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
@@ -227,6 +233,16 @@ export const Conference = ({
onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, {
path: 'connect_publish',
})
@@ -0,0 +1,28 @@
import { useRef } from 'react'
import { useSnapshot } from 'valtio'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
import { MuteAlertDialog } from './MuteAlertDialog'
export const MuteAlertDialogProvider = () => {
const { participant } = useSnapshot(muteDialogStore)
const { muteParticipant } = useMuteParticipant()
const lastNameRef = useRef('')
if (participant) {
lastNameRef.current = participant.name || participant.identity
}
return (
<MuteAlertDialog
isOpen={!!participant}
name={lastNameRef.current}
onClose={closeMuteDialog}
onSubmit={() => {
const target = muteDialogStore.participant
if (!target) return
muteParticipant(target).then(closeMuteDialog)
}}
/>
)
}
@@ -105,9 +105,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
_initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') {
throw new Error(
'Virtual background is only supported for virtual background'
)
return
}
const needsUpdate =
@@ -37,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius {
NONE = 0,
LIGHT = 5,
NORMAL = 10,
LIGHT = 10,
NORMAL = 20,
}
const isSupported = BackgroundProcessorFactory.isSupported()
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { RefObject, useMemo, useState } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
@@ -21,7 +21,14 @@ export function DesktopControlBar({
onDeviceError,
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const [controlBarElement, setControlBarElement] =
useState<HTMLDivElement | null>(null)
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
() => ({ current: controlBarElement }),
[controlBarElement]
)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
@@ -45,7 +52,7 @@ export function DesktopControlBar({
return (
<div
ref={desktopControlBarEl}
ref={setControlBarElement}
className={css({
width: '100vw',
display: 'flex',
@@ -19,6 +19,7 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
@@ -144,6 +145,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast />
<RecordingProvider />
<SettingsDialogProvider />
<MuteAlertDialogProvider />
<ReactionPortals />
</div>
</>
+18
View File
@@ -0,0 +1,18 @@
import { proxy, ref } from 'valtio'
import type { Participant } from 'livekit-client'
type State = {
participant: Participant | null
}
export const muteDialogStore = proxy<State>({
participant: null,
})
export const openMuteDialog = (participant: Participant) => {
muteDialogStore.participant = ref(participant)
}
export const closeMuteDialog = () => {
muteDialogStore.participant = null
}