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"
+ }
}