️(frontend) isolate humanize-duration in its own chunk

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.
This commit is contained in:
lebaudantoine
2026-05-25 23:41:22 +02:00
committed by aleb_the_flash
parent 33ac849d3b
commit ac520d8b34
8 changed files with 260 additions and 173 deletions
@@ -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 (
<Text variant="body" fullWidth>
{maxRecordingDuration
? t('body', { max_duration: maxRecordingDuration })
: t('bodyWithoutMaxDuration')}{' '}
{supportArticleLink && (
<A
href={supportArticleLink}
target="_blank"
rel="noopener noreferrer"
externalIcon
aria-label={t('linkAriaLabel')}
>
{t('linkMore')}
</A>
)}
</Text>
)
}
@@ -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 (
<P>
{t('description', {
duration_message: formattedDuration
? t('durationMessage', {
duration: formattedDuration,
})
: '',
})}
</P>
)
}
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 (
<Dialog isOpen={isAlertOpen} role="alertdialog" title={t('title')}>
<P>
{t('description', {
duration_message: maxDuration
? t('durationMessage', {
duration: maxDuration,
})
: '',
})}
</P>
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
<LimitDescription />
<HStack gap={1}>
<Button variant="text" size="sm" onPress={() => setIsAlertOpen(false)}>
<Button variant="text" size="sm" onPress={() => setIsOpen(false)}>
{t('button')}
</Button>
</HStack>
</Dialog>
)
}
export const LimitReachedAlertDialog = () => (
<AdminOrOwnerOnly>
<LimitReachedAlertDialogContent />
</AdminOrOwnerOnly>
)
@@ -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 = () => {
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', { max_duration: recordingMaxDuration })
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_recording && (
<A
href={data.support.help_article_recording}
target="_blank"
rel="noopener noreferrer"
externalIcon
aria-label={t('linkAriaLabel')}
>
{t('linkMore')}
</A>
)}
</Text>
<LimitDescription
keyPrefix={'screenRecording'}
supportArticleLink={data?.support?.help_article_recording}
/>
</VStack>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="cloud_download" position="first">
@@ -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 = () => {
<H lvl={1} margin={'sm'}>
{t('heading')}
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', { max_duration: recordingMaxDuration })
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
target="_blank"
rel="noopener noreferrer"
externalIcon
aria-label={t('linkAriaLabel')}
>
{t('linkMore')}
</A>
)}
</Text>
<LimitDescription
keyPrefix={'transcript'}
supportArticleLink={data?.support?.help_article_transcript}
/>
</VStack>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="article" position="first">
@@ -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])
}
@@ -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
@@ -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<number | null>(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<number | null>(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 (
<div
className={css({
height: '50px',
width: '50px',
backgroundColor: 'blue.100',
borderRadius: '25px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: '500',
color: 'blue.800',
margin: 'auto',
})}
aria-hidden="true"
>
{formattedTime}
</div>
)
}
const Description = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const formatter = useHumanizeDuration()
return <P>{t('body', { duration: formatter(IDLE_DISCONNECT_TIMEOUT_MS) })}</P>
}
const Settings = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const { openSettingsDialog } = useSettingsDialog()
return (
<P>
{t('settingsPrefix')}{' '}
<A
color="primary"
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
}}
>
{t('settingsLink')}
</A>
{t('settingsSuffix')}
</P>
)
}
export const IsIdleDisconnectModal = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const connectionObserverSnap = useSnapshot(connectionObserverStore)
return (
<Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
@@ -93,46 +130,12 @@ export const IsIdleDisconnectModal = () => {
return (
<div>
<ScreenReaderAnnouncer channel="idle" />
<div
className={css({
height: '50px',
width: '50px',
backgroundColor: 'blue.100',
borderRadius: '25px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: '500',
color: 'blue.800',
margin: 'auto',
})}
aria-hidden="true"
>
{formattedTime}
</div>
<VisualCountDown />
<H lvl={2} centered>
{t('title')}
</H>
<P>
{t('body', {
duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, {
language: i18n.language,
}),
})}
</P>
<P>
{t('settingsPrefix')}{' '}
<A
color="primary"
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
}}
>
{t('settingsLink')}
</A>
{t('settingsSuffix')}
</P>
<Description />
<Settings />
<HStack marginTop="2rem">
<Button
onPress={() => {
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from 'react'
import i18n from 'i18next'
import type humanizeDurationType from 'humanize-duration'
// Synchronous, locale-aware fallback shown until `humanize-duration` loads.
// Uses Intl.NumberFormat's `unit` style so we get correct pluralization
// and unit names in every locale without shipping translations.
function fallbackFormat(ms: number, locale: string): string {
const seconds = Math.round(ms / 1000)
const minutes = Math.round(seconds / 60)
const hours = Math.round(minutes / 60)
const format = (value: number, unit: 'hour' | 'minute' | 'second') =>
new Intl.NumberFormat(locale, {
style: 'unit',
unit,
unitDisplay: 'long',
}).format(value)
if (hours >= 1) return format(hours, 'hour')
if (minutes >= 1) return format(minutes, 'minute')
return format(seconds, 'second')
}
let humanizeDuration: typeof humanizeDurationType | null = null
let loadPromise: Promise<typeof humanizeDurationType> | null = null
const loadHumanizeDuration = () => {
loadPromise ??= import('humanize-duration').then((m) => {
humanizeDuration = m.default
return m.default
})
return loadPromise
}
export const useHumanizeDuration = () => {
const [isLoaded, setIsLoaded] = useState(() => humanizeDuration !== null)
useEffect(() => {
if (isLoaded) return
let cancelled = false
loadHumanizeDuration().then(() => {
if (!cancelled) setIsLoaded(true)
})
return () => {
cancelled = true
}
}, [isLoaded])
return useCallback(
(
duration: number | undefined,
{ round, largest }: { round?: boolean; largest?: number } = {}
): string | undefined => {
if (duration == undefined) return undefined
if (!humanizeDuration || !isLoaded)
return fallbackFormat(duration, i18n.language)
return humanizeDuration(duration, {
language: i18n.language,
delimiter: ' ',
...(round !== undefined && { round }),
...(largest !== undefined && { largest }),
})
},
[isLoaded]
)
}