🚸(frontend) alert room owner when recording exceeds max duration

Display notification to prevent silent recording failures. Shows
configured max duration in proper locale if backend provides the limit.
Prevents users from missing recording termination.
This commit is contained in:
lebaudantoine
2025-07-16 11:08:28 +02:00
committed by aleb_the_flash
parent 59cd1f766a
commit 7c631bb76f
9 changed files with 108 additions and 11 deletions

View File

@@ -18,6 +18,7 @@
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
@@ -37,6 +38,7 @@
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
@@ -3994,6 +3996,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/humanize-duration": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz",
"integrity": "sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@@ -6535,6 +6544,12 @@
"entities": "^4.5.0"
}
},
"node_modules/humanize-duration": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz",
"integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==",
"license": "Unlicense"
},
"node_modules/i18next": {
"version": "25.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",

View File

@@ -23,13 +23,14 @@
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.9",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"libphonenumber-js": "1.12.9",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
@@ -42,6 +43,7 @@
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",

View File

@@ -8,7 +8,9 @@ export enum NotificationType {
ParticipantWaiting = 'participantWaiting',
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
}

View File

@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
export const LimitReachedAlertDialog = ({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) => {
const { t, i18n } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast.limitReachedAlert',
})
const { data } = useConfig()
return (
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
<P>
{t('description', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
duration: humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
}),
})
: '',
})}
</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={onClose}>
{t('button')}
</Button>
</HStack>
</Dialog>
)
}

View File

@@ -1,11 +1,11 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio/index'
import { useSnapshot } from 'valtio'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Text } from '@/primitives'
import { RemoteParticipant, RoomEvent } from 'livekit-client'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
@@ -18,14 +18,18 @@ import {
import { FeatureFlags } from '@/features/analytics/enums'
import { Button as RACButton } from 'react-aria-components'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast',
})
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const { openTranscript, openScreenRecording } = useSidePanel()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const recordingSnap = useSnapshot(recordingStore)
@@ -53,13 +57,10 @@ export const RecordingStateToast = () => {
}, [room.isRecording])
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const handleDataReceived = (payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
if (!participant || !notification) return
if (!notification) return
switch (notification.type) {
case NotificationType.TranscriptionStarted:
@@ -68,12 +69,20 @@ export const RecordingStateToast = () => {
case NotificationType.TranscriptionStopped:
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.TranscriptionLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break
case NotificationType.ScreenRecordingStopped:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
case NotificationType.ScreenRecordingLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
default:
return
}
@@ -100,7 +109,7 @@ export const RecordingStateToast = () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room, recordingSnap])
}, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
const key = useMemo(() => {
switch (recordingSnap.status) {
@@ -119,7 +128,14 @@ export const RecordingStateToast = () => {
}
}, [recordingSnap])
if (!key) return
if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
isOpen={isAlertOpen}
onClose={() => setIsAlertOpen(false)}
aria-label="Recording limit exceeded"
/>
) : null
const isStarted = key?.includes('started')

View File

@@ -379,6 +379,12 @@
},
"any": {
"started": "Aufzeichnung läuft"
},
"limitReachedAlert": {
"title": "Aufnahmelimit überschritten",
"description": "Die Aufnahme hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
"durationMessage": " von {{duration}}",
"button": "OK"
}
},
"participantTileFocus": {

View File

@@ -379,6 +379,12 @@
},
"any": {
"started": "Recording in progress"
},
"limitReachedAlert": {
"title": "Recording limit exceeded",
"description": "The recording has exceeded the maximum allowed duration{{duration_message}}. It will now be saved automatically.",
"durationMessage": " of {{duration}}",
"button": "OK"
}
},
"participantTileFocus": {

View File

@@ -379,6 +379,12 @@
},
"any": {
"started": "Enregistrement en cours"
},
"limitReachedAlert": {
"title": "Limite d'enregistrement dépassée",
"description": "L'enregistrement a dépassé la durée maximale autorisée{{duration_message}}. Il va maintenant être automatiquement sauvegardé.",
"durationMessage": " de {{duration}}",
"button": "OK"
}
},
"participantTileFocus": {

View File

@@ -379,6 +379,12 @@
},
"any": {
"started": "Opname bezig"
},
"limitReachedAlert": {
"title": "Opnamelimiet overschreden",
"description": "De opname heeft de maximaal toegestane duur{{duration_message}} overschreden. Deze wordt nu automatisch opgeslagen.",
"durationMessage": " van {{duration}}",
"button": "OK"
}
},
"participantTileFocus": {