(frontend) lower hand autonomously for active speakers

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.
This commit is contained in:
lebaudantoine
2025-02-14 14:59:52 +01:00
committed by aleb_the_flash
parent 13bb6acdf6
commit 291544bd52
8 changed files with 137 additions and 4 deletions

View File

@@ -3,4 +3,5 @@ export enum NotificationType {
HandRaised = 'handRaised',
ParticipantMuted = 'participantMuted',
MessageReceived = 'messageReceived',
LowerHand = 'lowerHand',
}

View File

@@ -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 (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
<p>{t('auto')}</p>
<Button
size="sm"
variant="text"
style={{
color: '#60a5fa',
marginLeft: '0.5rem',
}}
onPress={() => handleDismiss()}
>
{t('dismiss')}
</Button>
</HStack>
</StyledToastContainer>
)
}

View File

@@ -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<ToastData>
@@ -33,6 +34,9 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
<ToastMessageReceived key={toast.key} toast={toast} state={state} />
)
}
if (toast.content?.type === NotificationType.LowerHand) {
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
}
return <Toast key={toast.key} toast={toast} state={state} />
})}
</div>

View File

@@ -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)
}
})
}

View File

@@ -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<NodeJS.Timeout | null>(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}`}
>
<RiHand />

View File

@@ -8,5 +8,9 @@
"cta": ""
},
"muted": "",
"openChat": ""
"openChat": "",
"lowerHand": {
"auto": "",
"dismiss": ""
}
}

View File

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

View File

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