🚸(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", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25", "crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1", "i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0", "i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0", "i18next-parser": "9.3.0",
@@ -37,6 +38,7 @@
"@pandacss/dev": "0.54.0", "@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2", "@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5", "@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0", "@types/node": "22.16.0",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
@@ -3994,6 +3996,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/minimatch": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@@ -6535,6 +6544,12 @@
"entities": "^4.5.0" "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": { "node_modules/i18next": {
"version": "25.3.1", "version": "25.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",

View File

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

View File

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

View File

@@ -379,6 +379,12 @@
}, },
"any": { "any": {
"started": "Aufzeichnung läuft" "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": { "participantTileFocus": {

View File

@@ -379,6 +379,12 @@
}, },
"any": { "any": {
"started": "Recording in progress" "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": { "participantTileFocus": {

View File

@@ -379,6 +379,12 @@
}, },
"any": { "any": {
"started": "Enregistrement en cours" "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": { "participantTileFocus": {

View File

@@ -379,6 +379,12 @@
}, },
"any": { "any": {
"started": "Opname bezig" "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": { "participantTileFocus": {