From 291544bd52295f4d3802a829292bff6eab4cf529 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 14 Feb 2025 14:59:52 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20lower=20hand=20autonomous?= =?UTF-8?q?ly=20for=20active=20speakers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users requested that their raised hands be lowered automatically if they become the active speaker. This change ensures a more natural experience during discussions, preventing user from forgetting to lower their hand while speaking. --- .../notifications/NotificationType.ts | 1 + .../components/ToastLowerHand.tsx | 45 +++++++++++++++++++ .../notifications/components/ToastRegion.tsx | 4 ++ .../src/features/notifications/utils.ts | 28 ++++++++++++ .../components/controls/HandToggle.tsx | 45 ++++++++++++++++++- .../src/locales/de/notifications.json | 6 ++- .../src/locales/en/notifications.json | 6 ++- .../src/locales/fr/notifications.json | 6 ++- 8 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 src/frontend/src/features/notifications/components/ToastLowerHand.tsx create mode 100644 src/frontend/src/features/notifications/utils.ts diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index 8618fe49..cf3953e2 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -3,4 +3,5 @@ export enum NotificationType { HandRaised = 'handRaised', ParticipantMuted = 'participantMuted', MessageReceived = 'messageReceived', + LowerHand = 'lowerHand', } diff --git a/src/frontend/src/features/notifications/components/ToastLowerHand.tsx b/src/frontend/src/features/notifications/components/ToastLowerHand.tsx new file mode 100644 index 00000000..45505fbc --- /dev/null +++ b/src/frontend/src/features/notifications/components/ToastLowerHand.tsx @@ -0,0 +1,45 @@ +import { useToast } from '@react-aria/toast' +import { useRef } from 'react' + +import { StyledToastContainer, ToastProps } from './Toast' +import { HStack } from '@/styled-system/jsx' +import { useTranslation } from 'react-i18next' +import { Button } from '@/primitives' + +export function ToastLowerHand({ state, ...props }: ToastProps) { + const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' }) + const ref = useRef(null) + const { toastProps, contentProps } = useToast(props, state, ref) + const toast = props.toast + + const handleDismiss = () => { + // Clear onClose handler to prevent lowering the hand when user dismisses + toast.onClose = undefined + state.close(toast.key) + } + + return ( + + +

{t('auto')}

+ +
+
+ ) +} diff --git a/src/frontend/src/features/notifications/components/ToastRegion.tsx b/src/frontend/src/features/notifications/components/ToastRegion.tsx index 7151f223..8351ab36 100644 --- a/src/frontend/src/features/notifications/components/ToastRegion.tsx +++ b/src/frontend/src/features/notifications/components/ToastRegion.tsx @@ -8,6 +8,7 @@ import { ToastData } from './ToastProvider' import { ToastRaised } from './ToastRaised' import { ToastMuted } from './ToastMuted' import { ToastMessageReceived } from './ToastMessageReceived' +import { ToastLowerHand } from './ToastLowerHand' interface ToastRegionProps extends AriaToastRegionProps { state: ToastState @@ -33,6 +34,9 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) { ) } + if (toast.content?.type === NotificationType.LowerHand) { + return + } return })} diff --git a/src/frontend/src/features/notifications/utils.ts b/src/frontend/src/features/notifications/utils.ts new file mode 100644 index 00000000..58605f3d --- /dev/null +++ b/src/frontend/src/features/notifications/utils.ts @@ -0,0 +1,28 @@ +import { toastQueue } from './components/ToastProvider' +import { NotificationType } from './NotificationType' +import { NotificationDuration } from './NotificationDuration' +import { Participant } from 'livekit-client' + +export const showLowerHandToast = ( + participant: Participant, + onClose: () => void +) => { + toastQueue.add( + { + participant, + type: NotificationType.LowerHand, + }, + { + timeout: NotificationDuration.LOWER_HAND, + onClose, + } + ) +} + +export const closeLowerHandToasts = () => { + toastQueue.visibleToasts.forEach((toast) => { + if (toast.content.type === NotificationType.LowerHand) { + toastQueue.close(toast.key) + } + }) +} 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 1249518a..be7e58ae 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx @@ -4,6 +4,13 @@ import { ToggleButton } from '@/primitives' import { css } from '@/styled-system/css' 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' + +const SPEAKING_DETECTION_DELAY = 3000 export const HandToggle = () => { const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) @@ -13,6 +20,39 @@ export const HandToggle = () => { participant: room.localParticipant, }) + const isSpeaking = room.localParticipant.isSpeaking + const speakingTimerRef = useRef(null) + const [hasShownToast, setHasShownToast] = useState(false) + + const resetToastState = () => { + setHasShownToast(false) + } + + useEffect(() => { + if (isHandRaised) return + closeLowerHandToasts() + }, [isHandRaised]) + + useEffect(() => { + const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast + + if (shouldShowToast && !speakingTimerRef.current) { + speakingTimerRef.current = setTimeout(() => { + setHasShownToast(true) + const onClose = () => { + if (isHandRaised) toggleRaisedHand() + 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, toggleRaisedHand]) + const tooltipLabel = isHandRaised ? 'lower' : 'raise' return ( @@ -28,7 +68,10 @@ export const HandToggle = () => { aria-label={t(tooltipLabel)} tooltip={t(tooltipLabel)} isSelected={isHandRaised} - onPress={() => toggleRaisedHand()} + onPress={() => { + toggleRaisedHand() + resetToastState() + }} data-attr={`controls-hand-${tooltipLabel}`} > diff --git a/src/frontend/src/locales/de/notifications.json b/src/frontend/src/locales/de/notifications.json index a000aced..343c0d3c 100644 --- a/src/frontend/src/locales/de/notifications.json +++ b/src/frontend/src/locales/de/notifications.json @@ -8,5 +8,9 @@ "cta": "" }, "muted": "", - "openChat": "" + "openChat": "", + "lowerHand": { + "auto": "", + "dismiss": "" + } } diff --git a/src/frontend/src/locales/en/notifications.json b/src/frontend/src/locales/en/notifications.json index 07b0437f..2a34a463 100644 --- a/src/frontend/src/locales/en/notifications.json +++ b/src/frontend/src/locales/en/notifications.json @@ -8,5 +8,9 @@ "cta": "Open waiting list" }, "muted": "{{name}} has muted your microphone. No participant can hear you.", - "openChat": "Open chat" + "openChat": "Open chat", + "lowerHand": { + "auto": "It seems you have started speaking, so your hand will be lowered.", + "dismiss": "Keep hand raised" + } } diff --git a/src/frontend/src/locales/fr/notifications.json b/src/frontend/src/locales/fr/notifications.json index 40cbe068..1b03c732 100644 --- a/src/frontend/src/locales/fr/notifications.json +++ b/src/frontend/src/locales/fr/notifications.json @@ -8,5 +8,9 @@ "cta": "Ouvrir la file d'attente" }, "muted": "{{name}} a coupé votre micro. Aucun participant ne peut l'entendre.", - "openChat": "Ouvrir le chat" + "openChat": "Ouvrir le chat", + "lowerHand": { + "auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.", + "dismiss": "Laisser la main levée" + } }