✨(frontend) add idle disconnect warning dialog for LiveKit maintenance
Introduce pop-in alerting participants of automatic 2-minute idle disconnect to enable LiveKit node configuration updates during maintenance windows, preventing forgotten tabs from blocking overnight production updates following patterns from proprietary videoconference solutions.
This commit is contained in:
committed by
aleb_the_flash
parent
214dc87b1f
commit
2443fa63a5
@@ -0,0 +1,102 @@
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
|
||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||
|
||||
export const IsIdleDisconnectModal = () => {
|
||||
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||
|
||||
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 minutes = Math.floor(timeRemaining / 1000 / 60)
|
||||
const seconds = (timeRemaining / 1000) % 60
|
||||
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
||||
role="alertdialog"
|
||||
type="alert"
|
||||
aria-label={t('title')}
|
||||
onClose={() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
}}
|
||||
>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<div>
|
||||
<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',
|
||||
})}
|
||||
>
|
||||
{formattedTime}
|
||||
</div>
|
||||
<H lvl={2} centered>
|
||||
{t('title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('body', {
|
||||
duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, {
|
||||
language: i18n.language,
|
||||
}),
|
||||
})}
|
||||
</P>
|
||||
<P>{t('settings')}</P>
|
||||
<HStack 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>
|
||||
</HStack>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,63 @@
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
useRemoteParticipants,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import posthog from 'posthog-js'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useConnectionObserver = () => {
|
||||
const room = useRoomContext()
|
||||
const connectionStartTimeRef = useRef<number | null>(null)
|
||||
|
||||
const { data } = useConfig()
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [
|
||||
RoomEvent.ParticipantConnected,
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear existing timer on dependency change
|
||||
if (idleDisconnectModalTimeoutRef.current) {
|
||||
clearTimeout(idleDisconnectModalTimeoutRef.current)
|
||||
idleDisconnectModalTimeoutRef.current = null
|
||||
}
|
||||
|
||||
const delay = data?.idle_disconnect_warning_delay
|
||||
|
||||
// Disabled or invalid delay: ensure modal is closed
|
||||
if (!delay) {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
return
|
||||
}
|
||||
|
||||
if (remoteParticipants.length === 0) {
|
||||
idleDisconnectModalTimeoutRef.current = setTimeout(() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = true
|
||||
}, delay)
|
||||
} else {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (idleDisconnectModalTimeoutRef.current) {
|
||||
clearTimeout(idleDisconnectModalTimeoutRef.current)
|
||||
idleDisconnectModalTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [remoteParticipants.length, data?.idle_disconnect_warning_delay])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
import { GridLayout } from '../components/layout/GridLayout'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -191,6 +192,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "Neues Fenster"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Bist du noch da?",
|
||||
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
|
||||
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
|
||||
"stayButton": "Gespräch fortsetzen",
|
||||
"leaveButton": "Jetzt verlassen"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Mikrofon",
|
||||
"camera": "Kamera",
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "New window"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Are you still there?",
|
||||
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
|
||||
"settings": "To stop seeing this message, go to Settings > General.",
|
||||
"stayButton": "Continue the call",
|
||||
"leaveButton": "Leave now"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "Nouvelle fenêtre"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Êtes-vous toujours là ?",
|
||||
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
|
||||
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
|
||||
"stayButton": "Poursuivre l'appel",
|
||||
"leaveButton": "Quitter maintenant"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "Nieuw venster"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Ben je er nog?",
|
||||
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
|
||||
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
|
||||
"stayButton": "Gesprek voortzetten",
|
||||
"leaveButton": "Nu verlaten"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microfoon",
|
||||
"camera": "Camera",
|
||||
|
||||
9
src/frontend/src/stores/connectionObserver.ts
Normal file
9
src/frontend/src/stores/connectionObserver.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
isIdleDisconnectModalOpen: boolean
|
||||
}
|
||||
|
||||
export const connectionObserverStore = proxy<State>({
|
||||
isIdleDisconnectModalOpen: false,
|
||||
})
|
||||
@@ -57,6 +57,7 @@ backend:
|
||||
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
||||
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
Reference in New Issue
Block a user