diff --git a/src/frontend/src/features/recording/components/LimitDescription.tsx b/src/frontend/src/features/recording/components/LimitDescription.tsx new file mode 100644 index 00000000..b4d2232f --- /dev/null +++ b/src/frontend/src/features/recording/components/LimitDescription.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' + +import { A, Text } from '@/primitives' +import { useConfig } from '@/api/useConfig' +import { useHumanizeDuration } from '@/hooks/useHumanizeDuration' +import { useMemo } from 'react' + +export const LimitDescription = ({ + keyPrefix, + supportArticleLink, +}: { + keyPrefix?: 'transcript' | 'screenRecording' + supportArticleLink?: string +}) => { + const { data } = useConfig() + const { t } = useTranslation('rooms', { keyPrefix }) + + const formatter = useHumanizeDuration() + + const maxRecordingDuration = useMemo( + () => formatter(data?.recording?.max_duration), + [data?.recording?.max_duration, formatter] + ) + + return ( + + {maxRecordingDuration + ? t('body', { max_duration: maxRecordingDuration }) + : t('bodyWithoutMaxDuration')}{' '} + {supportArticleLink && ( + + {t('linkMore')} + + )} + + ) +} diff --git a/src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx b/src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx index ad2481e5..3f901558 100644 --- a/src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx +++ b/src/frontend/src/features/recording/components/LimitReachedAlertDialog.tsx @@ -1,64 +1,81 @@ import { useTranslation } from 'react-i18next' import { Button, Dialog, P } from '@/primitives' import { HStack } from '@/styled-system/jsx' -import { useHumanizeRecordingMaxDuration } from '@/features/recording' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { NotificationType } from '@/features/notifications' -import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly' import { RoomEvent } from 'livekit-client' import { decodeNotificationDataReceived } from '@/features/notifications/utils' import { useRoomContext } from '@livekit/components-react' +import { useHumanizeDuration } from '@/hooks/useHumanizeDuration' +import { useConfig } from '@/api/useConfig' -export const LimitReachedAlertDialog = () => { - const [isAlertOpen, setIsAlertOpen] = useState(false) - +// Isolated into its own component so the `useHumanizeDuration` hook (and the +// `humanize-duration` library it pulls in) is only loaded when the modal is +// actually opened. +const LimitDescription = () => { + const { data } = useConfig() const { t } = useTranslation('rooms', { keyPrefix: 'recordingStateToast.limitReachedAlert', }) + const formatter = useHumanizeDuration() + const formattedDuration = useMemo( + () => formatter(data?.recording?.max_duration), + [formatter, data?.recording?.max_duration] + ) + + return ( +

+ {t('description', { + duration_message: formattedDuration + ? t('durationMessage', { + duration: formattedDuration, + }) + : '', + })} +

+ ) +} + +const LimitReachedAlertDialogContent = () => { + const [isOpen, setIsOpen] = useState(false) + const { t } = useTranslation('rooms', { + keyPrefix: 'recordingStateToast.limitReachedAlert', + }) const room = useRoomContext() - const isAdminOrOwner = useIsAdminOrOwner() - const maxDuration = useHumanizeRecordingMaxDuration() useEffect(() => { - const handleDataReceived = (payload: Uint8Array) => { - if (!isAdminOrOwner) return - + const handleLimitNotification = (payload: Uint8Array) => { const notification = decodeNotificationDataReceived(payload) - if ( notification?.type === NotificationType.TranscriptionLimitReached || notification?.type === NotificationType.ScreenRecordingLimitReached ) { - setIsAlertOpen(true) + setIsOpen(true) } } - - room.on(RoomEvent.DataReceived, handleDataReceived) + room.on(RoomEvent.DataReceived, handleLimitNotification) return () => { - room.off(RoomEvent.DataReceived, handleDataReceived) + room.off(RoomEvent.DataReceived, handleLimitNotification) } - }, [room, isAdminOrOwner]) - - if (!isAdminOrOwner) return null + }, [room]) return ( - -

- {t('description', { - duration_message: maxDuration - ? t('durationMessage', { - duration: maxDuration, - }) - : '', - })} -

+ + - ) } + +export const LimitReachedAlertDialog = () => ( + + + +) diff --git a/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx b/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx index 20c08ed9..cd410210 100644 --- a/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx +++ b/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx @@ -1,4 +1,4 @@ -import { A, Div, H, Text } from '@/primitives' +import { Div, H, Text } from '@/primitives' import { css } from '@/styled-system/css' import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId' @@ -6,7 +6,6 @@ import { useRoomContext } from '@livekit/components-react' import { RecordingMode, useHasRecordingAccess, - useHumanizeRecordingMaxDuration, useRecordingStatuses, } from '@/features/recording' import { useState } from 'react' @@ -29,10 +28,10 @@ import { useMutateRecording } from '../hooks/useMutateRecording' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { FeatureFlags } from '@/features/analytics/enums' +import { LimitDescription } from './LimitDescription' export const ScreenRecordingSidePanel = () => { const { data } = useConfig() - const recordingMaxDuration = useHumanizeRecordingMaxDuration() const keyPrefix = 'screenRecording' const { t } = useTranslation('rooms', { keyPrefix }) @@ -169,22 +168,10 @@ export const ScreenRecordingSidePanel = () => { {t('heading')} - - {recordingMaxDuration - ? t('body', { max_duration: recordingMaxDuration }) - : t('bodyWithoutMaxDuration')}{' '} - {data?.support?.help_article_recording && ( - - {t('linkMore')} - - )} - + diff --git a/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx b/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx index aaa2d54a..64d24bc7 100644 --- a/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx +++ b/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx @@ -7,7 +7,6 @@ import { RecordingMode, useHasRecordingAccess, useHasFeatureWithoutAdminRights, - useHumanizeRecordingMaxDuration, useRecordingStatuses, } from '../index' import { useState } from 'react' @@ -35,10 +34,10 @@ import { useMutateRecording } from '../hooks/useMutateRecording' import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnabled' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { LimitDescription } from './LimitDescription' export const TranscriptSidePanel = () => { const { data } = useConfig() - const recordingMaxDuration = useHumanizeRecordingMaxDuration() const keyPrefix = 'transcript' const { t } = useTranslation('rooms', { keyPrefix }) @@ -193,22 +192,10 @@ export const TranscriptSidePanel = () => { {t('heading')} - - {recordingMaxDuration - ? t('body', { max_duration: recordingMaxDuration }) - : t('bodyWithoutMaxDuration')}{' '} - {data?.support?.help_article_transcript && ( - - {t('linkMore')} - - )} - + diff --git a/src/frontend/src/features/recording/hooks/useHumanizeRecordingMaxDuration.ts b/src/frontend/src/features/recording/hooks/useHumanizeRecordingMaxDuration.ts deleted file mode 100644 index b8793e32..00000000 --- a/src/frontend/src/features/recording/hooks/useHumanizeRecordingMaxDuration.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useMemo } from 'react' -import humanizeDuration from 'humanize-duration' -import i18n from 'i18next' -import { useConfig } from '@/api/useConfig' - -export const useHumanizeRecordingMaxDuration = () => { - const { data } = useConfig() - - return useMemo(() => { - if (!data?.recording?.max_duration) return - - return humanizeDuration(data?.recording?.max_duration, { - language: i18n.language, - delimiter: ' ', - }) - }, [data]) -} diff --git a/src/frontend/src/features/recording/index.ts b/src/frontend/src/features/recording/index.ts index ce068219..e9887a36 100644 --- a/src/frontend/src/features/recording/index.ts +++ b/src/frontend/src/features/recording/index.ts @@ -2,7 +2,6 @@ export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled' export { useHasRecordingAccess } from './hooks/useHasRecordingAccess' export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights' -export { useHumanizeRecordingMaxDuration } from './hooks/useHumanizeRecordingMaxDuration' export { useRecordingStatuses } from './hooks/useRecordingStatuses' // api diff --git a/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx b/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx index 758a142b..31e72115 100644 --- a/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx +++ b/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx @@ -6,79 +6,116 @@ import { connectionObserverStore } from '@/stores/connectionObserver' import { HStack } from '@/styled-system/jsx' import { useEffect, useRef, useState } from 'react' import { navigateTo } from '@/navigation/navigateTo' -import humanizeDuration from 'humanize-duration' -import i18n from 'i18next' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog' import { SettingsDialogExtendedKey } from '@/features/settings/type' +import { useHumanizeDuration } from '@/hooks/useHumanizeDuration' const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30]) const FINAL_COUNTDOWN_SECONDS = 10 -export const IsIdleDisconnectModal = () => { - const connectionObserverSnap = useSnapshot(connectionObserverStore) - const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS) - const lastAnnouncementRef = useRef(null) - const { openSettingsDialog } = useSettingsDialog() - +const useSrCountdownAnnouncement = (seconds: number) => { const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' }) const announce = useScreenReaderAnnounce() - useEffect(() => { - if (connectionObserverSnap.isIdleDisconnectModalOpen) { - setTimeRemaining(IDLE_DISCONNECT_TIMEOUT_MS) - const interval = setInterval(() => { - setTimeRemaining((prev) => { - if (prev <= 1000) { - clearInterval(interval) - connectionObserverStore.isIdleDisconnectModalOpen = false - navigateTo('feedback', { duplicateIdentity: false }) - return 0 - } - return prev - 1000 - }) - }, 1000) - return () => clearInterval(interval) - } - }, [connectionObserverSnap.isIdleDisconnectModalOpen]) + const lastAnnouncementRef = useRef(null) + + const formatter = useHumanizeDuration() useEffect(() => { - if (!connectionObserverSnap.isIdleDisconnectModalOpen) { - lastAnnouncementRef.current = null - } - }, [connectionObserverSnap.isIdleDisconnectModalOpen]) + const shouldAnnounce = + COUNTDOWN_ANNOUNCEMENT_SECONDS.has(seconds) || + seconds <= FINAL_COUNTDOWN_SECONDS + + if (!shouldAnnounce) return + if (seconds === lastAnnouncementRef.current) return + + lastAnnouncementRef.current = seconds + const message = t('countdownAnnouncement', { + duration: formatter(seconds * 1000, { round: false, largest: 2 }), + }) + announce(message, 'assertive', 'idle') + }, [announce, seconds, formatter, t]) +} + +const VisualCountDown = () => { + const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS) const remainingSeconds = Math.floor(timeRemaining / 1000) const minutes = Math.floor(remainingSeconds / 60) const seconds = remainingSeconds % 60 const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}` + useSrCountdownAnnouncement(remainingSeconds) + useEffect(() => { - if (!connectionObserverSnap.isIdleDisconnectModalOpen) return - - const shouldAnnounce = - COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) || - remainingSeconds <= FINAL_COUNTDOWN_SECONDS - - if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) { - lastAnnouncementRef.current = remainingSeconds - const message = t('countdownAnnouncement', { - duration: humanizeDuration(remainingSeconds * 1000, { - language: i18n.language, - round: false, - largest: 2, - }), + setTimeRemaining(IDLE_DISCONNECT_TIMEOUT_MS) + const interval = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1000) { + clearInterval(interval) + connectionObserverStore.isIdleDisconnectModalOpen = false + navigateTo('feedback', { duplicateIdentity: false }) + return 0 + } + return prev - 1000 }) - announce(message, 'assertive', 'idle') - } - }, [ - announce, - connectionObserverSnap.isIdleDisconnectModalOpen, - remainingSeconds, - t, - ]) + }, 1000) + return () => clearInterval(interval) + }, []) + + return ( + + ) +} + +const Description = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' }) + const formatter = useHumanizeDuration() + return

{t('body', { duration: formatter(IDLE_DISCONNECT_TIMEOUT_MS) })}

+} + +const Settings = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' }) + const { openSettingsDialog } = useSettingsDialog() + return ( +

+ {t('settingsPrefix')}{' '} + { + connectionObserverStore.isIdleDisconnectModalOpen = false + openSettingsDialog(SettingsDialogExtendedKey.GENERAL) + }} + > + {t('settingsLink')} + + {t('settingsSuffix')} +

+ ) +} + +export const IsIdleDisconnectModal = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' }) + const connectionObserverSnap = useSnapshot(connectionObserverStore) return ( { return (
- + {t('title')} -

- {t('body', { - duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, { - language: i18n.language, - }), - })} -

-

- {t('settingsPrefix')}{' '} - { - connectionObserverStore.isIdleDisconnectModalOpen = false - openSettingsDialog(SettingsDialogExtendedKey.GENERAL) - }} - > - {t('settingsLink')} - - {t('settingsSuffix')} -

+ +