feat(orbit): prompt to rejoin a session after an app restart

On launch, if a breadcrumb points at a still-alive session on the active
server, a modal offers a one-click rejoin with a 30s auto-rejoin countdown
(host resumes hosting, guest rejoins via the normal path). Declining, Escape,
or a failed attempt wipes the breadcrumb. Copy added across all nine locales.
This commit is contained in:
Psychotoxical
2026-06-07 22:28:45 +02:00
parent 2c95a3499c
commit 0de488335e
12 changed files with 254 additions and 0 deletions
+2
View File
@@ -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() {
<ThemeMigrationNotice />
<OrbitAccountPicker />
<OrbitHelpModal />
<OrbitReconnectModal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater />
</div>
+152
View File
@@ -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<OrbitLastSession | null>(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(
<div
className="modal-overlay orbit-exit-overlay"
onClick={e => { if (e.target === e.currentTarget) stayOut(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-reconnect-title"
>
<div className="modal-content orbit-exit-modal">
<h3 id="orbit-reconnect-title" className="orbit-exit-modal__title">{t('orbit.reconnect.title')}</h3>
<p className="orbit-exit-modal__body">{body}</p>
{!busy && (
<p className="orbit-reconnect-countdown">{t('orbit.reconnect.autoIn', { seconds: secondsLeft })}</p>
)}
<div className="orbit-exit-modal__actions">
<button type="button" className="btn btn-ghost" onClick={stayOut} disabled={busy}>
{t('orbit.reconnect.stayOut')}
</button>
<button type="button" className="btn btn-primary" onClick={() => void doReconnect()} disabled={busy} autoFocus>
{busy ? t('orbit.reconnect.reconnecting') : t('orbit.reconnect.rejoin')}
</button>
</div>
</div>
</div>,
document.body,
);
}
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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: 'Создать сессию',
+10
View File
@@ -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: '创建会话',
@@ -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 ── */