From 7c631bb76f92ab81b1a9dae4bfb7253c440b478c Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 16 Jul 2025 11:08:28 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=B8(frontend)=20alert=20room=20owner?= =?UTF-8?q?=20when=20recording=20exceeds=20max=20duration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/frontend/package-lock.json | 15 ++++++++ src/frontend/package.json | 4 +- .../notifications/NotificationType.ts | 2 + .../components/LimitReachedAlertDialog.tsx | 38 +++++++++++++++++++ .../components/RecordingStateToast.tsx | 36 +++++++++++++----- src/frontend/src/locales/de/rooms.json | 6 +++ src/frontend/src/locales/en/rooms.json | 6 +++ src/frontend/src/locales/fr/rooms.json | 6 +++ src/frontend/src/locales/nl/rooms.json | 6 +++ 9 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index a2f784fe..baa18bdb 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -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", diff --git a/src/frontend/package.json b/src/frontend/package.json index a6c86f2a..8db740f9 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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", diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index 7f022822..827a2323 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -8,7 +8,9 @@ export enum NotificationType { ParticipantWaiting = 'participantWaiting', TranscriptionStarted = 'transcriptionStarted', TranscriptionStopped = 'transcriptionStopped', + TranscriptionLimitReached = 'transcriptionLimitReached', ScreenRecordingStarted = 'screenRecordingStarted', ScreenRecordingStopped = 'screenRecordingStopped', + ScreenRecordingLimitReached = 'screenRecordingLimitReached', RecordingSaving = 'recordingSaving', } diff --git a/src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx b/src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx new file mode 100644 index 00000000..431d5a63 --- /dev/null +++ b/src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx @@ -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 ( + +

+ {t('description', { + duration_message: data?.recording?.max_duration + ? t('durationMessage', { + duration: humanizeDuration(data?.recording?.max_duration, { + language: i18n.language, + }), + }) + : '', + })} +

+ + + +
+ ) +} diff --git a/src/frontend/src/features/recording/components/RecordingStateToast.tsx b/src/frontend/src/features/recording/components/RecordingStateToast.tsx index 6803936c..8bdbee1f 100644 --- a/src/frontend/src/features/recording/components/RecordingStateToast.tsx +++ b/src/frontend/src/features/recording/components/RecordingStateToast.tsx @@ -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 ? ( + setIsAlertOpen(false)} + aria-label="Recording limit exceeded" + /> + ) : null const isStarted = key?.includes('started') diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index f99fb4a5..df89b07a 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index fc367b30..b771428c 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 5ae5bd44..8c2aa2a3 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index c2fe2fe5..906e4fc5 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -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": {