(frontend) notify user when hands are raised

Using toast, first draft of a toast notification inspired by Gmeet.
Users can open the waiting list to see all the raised hands.
This commit is contained in:
lebaudantoine
2024-09-10 15:42:37 +02:00
committed by aleb_the_flash
parent e21858febe
commit a402c2f46f
9 changed files with 144 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { Participant, RoomEvent } from 'livekit-client' import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider' import { ToastProvider, toastQueue } from './components/ToastProvider'
import { NotificationType } from './NotificationType' import { NotificationType } from './NotificationType'
import { Div } from '@/primitives' import { Div } from '@/primitives'
@@ -27,6 +27,45 @@ export const MainNotificationToast = () => {
} }
}, [room]) }, [room])
// fixme - close all related toasters when hands are lowered remotely
useEffect(() => {
const decoder = new TextDecoder()
const handleNotificationReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
if (!participant) {
return
}
const notification = decoder.decode(payload)
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.Raised
)
if (existingToast && notification === NotificationType.Lowered) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && notification === NotificationType.Raised) {
toastQueue.add(
{
participant,
type: NotificationType.Raised,
},
{ timeout: 5000 }
)
}
}
room.on(RoomEvent.DataReceived, handleNotificationReceived)
return () => {
room.off(RoomEvent.DataReceived, handleNotificationReceived)
}
}, [room])
return ( return (
<Div position="absolute" bottom={20} right={5} zIndex={1000}> <Div position="absolute" bottom={20} right={5} zIndex={1000}>
<ToastProvider /> <ToastProvider />

View File

@@ -1,4 +1,6 @@
export enum NotificationType { export enum NotificationType {
Joined = 'joined', Joined = 'joined',
Default = 'default', Default = 'default',
Raised = 'raised',
Lowered = 'lowered',
} }

View File

@@ -0,0 +1,66 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { Button, Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RiCloseLine, RiHand } from '@remixicon/react'
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
export function ToastRaised({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref
)
const participant = props.toast.content.participant
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
<RiHand
color="white"
style={{
marginRight: '1rem',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
<Div {...titleProps} marginRight={0.5}>
{t('raised.description', {
name: participant.name || t('defaultName'),
})}
</Div>
{!isParticipantsOpen && (
<Button
size="sm"
variant="text"
style={{
color: '#60a5fa',
}}
onPress={(e) => {
toggleParticipants()
closeButtonProps.onPress?.(e)
}}
>
{t('raised.cta')}
</Button>
)}
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine size={18} color="white" />
</Button>
</HStack>
</StyledToastContainer>
)
}

View File

@@ -5,6 +5,7 @@ import { useRef } from 'react'
import { NotificationType } from '../NotificationType' import { NotificationType } from '../NotificationType'
import { ToastJoined } from './ToastJoined' import { ToastJoined } from './ToastJoined'
import { ToastData } from './ToastProvider' import { ToastData } from './ToastProvider'
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
interface ToastRegionProps extends AriaToastRegionProps { interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData> state: ToastState<ToastData>
@@ -19,6 +20,9 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
if (toast.content?.type === NotificationType.Joined) { if (toast.content?.type === NotificationType.Joined) {
return <ToastJoined key={toast.key} toast={toast} state={state} /> return <ToastJoined key={toast.key} toast={toast} state={state} />
} }
if (toast.content?.type === NotificationType.Raised) {
return <ToastRaised key={toast.key} toast={toast} state={state} />
}
return <Toast key={toast.key} toast={toast} state={state} /> return <Toast key={toast.key} toast={toast} state={state} />
})} })}
</div> </div>

View File

@@ -2,21 +2,33 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react' import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useLocalParticipant } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { NotificationType } from '@/features/notifications/NotificationType'
export const HandToggle = () => { export const HandToggle = () => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const localParticipant = useLocalParticipant().localParticipant const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({ const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: localParticipant, participant: room.localParticipant,
}) })
const label = isHandRaised const label = isHandRaised
? t('controls.hand.lower') ? t('controls.hand.lower')
: t('controls.hand.raise') : t('controls.hand.raise')
const notifyOtherParticipants = (isHandRaised: boolean) => {
room.localParticipant.publishData(
new TextEncoder().encode(
!isHandRaised ? NotificationType.Raised : NotificationType.Lowered
),
{
reliable: true,
}
)
}
return ( return (
<div <div
className={css({ className={css({
@@ -30,7 +42,10 @@ export const HandToggle = () => {
aria-label={label} aria-label={label}
tooltip={label} tooltip={label}
isSelected={isHandRaised} isSelected={isHandRaised}
onPress={() => toggleRaisedHand()} onPress={() => {
notifyOtherParticipants(isHandRaised)
toggleRaisedHand()
}}
> >
<RiHand /> <RiHand />
</ToggleButton> </ToggleButton>

View File

@@ -2,5 +2,9 @@
"defaultName": "", "defaultName": "",
"joined": { "joined": {
"description": "" "description": ""
},
"raised": {
"description": "",
"cta": ""
} }
} }

View File

@@ -2,5 +2,9 @@
"defaultName": "A contributor", "defaultName": "A contributor",
"joined": { "joined": {
"description": "{{name}} join the room" "description": "{{name}} join the room"
},
"raised": {
"description": "{{name}} raised its hand.",
"cta": "Open waiting list"
} }
} }

View File

@@ -2,5 +2,9 @@
"defaultName": "Un contributeur", "defaultName": "Un contributeur",
"joined": { "joined": {
"description": "{{name}} participe à l'appel" "description": "{{name}} participe à l'appel"
},
"raised": {
"description": "{{name}} a levé la main.",
"cta": "Ouvrir la file d'attente"
} }
} }

View File

@@ -77,6 +77,7 @@ export const buttonRecipe = cva({
color: 'primary', color: 'primary',
'&[data-hovered]': { '&[data-hovered]': {
background: 'gray.100 !important', background: 'gray.100 !important',
color: 'primary !important',
}, },
}, },
danger: { danger: {