mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-11 23:52:00 +00:00
d89b01b681
`EGRESS_ABORTED` and `EGRESS_FAILED` events were previously ignored, leaving recordings indefinitely in `ACTIVE` state and potentially blocking subsequent recordings with 409 errors. Add handling and logging for failed and aborted egresses, discarding failed recordings while preserving the existing behavior for savable recordings. Rename `handle_complete` to `handle_savable` to reflect that it handles both `EGRESS_COMPLETE` and `EGRESS_LIMIT_REACHED`. Slight refactor to separate LiveKit event handling from recording concerns as part of a general separation concern to allow for future SFU swapping. NB: - FAILED recordings are currently discarded although exploitable media files may exist - There is a theoretical hole: if stop observes EGRESS_FAILED before the egress_ended webhook is processed, the recording is immediately marked as FAILED. Since only ACTIVE and STOPPED recordings are savable, the webhook then skips the failure notification and LiveKit error log. In that rare race condition, participants may therefore not see the failure toast. We accept this trade-off for now, as this should be very infrequent. - Another theoretical hole: There is a short race window where the user clicks stop while the limit-reached status is being processed. Since the user explicitly requested the stop, we consider skipping the limit notification acceptable and do not handle this case. fix(recording): log aborted worker events at info level
277 lines
8.6 KiB
TypeScript
277 lines
8.6 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:
|
|
case NotificationType.TranscriptionFailed:
|
|
case NotificationType.ScreenRecordingFailed:
|
|
case NotificationType.TranscriptionAborted:
|
|
case NotificationType.ScreenRecordingAborted:
|
|
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 />
|
|
}
|