🚸(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:
committed by
aleb_the_flash
parent
59cd1f766a
commit
7c631bb76f
15
src/frontend/package-lock.json
generated
15
src/frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user