From ac520d8b347ec5867194167bc91b1b5b37042d33 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 25 May 2026 23:41:22 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20isolate=20humani?= =?UTF-8?q?ze-duration=20in=20its=20own=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library is rarely used, so load it dynamically. It is only 50ko, which might not have been worth the effort, but these 50ko were bundled in the main chunk and are not needed on the homepage nor when launching a room. The static placeholder is good enough to be acceptable. On very slow connections, the 50ko might take a second to load, after which the text is updated with the right content. Also improves renders across the components touched, especially the idle modal with the countdown. --- .../recording/components/LimitDescription.tsx | 43 +++++ .../components/LimitReachedAlertDialog.tsx | 77 +++++--- .../components/ScreenRecordingSidePanel.tsx | 25 +-- .../components/TranscriptSidePanel.tsx | 23 +-- .../hooks/useHumanizeRecordingMaxDuration.ts | 17 -- src/frontend/src/features/recording/index.ts | 1 - .../components/IsIdleDisconnectModal.tsx | 179 +++++++++--------- src/frontend/src/hooks/useHumanizeDuration.ts | 68 +++++++ 8 files changed, 260 insertions(+), 173 deletions(-) create mode 100644 src/frontend/src/features/recording/components/LimitDescription.tsx delete mode 100644 src/frontend/src/features/recording/hooks/useHumanizeRecordingMaxDuration.ts create mode 100644 src/frontend/src/hooks/useHumanizeDuration.ts 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')} -

+ +