🐛(frontend) honour Keep hand raised when picture-in-picture is open

The hand button owns the lower-hand timer and the toast, and the
picture-in-picture window draws a second copy of that button, so two
offers go up and dismissing one leaves the other to lower the hand.
Move the watching into a component rendered once beside the room.
This commit is contained in:
davd-gzl
2026-09-09 06:55:52 +00:00
committed by aleb_the_flash
parent c0dfd88776
commit c075db25a4
4 changed files with 50 additions and 60 deletions
+1
View File
@@ -34,6 +34,7 @@ and this project adheres to
- 🐛(frontend) hide tooltips until they have a computed placement
- 🐛(brevo) use django-lasuite for marketing management
- ♿️(frontend) expose loading state to assistive technology
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
## [1.33.0] - 2026-09-30
@@ -0,0 +1,43 @@
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
const SPEAKING_DETECTION_DELAY = 3000
/**
* Offers to lower the local participant's raised hand after
* SPEAKING_DETECTION_DELAY of speaking. Mount it once: each copy runs its own
* timer and shows its own toast.
*/
export const LowerHandOnSpeaking = () => {
const room = useRoomContext()
const { isHandRaised, lowerHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const [hasOffered, setHasOffered] = useState(false)
useEffect(() => {
if (isHandRaised) return
setHasOffered(false)
closeLowerHandToasts()
}, [isHandRaised])
useEffect(() => {
if (!isSpeaking || !isHandRaised || hasOffered) return
const timer = setTimeout(() => {
setHasOffered(true)
showLowerHandToast(room.localParticipant, lowerHand)
}, SPEAKING_DETECTION_DELAY)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasOffered])
return null
}
@@ -2,19 +2,12 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({
@@ -25,64 +18,15 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
const resetToastState = () => {
setHasShownToast(false)
}
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: handleToggle,
handler: toggleRaisedHand,
})
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true)
const onClose = () => {
lowerHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
}, SPEAKING_DETECTION_DELAY)
}
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return (
@@ -100,7 +44,7 @@ export const HandToggle = ({
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={(e) => {
handleToggle()
toggleRaisedHand()
onPress?.(e)
}}
data-attr={`controls-hand-${tooltipLabel}`}
@@ -31,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LowerHandOnSpeaking } from '@/features/rooms/livekit/components/LowerHandOnSpeaking'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/**
@@ -119,6 +120,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionObserver />
<SyncDevicePreferences />
<RoomSilentMicDetector />
<LowerHandOnSpeaking />
<MediaStateObserver />
<ChatProvider />
<LobbyProvider />