🐛(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.
This commit is contained in:
lebaudantoine
2026-08-21 21:26:47 +02:00
parent e204cd7f38
commit 35f2d3da86
6 changed files with 89 additions and 71 deletions
+1
View File
@@ -29,6 +29,7 @@ and this project adheres to
- 🐛(frontend) treat client-initiated connect aborts as events - 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container - 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates - 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
## [1.27.0] - 2026-08-14 ## [1.27.0] - 2026-08-14
@@ -1,11 +1,9 @@
import { Participant, Track } from 'livekit-client' import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react' import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react' 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 }) => { export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
source: Track.Source.Microphone, source: Track.Source.Microphone,
}) })
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<> <Button
<Button isDisabled={isMuted}
isDisabled={isMuted} size={'sm'}
size={'sm'} variant={'primaryTextDark'}
variant={'primaryTextDark'} square
square onPress={() => openMuteDialog(participant)}
onPress={() => setIsAlertOpen(true)} tooltip={t('muteParticipant', { name })}
tooltip={t('muteParticipant', { name })} >
> {!isMuted ? <RiMicLine /> : <RiMicOffLine />}
{!isMuted ? <RiMicLine /> : <RiMicOffLine />} </Button>
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -19,13 +19,11 @@ import {
import Source = Track.Source import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react' import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge' import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog' import { openMuteDialog } from '@/stores/muteDialog'
import { ParticipantName } from './ParticipantName' import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
const MicIndicator = ({ participant }: MicIndicatorProps) => { const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({ const { isMuted } = useTrackMutedIndicator({
participant: participant, participant: participant,
source: Source.Microphone, source: Source.Microphone,
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
const isSpeaking = useIsSpeaking(participant) const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
const label = isLocal(participant) const label = isLocal(participant)
@@ -52,46 +48,34 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
}) })
return ( return (
<> <Button
<Button square
square variant="greyscale"
variant="greyscale" size="sm"
size="sm" tooltip={label}
tooltip={label} aria-label={label}
aria-label={label} isDisabled={isMuted || !canMute}
isDisabled={isMuted || !canMute} onPress={async () =>
onPress={async () => !isMuted && isLocal(participant)
!isMuted && isLocal(participant) ? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
? await (participant as LocalParticipant)?.setMicrophoneEnabled( : openMuteDialog(participant)
false }
) data-attr="participants-mute"
: setIsAlertOpen(true) >
} {isMuted ? (
data-attr="participants-mute" <RiMicOffFill color={'gray'} aria-hidden={true} />
> ) : (
{isMuted ? ( <RiMicFill
<RiMicOffFill color={'gray'} aria-hidden={true} /> className={css({
) : ( color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
<RiMicFill animation: isSpeaking
className={css({ ? 'pulse_background 800ms infinite'
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50', : undefined,
animation: isSpeaking })}
? 'pulse_background 800ms infinite' aria-hidden={true}
: undefined, />
})} )}
aria-hidden={true} </Button>
/>
)}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -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)
}}
/>
)
}
@@ -19,6 +19,7 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription' import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals' import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea' import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
@@ -144,6 +145,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast /> <ConnectionStateToast />
<RecordingProvider /> <RecordingProvider />
<SettingsDialogProvider /> <SettingsDialogProvider />
<MuteAlertDialogProvider />
<ReactionPortals /> <ReactionPortals />
</div> </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
}