mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-05 16:37:43 +00:00
⚡️(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:
committed by
aleb_the_flash
parent
33ac849d3b
commit
ac520d8b34
@@ -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]
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user