Files
meet/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx
T
lebaudantoine 6e2a7f0ca6 📱(frontend) stack idle modal buttons in a column on mobile
Wrap the buttons in the idle modal so they stack vertically on
mobile viewports.

The horizontal layout was cramped on small screens; a column layout
gives each button a comfortable touch target and reads more
naturally on narrow displays.
2026-08-24 14:51:07 +02:00

162 lines
4.9 KiB
TypeScript

import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { Stack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
import { openSettingsDialog } from '@/stores/settings'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
const FINAL_COUNTDOWN_SECONDS = 10
const useSrCountdownAnnouncement = (seconds: number) => {
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const announce = useScreenReaderAnnounce()
const lastAnnouncementRef = useRef<number | null>(null)
const formatter = useHumanizeDuration()
useEffect(() => {
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(() => {
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)
}, [])
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' })
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}
role="alertdialog"
type="alert"
aria-label={t('title')}
onClose={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
}}
>
{({ close }) => {
return (
<div>
<ScreenReaderAnnouncer channel="idle" />
<VisualCountDown />
<H lvl={2} centered>
{t('title')}
</H>
<Description />
<Settings />
<Stack
direction={{ base: 'column', xsm: 'row' }}
align={{ base: 'stretch', xsm: 'center' }}
marginTop="2rem"
>
<Button
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
}}
size="sm"
variant="secondary"
>
{t('leaveButton')}
</Button>
<Button onPress={close} size="sm" variant="primary">
{t('stayButton')}
</Button>
</Stack>
</div>
)
}}
</Dialog>
)
}