diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index daccb294..37132ccd 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -23,6 +23,7 @@ import GlobalConfirmModal from '../components/GlobalConfirmModal'; import ThemeMigrationNotice from '../components/ThemeMigrationNotice'; import OrbitAccountPicker from '../components/OrbitAccountPicker'; import OrbitHelpModal from '../components/OrbitHelpModal'; +import OrbitReconnectModal from '../components/OrbitReconnectModal'; import TooltipPortal from '../components/TooltipPortal'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { @@ -322,6 +323,7 @@ export function AppShell() { + {!perfFlags.disableTooltipPortal && } diff --git a/src/components/OrbitReconnectModal.tsx b/src/components/OrbitReconnectModal.tsx new file mode 100644 index 00000000..632cbac2 --- /dev/null +++ b/src/components/OrbitReconnectModal.tsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { useAuthStore } from '../store/authStore'; +import { showToast } from '../utils/ui/toast'; +import { + clearOrbitLastSession, + findSessionPlaylistId, + joinOrbitSession, + ORBIT_RECONNECT_COUNTDOWN_S, + ORBIT_RECONNECT_MAX_AGE_MS, + readOrbitLastSession, + readOrbitState, + resumeOrbitSessionAsHost, + type OrbitLastSession, +} from '../utils/orbit'; + +/** + * Orbit — reconnect prompt shown at startup when the app was restarted while a + * session was active. The in-memory Orbit store is gone after a restart, but + * the `lastSession` breadcrumb survives; this verifies the session is still + * alive on the server and offers a one-click rejoin with a countdown that + * auto-rejoins when it reaches zero. + * + * Host breadcrumbs resume hosting (`resumeOrbitSessionAsHost`); guest + * breadcrumbs rejoin via the normal `joinOrbitSession` path. Declining / + * Escape / a failed attempt wipes the breadcrumb so it isn't offered again. + */ +export default function OrbitReconnectModal() { + const { t } = useTranslation(); + const isLoggedIn = useAuthStore(s => s.isLoggedIn); + const activeServerId = useAuthStore(s => s.activeServerId); + const orbitRole = useOrbitStore(s => s.role); + + const [candidate, setCandidate] = useState(null); + const [secondsLeft, setSecondsLeft] = useState(ORBIT_RECONNECT_COUNTDOWN_S); + const [busy, setBusy] = useState(false); + const ranRef = useRef(false); + const firedRef = useRef(false); + + // One-shot startup preflight: is there a live session worth offering? + useEffect(() => { + if (ranRef.current) return; + if (!isLoggedIn || !activeServerId) return; // wait for auth hydration + if (orbitRole !== null) return; // already bound to a session + ranRef.current = true; + + const rec = readOrbitLastSession(); + if (!rec || rec.serverId !== activeServerId) return; // none / different server + + let cancelled = false; + void (async () => { + try { + const sessionPlaylistId = await findSessionPlaylistId(rec.sid); + if (!sessionPlaylistId) { clearOrbitLastSession(); return; } + const state = await readOrbitState(sessionPlaylistId); + if (!state || state.ended) { clearOrbitLastSession(); return; } + // Too long since the last host snapshot → treat as dead, don't offer. + if (Date.now() - (state.positionAt ?? 0) > ORBIT_RECONNECT_MAX_AGE_MS) { + clearOrbitLastSession(); + return; + } + // A host breadcrumb only resumes if we're still the session's host. + if (rec.role === 'host' && state.host !== useAuthStore.getState().getActiveServer()?.username) { + clearOrbitLastSession(); + return; + } + if (!cancelled) setCandidate(rec); + } catch { + /* network hiccup — keep the breadcrumb, just skip the prompt this launch */ + } + })(); + return () => { cancelled = true; }; + }, [isLoggedIn, activeServerId, orbitRole]); + + const doReconnect = useCallback(async () => { + if (firedRef.current) return; // guard countdown + manual click racing + const rec = candidate; + if (!rec) return; + firedRef.current = true; + setBusy(true); + try { + if (rec.role === 'host') await resumeOrbitSessionAsHost(rec.sid); + else await joinOrbitSession(rec.sid); + setCandidate(null); // success → store now bound, modal hides + } catch { + clearOrbitLastSession(); + showToast(t('orbit.reconnect.failed'), 4000, 'error'); + setCandidate(null); + } finally { + setBusy(false); + } + }, [candidate, t]); + + const stayOut = useCallback(() => { + clearOrbitLastSession(); + setCandidate(null); + }, []); + + // Countdown → auto-rejoin at zero. Paused while a reconnect is in flight. + useEffect(() => { + if (!candidate || busy) return; + if (secondsLeft <= 0) { void doReconnect(); return; } + const id = window.setTimeout(() => setSecondsLeft(s => s - 1), 1000); + return () => window.clearTimeout(id); + }, [candidate, busy, secondsLeft, doReconnect]); + + // Enter → rejoin now; Escape → stay out (explicit dismissal, no auto-rejoin). + useEffect(() => { + if (!candidate) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); void doReconnect(); } + else if (e.key === 'Escape') { e.preventDefault(); stayOut(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [candidate, doReconnect, stayOut]); + + if (!candidate) return null; + + const body = candidate.role === 'host' + ? t('orbit.reconnect.bodyHost', { name: candidate.sessionName }) + : t('orbit.reconnect.bodyGuest', { host: candidate.hostUsername, name: candidate.sessionName }); + + return createPortal( + { if (e.target === e.currentTarget) stayOut(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-reconnect-title" + > + + {t('orbit.reconnect.title')} + {body} + {!busy && ( + {t('orbit.reconnect.autoIn', { seconds: secondsLeft })} + )} + + + {t('orbit.reconnect.stayOut')} + + void doReconnect()} disabled={busy} autoFocus> + {busy ? t('orbit.reconnect.reconnecting') : t('orbit.reconnect.rejoin')} + + + + , + document.body, + ); +} diff --git a/src/locales/de/orbit.ts b/src/locales/de/orbit.ts index 19e3ae4a..bc4f71ea 100644 --- a/src/locales/de/orbit.ts +++ b/src/locales/de/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: 'Wieder in deine Orbit-Session?', + bodyHost: 'Du hast „{{name}}" gehostet. Dort weitermachen, wo du aufgehört hast?', + bodyGuest: '{{host}} hostet „{{name}}". Wieder beitreten?', + autoIn: 'Automatisch in {{seconds}}s', + rejoin: 'Wieder rein', + stayOut: 'Draußen bleiben', + reconnecting: 'Trete bei…', + failed: 'Wiedereinstieg fehlgeschlagen — die Session ist vielleicht beendet.', + }, triggerLabel: 'Orbit', triggerTooltip: 'Gemeinsame Hör-Session starten oder beitreten', launchCreate: 'Session erstellen', diff --git a/src/locales/en/orbit.ts b/src/locales/en/orbit.ts index 49a72332..bb75c68d 100644 --- a/src/locales/en/orbit.ts +++ b/src/locales/en/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: 'Rejoin your Orbit session?', + bodyHost: 'You were hosting "{{name}}". Pick the session back up where you left off?', + bodyGuest: '{{host}} is hosting "{{name}}". Rejoin the session?', + autoIn: 'Auto-rejoin in {{seconds}}s', + rejoin: 'Rejoin', + stayOut: 'Stay out', + reconnecting: 'Rejoining…', + failed: "Couldn't rejoin — the session may have ended.", + }, triggerLabel: 'Orbit', triggerTooltip: 'Start or join a shared listening session', launchCreate: 'Create a session', diff --git a/src/locales/es/orbit.ts b/src/locales/es/orbit.ts index fbc27850..9f9498dc 100644 --- a/src/locales/es/orbit.ts +++ b/src/locales/es/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: '¿Volver a tu sesión de Orbit?', + bodyHost: 'Estabas alojando «{{name}}». ¿Retomar la sesión donde la dejaste?', + bodyGuest: '{{host}} está alojando «{{name}}». ¿Volver a unirte?', + autoIn: 'Reconexión automática en {{seconds}} s', + rejoin: 'Volver a unirse', + stayOut: 'Quedarse fuera', + reconnecting: 'Uniéndose…', + failed: 'No se pudo volver a unir: puede que la sesión haya terminado.', + }, triggerLabel: 'Orbit', triggerTooltip: 'Iniciar o unirse a una sesión de escucha compartida', launchCreate: 'Crear una sesión', diff --git a/src/locales/fr/orbit.ts b/src/locales/fr/orbit.ts index 7caa47f7..7f85a8f0 100644 --- a/src/locales/fr/orbit.ts +++ b/src/locales/fr/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: 'Rejoindre votre session Orbit ?', + bodyHost: 'Vous hébergiez « {{name}} ». Reprendre la session là où vous l\'avez laissée ?', + bodyGuest: '{{host}} héberge « {{name}} ». Rejoindre la session ?', + autoIn: 'Reconnexion auto dans {{seconds}} s', + rejoin: 'Rejoindre', + stayOut: 'Rester en dehors', + reconnecting: 'Connexion…', + failed: 'Impossible de rejoindre — la session est peut-être terminée.', + }, triggerLabel: 'Orbit', triggerTooltip: 'Démarrer ou rejoindre une session d\'écoute partagée', launchCreate: 'Créer une session', diff --git a/src/locales/nb/orbit.ts b/src/locales/nb/orbit.ts index 673e8274..55e192f6 100644 --- a/src/locales/nb/orbit.ts +++ b/src/locales/nb/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: 'Bli med i Orbit-økten igjen?', + bodyHost: 'Du var vert for «{{name}}». Fortsette økten der du slapp?', + bodyGuest: '{{host}} er vert for «{{name}}». Bli med igjen?', + autoIn: 'Kobler til automatisk om {{seconds}} s', + rejoin: 'Bli med igjen', + stayOut: 'Hold deg utenfor', + reconnecting: 'Kobler til…', + failed: 'Kunne ikke bli med igjen – økten kan være avsluttet.', + }, triggerLabel: 'Orbit', triggerTooltip: 'Start eller bli med i en delt lytteøkt', launchCreate: 'Opprett en økt', diff --git a/src/locales/nl/orbit.ts b/src/locales/nl/orbit.ts index 63553dfa..941dee00 100644 --- a/src/locales/nl/orbit.ts +++ b/src/locales/nl/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: 'Weer in je Orbit-sessie?', + bodyHost: 'Je hostte "{{name}}". De sessie hervatten waar je was gebleven?', + bodyGuest: '{{host}} host "{{name}}". Weer deelnemen?', + autoIn: 'Automatisch over {{seconds}} s', + rejoin: 'Opnieuw deelnemen', + stayOut: 'Erbuiten blijven', + reconnecting: 'Deelnemen…', + failed: 'Kon niet opnieuw deelnemen — de sessie is mogelijk beëindigd.', + }, triggerLabel: 'Orbit', triggerTooltip: 'Een gedeelde luistersessie starten of eraan deelnemen', launchCreate: 'Sessie aanmaken', diff --git a/src/locales/ro/orbit.ts b/src/locales/ro/orbit.ts index deec6dc6..267edf45 100644 --- a/src/locales/ro/orbit.ts +++ b/src/locales/ro/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: 'Reintri în sesiunea Orbit?', + bodyHost: 'Găzduiai „{{name}}". Reiei sesiunea de unde ai rămas?', + bodyGuest: '{{host}} găzduiește „{{name}}". Reintri în sesiune?', + autoIn: 'Reconectare automată în {{seconds}} s', + rejoin: 'Reintră', + stayOut: 'Rămâi afară', + reconnecting: 'Se reconectează…', + failed: 'Nu s-a putut reintra — sesiunea s-ar putea să se fi încheiat.', + }, triggerLabel: 'Orbit', triggerTooltip: 'Pornește sau intră în o sesiune de ascultare distribuită', launchCreate: 'Crează o sesiune', diff --git a/src/locales/ru/orbit.ts b/src/locales/ru/orbit.ts index 20b5c6ca..49d73abb 100644 --- a/src/locales/ru/orbit.ts +++ b/src/locales/ru/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: 'Вернуться в сессию Orbit?', + bodyHost: 'Вы вели «{{name}}». Продолжить с того места, где остановились?', + bodyGuest: '{{host}} ведёт «{{name}}». Снова присоединиться?', + autoIn: 'Автоматически через {{seconds}} с', + rejoin: 'Вернуться', + stayOut: 'Остаться', + reconnecting: 'Подключение…', + failed: 'Не удалось вернуться — возможно, сессия завершена.', + }, triggerLabel: 'Orbit', triggerTooltip: 'Начать или присоединиться к совместной сессии прослушивания', launchCreate: 'Создать сессию', diff --git a/src/locales/zh/orbit.ts b/src/locales/zh/orbit.ts index faa818c7..80c7b002 100644 --- a/src/locales/zh/orbit.ts +++ b/src/locales/zh/orbit.ts @@ -1,4 +1,14 @@ export const orbit = { + reconnect: { + title: '重新加入你的 Orbit 会话?', + bodyHost: '你之前在主持「{{name}}」。从上次离开的地方继续会话吗?', + bodyGuest: '{{host}} 正在主持「{{name}}」。重新加入会话吗?', + autoIn: '{{seconds}} 秒后自动重连', + rejoin: '重新加入', + stayOut: '不加入', + reconnecting: '正在加入…', + failed: '无法重新加入——会话可能已结束。', + }, triggerLabel: 'Orbit', triggerTooltip: '开始或加入共享收听会话', launchCreate: '创建会话', diff --git a/src/styles/components/orbit-session-top-strip.css b/src/styles/components/orbit-session-top-strip.css index cacab945..44e01bfd 100644 --- a/src/styles/components/orbit-session-top-strip.css +++ b/src/styles/components/orbit-session-top-strip.css @@ -400,6 +400,16 @@ .orbit-exit-modal__actions { display: flex; justify-content: center; + gap: 10px; +} + +/* Reconnect prompt — auto-rejoin countdown line above the actions. */ +.orbit-reconnect-countdown { + margin: 0 0 16px; + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + text-align: center; } /* ── Topbar "start Orbit" trigger — same form as Live, accent-tinted ── */
{body}
{t('orbit.reconnect.autoIn', { seconds: secondsLeft })}