diff --git a/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx b/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx new file mode 100644 index 00000000..d2eac89a --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/IsIdleDisconnectModal.tsx @@ -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 ( + { + connectionObserverStore.isIdleDisconnectModalOpen = false + }} + > + {({ close }) => { + return ( +
+
+ {formattedTime} +
+ + {t('title')} + +

+ {t('body', { + duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, { + language: i18n.language, + }), + })} +

+

{t('settings')}

+ + + + +
+ ) + }} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts b/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts index dfe60da6..74d7f7cf 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts @@ -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(null) + const { data } = useConfig() const isAnalyticsEnabled = useIsAnalyticsEnabled() + const idleDisconnectModalTimeoutRef = useRef | 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 diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 0c3efb54..edffa7e3 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -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)} /> +
Allgemein.", + "stayButton": "Gespräch fortsetzen", + "leaveButton": "Jetzt verlassen" + }, "controls": { "microphone": "Mikrofon", "camera": "Kamera", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 6bce3a61..694ea115 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index ba3b9d22..d2469a50 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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", diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 7821fd55..47f04777 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -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", diff --git a/src/frontend/src/stores/connectionObserver.ts b/src/frontend/src/stores/connectionObserver.ts new file mode 100644 index 00000000..5149cd59 --- /dev/null +++ b/src/frontend/src/stores/connectionObserver.ts @@ -0,0 +1,9 @@ +import { proxy } from 'valtio' + +type State = { + isIdleDisconnectModalOpen: boolean +} + +export const connectionObserverStore = proxy({ + isIdleDisconnectModalOpen: false, +}) diff --git a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl index 44f9d245..cb1bbce2 100644 --- a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl @@ -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