(frontend) implement persistent notifications for waiting participants

Add special non-closing notifications for waiting participants that remain
visible until all have been reviewed. Implement complementary system alongside
existing toaster notifications.

Designed with accessibility in mind but would benefit from expert review.
Current UX provides good foundation with quick actions planned for v2.
This commit is contained in:
lebaudantoine
2025-02-23 00:13:18 +01:00
committed by aleb_the_flash
parent 92851b10cc
commit e535040ac6
7 changed files with 139 additions and 7 deletions

View File

@@ -26,6 +26,11 @@ const avatar = cva({
height: '100%',
},
},
notification: {
true: {
border: '2px solid white',
},
},
},
defaultVariants: {
context: 'list',
@@ -37,14 +42,22 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
bgColor?: string
} & RecipeVariantProps<typeof avatar>
export const Avatar = ({ name, bgColor, context, ...props }: AvatarProps) => {
export const Avatar = ({
name,
bgColor,
context,
notification,
style,
...props
}: AvatarProps) => {
const initial = name?.trim()?.charAt(0) || ''
return (
<div
style={{
backgroundColor: bgColor,
...style,
}}
className={avatar({ context })}
className={avatar({ context, notification })}
{...props}
>
{initial}

View File

@@ -1,13 +1,15 @@
import { useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { Div } from '@/primitives'
import { NotificationType } from './NotificationType'
import { NotificationDuration } from './NotificationDuration'
import { decodeNotificationDataReceived } from './utils'
import { Div } from '@/primitives'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import {
EMOJIS,
Reaction,
@@ -16,7 +18,6 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { useTranslation } from 'react-i18next'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -195,6 +196,7 @@ export const MainNotificationToast = () => {
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
<ReactionPortals reactions={reactions} />
</Div>
)

View File

@@ -22,7 +22,7 @@ export const StyledToastContainer = styled('div', {
},
})
const StyledToast = styled('div', {
export const StyledToast = styled('div', {
base: {
display: 'flex',
justifyContent: 'space-between',

View File

@@ -0,0 +1,102 @@
import { useListWaitingParticipants } from '@/features/rooms/api/listWaitingParticipants'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { StyledToastContainer } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { Button, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiInfinityLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
export const WaitingParticipantNotification = () => {
const data = useRoomData()
const { t } = useTranslation('notifications', {
keyPrefix: 'waitingParticipants',
})
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const { data: readOnlyData } = useListWaitingParticipants(data!.id, {
retry: false,
enabled: false,
})
const participants = readOnlyData?.participants || []
if (!participants.length) return
return (
<StyledToastContainer role="alert">
<HStack
padding={'1rem'}
gap={'1rem'}
role={'alertdialog'}
aria-label={participants.length > 1 ? t('several') : t('one')}
aria-modal={false}
>
<HStack gap={0}>
<Avatar
name={participants[0].username}
bgColor={participants[0].color}
context="list"
notification
/>
{participants.length > 1 && (
<Avatar
name={participants[1].username}
bgColor={participants[1].color}
context="list"
notification
style={{
marginLeft: '-10px',
}}
/>
)}
{participants.length > 2 && (
<span
className={css({
width: '32px',
height: '32px',
fontSize: '1rem',
color: 'white',
display: 'flex',
borderRadius: '50%',
justifyContent: 'center',
alignItems: 'center',
background: 'primaryDark.100',
border: '2px solid white',
marginLeft: '-10px',
})}
>
{participants.length < 102 ? (
<p>+{participants.length - 2}</p>
) : (
<RiInfinityLine size={20} />
)}
</span>
)}
</HStack>
<Text
variant="paragraph"
margin={false}
wrap={'balance'}
style={{
maxWidth: participants.length == 1 ? '10rem' : '15rem',
}}
>
{participants.length > 1 ? t('several') : t('one')}
</Text>
{!isParticipantsOpen && (
<Button
size="sm"
variant="text"
style={{
color: '#60a5fa',
}}
onPress={() => {
toggleParticipants()
}}
>
{t('open')}
</Button>
)}
</HStack>
</StyledToastContainer>
)
}

View File

@@ -15,5 +15,10 @@
},
"reaction": {
"description": ""
},
"waitingParticipants": {
"one": "",
"several": "",
"open": ""
}
}

View File

@@ -15,5 +15,10 @@
},
"reaction": {
"description": "{{name}} reacted with {{emoji}}"
},
"waitingParticipants": {
"one": "One person wants to join this call.",
"several": "Several people want to join this call.",
"open": "Open"
}
}

View File

@@ -15,5 +15,10 @@
},
"reaction": {
"description": "{{name}} a reagi avec {{emoji}}"
},
"waitingParticipants": {
"one": "Une personne souhaite participer à cet appel.",
"several": "Plusieurs personnes souhaitent participer à cet appel.",
"open": "Afficher"
}
}