Files
meet/src/frontend/src/features/notifications/MainNotificationToast.tsx
T
lebaudantoine b7210e62bb (frontend) notify user when their meeting role changes
Show a notification to the user whenever their role in the meeting
changes, so they immediately see when they have been promoted or
demoted.
2026-08-04 19:04:12 +02:00

273 lines
8.4 KiB
TypeScript

import { useCallback, useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { type ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { NotificationType } from './NotificationType'
import { NotificationDuration } from './NotificationDuration'
import { decodeNotificationDataReceived } from './utils'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { toastQueue } from './components/ToastProvider'
import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
import { NotificationProvider } from './NotificationProvider'
import { useConfig } from '@/api/useConfig'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { data } = useConfig()
const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce()
const { appendReaction } = useReactions()
useEffect(() => {
const handleChatMessage = (
chatMessage: ChatMessage,
participant?: Participant | undefined
) => {
if (!participant || participant.isLocal) return
triggerNotificationSound(NotificationType.MessageReceived)
toastQueue.add(
{
participant: participant,
message: chatMessage.message,
type: NotificationType.MessageReceived,
},
{ timeout: NotificationDuration.MESSAGE }
)
if (layoutStore.activePanelId !== PanelId.CHAT) {
announce(
t('chatMessageReceived', {
name: participant.name || t('defaultName'),
message: chatMessage.message,
}),
'polite'
)
}
}
room.on(RoomEvent.ChatMessage, handleChatMessage)
return () => {
room.off(RoomEvent.ChatMessage, handleChatMessage)
}
}, [room, triggerNotificationSound, announce, t])
const handleEmoji = useCallback(
(emoji: string, participant: Participant) => {
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
appendReaction(emoji as Emoji, participant)
},
[appendReaction]
)
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const notification = decodeNotificationDataReceived(payload)
if (!notification) return
switch (notification.type) {
case NotificationType.ParticipantMuted:
if (participant) {
toastQueue.add(
{
participant,
type: NotificationType.ParticipantMuted,
},
{ timeout: NotificationDuration.ALERT }
)
}
break
case NotificationType.ReactionReceived:
if (notification.data?.emoji && participant)
handleEmoji(notification.data.emoji, participant)
break
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingLimitReached:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.TranscriptionRequested:
case NotificationType.ScreenRecordingRequested:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.RECORDING_REQUESTED }
)
break
case NotificationType.PermissionsRemoved: {
const removedSources = notification?.data?.removedSources
if (!removedSources?.length) break
toastQueue.add(
{
participant,
type: notification.type,
removedSources: removedSources,
},
{ timeout: NotificationDuration.ALERT }
)
break
}
default:
return
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [room, handleEmoji])
const triggerNotificationSoundIfRoomIsSmall = useCallback(
(type: NotificationType) => {
if (!data) return
if (room.numParticipants >= data.max_participants_for_sound) return
triggerNotificationSound(type)
},
[room, data, triggerNotificationSound]
)
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
if (isMobileBrowser()) {
return
}
triggerNotificationSoundIfRoomIsSmall(NotificationType.ParticipantJoined)
toastQueue.add(
{
participant,
type: NotificationType.ParticipantJoined,
},
{
timeout: NotificationDuration.PARTICIPANT_JOINED,
}
)
}
room.on(RoomEvent.ParticipantConnected, showJoinNotification)
return () => {
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
}
}, [room, triggerNotificationSoundIfRoomIsSmall])
useEffect(() => {
const handleAttributeChanged = (
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant.isLocal || !('room_role' in changedAttributes)) return
const newRole = changedAttributes['room_role']
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: newRole,
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
return () => {
room.off(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
}
}, [room])
useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => {
if (toast.content.participant === participant) {
toastQueue.close(toast.key)
}
})
}
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
return () => {
room.off(
RoomEvent.ParticipantDisconnected,
removeParticipantNotifications
)
}
}, [room])
useEffect(() => {
const handleNotificationReceived = (
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant) return
if (isMobileBrowser()) return
if (participant.isLocal) return
if (!('handRaisedAt' in changedAttributes)) return
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.HandRaised
)
if (existingToast && !changedAttributes?.handRaisedAt) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && !!changedAttributes?.handRaisedAt) {
triggerNotificationSound(NotificationType.HandRaised)
toastQueue.add(
{
participant,
type: NotificationType.HandRaised,
},
{ timeout: NotificationDuration.HAND_RAISED }
)
}
}
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
return () => {
room.off(
RoomEvent.ParticipantAttributesChanged,
handleNotificationReceived
)
}
}, [room, triggerNotificationSound])
useEffect(() => {
const closeAllToasts = () => {
toastQueue.visibleToasts.forEach(({ key }) => toastQueue.close(key))
}
room.on(RoomEvent.Disconnected, closeAllToasts)
return () => {
room.off(RoomEvent.Disconnected, closeAllToasts)
}
}, [room])
// Without this line, when the component first renders,
// the 'notifications' namespace might not be loaded yet
useTranslation(['notifications'])
return <NotificationProvider />
}