From 01e148d082f5f520a660a794d8e0a8e2327966ba Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:43:34 +0200 Subject: [PATCH] chore(orbit): auto-leave on guest side after prolonged host silence Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/OrbitExitModal.tsx | 17 +++++++++++------ src/hooks/useOrbitGuest.ts | 17 +++++++++++++++++ src/locales/de.ts | 2 ++ src/locales/en.ts | 2 ++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/components/OrbitExitModal.tsx b/src/components/OrbitExitModal.tsx index 9b8b8b4a..688889a3 100644 --- a/src/components/OrbitExitModal.tsx +++ b/src/components/OrbitExitModal.tsx @@ -22,21 +22,26 @@ export default function OrbitExitModal() { const sessionName = useOrbitStore(s => s.state?.name); const hostName = useOrbitStore(s => s.state?.host); - const isEnded = phase === 'ended'; - const isKicked = phase === 'error' && errorMessage === 'kicked'; - const isRemoved = phase === 'error' && errorMessage === 'removed'; - if (!isEnded && !isKicked && !isRemoved) return null; + const isEnded = phase === 'ended'; + const isKicked = phase === 'error' && errorMessage === 'kicked'; + const isRemoved = phase === 'error' && errorMessage === 'removed'; + const isHostTimeout = phase === 'error' && errorMessage === 'host-timeout'; + if (!isEnded && !isKicked && !isRemoved && !isHostTimeout) return null; const title = isKicked ? t('orbit.exitKickedTitle') : isRemoved ? t('orbit.exitRemovedTitle') - : t('orbit.exitEndedTitle'); + : isHostTimeout + ? t('orbit.exitHostTimeoutTitle') + : t('orbit.exitEndedTitle'); const body = isKicked ? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' }) : isRemoved ? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' }) - : t('orbit.exitEndedBody', { name: sessionName ?? '' }); + : isHostTimeout + ? t('orbit.exitHostTimeoutBody', { host: hostName ?? '', name: sessionName ?? '' }) + : t('orbit.exitEndedBody', { name: sessionName ?? '' }); const onOk = async () => { try { diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index 6fb2e023..30003c07 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -28,6 +28,13 @@ import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from ' const STATE_READ_TICK_MS = 2_500; const HEARTBEAT_TICK_MS = 10_000; +/** + * Host must be quiet (no state writes) for this long before we treat the + * session as dead and auto-leave. Well above any normal network blip — + * reconnects inside this window are silent. Tuned per user decision: + * manual exits have priority, short reconnects never trigger auto-close. + */ +const HOST_TIMEOUT_MS = 5 * 60_000; export function useOrbitGuest(): void { const role = useOrbitStore(s => s.role); @@ -129,6 +136,16 @@ export function useOrbitGuest(): void { useOrbitStore.getState().setState(state); + // Auto-leave after prolonged host silence. We keep polling as long as + // state reads succeed (short reconnects are silent), but if the host + // hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the + // session as effectively dead and surface the exit modal. Manual exit + // still works instantly — the bar's X button short-circuits this path. + if (state.positionAt > 0 && (Date.now() - state.positionAt) > HOST_TIMEOUT_MS) { + useOrbitStore.getState().setError('host-timeout'); + return; + } + // Reconcile pending guest suggestions against the host's *playable* // queue — NOT `state.queue`, which is the suggestion history (every // submission lands there immediately, even under manual-approval mode diff --git a/src/locales/de.ts b/src/locales/de.ts index 384a7ff9..2276c093 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1566,6 +1566,8 @@ export const deTranslation = { exitKickedTitle: 'Du wurdest aus der Session gebannt', exitRemovedTitle: 'Du wurdest aus der Session entfernt', exitEndedTitle: 'Der Gastgeber hat die Session beendet', + exitHostTimeoutTitle: 'Host nicht mehr erreichbar', + exitHostTimeoutBody: '{{host}} hat sich länger nicht mehr gemeldet — „{{name}}" wurde auf deiner Seite beendet. Über den Einladungslink kannst du jederzeit wieder beitreten.', exitKickedBody: '{{host}} hat dich aus „{{name}}" gebannt. Beitritt nicht mehr möglich.', exitRemovedBody: '{{host}} hat dich aus „{{name}}" entfernt. Du kannst jederzeit über den Einladungslink wieder beitreten.', exitEndedBody: '„{{name}}" ist zu Ende. Hoffentlich war\'s schön.', diff --git a/src/locales/en.ts b/src/locales/en.ts index 52816abb..1a52cf12 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1569,6 +1569,8 @@ export const enTranslation = { exitKickedTitle: 'You were banned from the session', exitRemovedTitle: 'You were removed from the session', exitEndedTitle: 'The host ended the session', + exitHostTimeoutTitle: 'Host went silent', + exitHostTimeoutBody: "{{host}} hasn't sent an update for a while, so \"{{name}}\" was closed on your end. You can re-join any time with the invite link.", exitKickedBody: '{{host}} banned you from "{{name}}". You can\'t re-join.', exitRemovedBody: '{{host}} removed you from "{{name}}". You can re-join any time via the invite link.', exitEndedBody: '"{{name}}" has ended. Hope you had fun.',