From c075db25a455335480c110c6ce5bb09046b01304 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:55:52 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20honour=20Keep=20hand?= =?UTF-8?q?=20raised=20when=20picture-in-picture=20is=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + .../components/LowerHandOnSpeaking.tsx | 43 +++++++++++++ .../components/controls/HandToggle.tsx | 64 ++----------------- .../rooms/livekit/prefabs/VideoConference.tsx | 2 + 4 files changed, 50 insertions(+), 60 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/LowerHandOnSpeaking.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d24a9218b..1b383131b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/src/features/rooms/livekit/components/LowerHandOnSpeaking.tsx b/src/frontend/src/features/rooms/livekit/components/LowerHandOnSpeaking.tsx new file mode 100644 index 000000000..2c5ebaa22 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/LowerHandOnSpeaking.tsx @@ -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 +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx index 902a89f98..782229beb 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx @@ -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, '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 | 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}`} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 41a8fe960..952db5985 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -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) { +