✨(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:
committed by
aleb_the_flash
parent
13bb6acdf6
commit
291544bd52
@@ -3,4 +3,5 @@ export enum NotificationType {
|
||||
HandRaised = 'handRaised',
|
||||
ParticipantMuted = 'participantMuted',
|
||||
MessageReceived = 'messageReceived',
|
||||
LowerHand = 'lowerHand',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
28
src/frontend/src/features/notifications/utils.ts
Normal file
28
src/frontend/src/features/notifications/utils.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
"cta": ""
|
||||
},
|
||||
"muted": "",
|
||||
"openChat": ""
|
||||
"openChat": "",
|
||||
"lowerHand": {
|
||||
"auto": "",
|
||||
"dismiss": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user