Files
cinny/src/app/utils/callRejoin.ts
T
jaredandClaude Opus 5 dc0d524989 feat(calls): rejoin the voice room after a crash, update or reload (#118)
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.

Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:50:57 -04:00

88 lines
2.9 KiB
TypeScript

/**
* [Gitea #118] Remember the voice room you were in so a crash, update or
* reload can put you back. Device-local (localStorage), never synced;
* cleared on a deliberate hangup and on logout.
*/
export const CALL_SESSION_KEY = 'lotus-call-session';
/** A record older than this (by last heartbeat) is stale — the call is over. */
export const CALL_REJOIN_MAX_AGE_MS = 10 * 60_000;
export const CALL_SESSION_HEARTBEAT_MS = 30_000;
export type CallSessionRecord = {
roomId: string;
deviceId: string;
joinedAt: number;
/** Refreshed while joined so a crash leaves a recent timestamp behind. */
lastSeen: number;
microphone: boolean;
video: boolean;
};
export type CallRejoinMode = 'ask' | 'auto' | 'off';
export const readCallSession = (): CallSessionRecord | undefined => {
try {
const raw = localStorage.getItem(CALL_SESSION_KEY);
if (!raw) return undefined;
const r = JSON.parse(raw) as Partial<CallSessionRecord>;
if (typeof r.roomId !== 'string' || typeof r.deviceId !== 'string') return undefined;
return {
roomId: r.roomId,
deviceId: r.deviceId,
joinedAt: Number(r.joinedAt) || 0,
lastSeen: Number(r.lastSeen) || 0,
microphone: r.microphone !== false,
video: r.video === true,
};
} catch {
return undefined;
}
};
export const writeCallSession = (record: CallSessionRecord): void => {
try {
localStorage.setItem(CALL_SESSION_KEY, JSON.stringify(record));
} catch {
/* quota / private mode */
}
};
export const clearCallSession = (): void => {
try {
localStorage.removeItem(CALL_SESSION_KEY);
} catch {
/* ignore */
}
};
export type RejoinContext = {
record: CallSessionRecord | undefined;
mode: CallRejoinMode;
now: number;
myDeviceId: string;
/** Whether the room still exists and we are joined to it. */
roomJoined: boolean;
/** Device ids of our own live call memberships in that room (other tabs/devices). */
ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */
memberCount: number;
};
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
/** Pure: whether startup should offer (or perform) a rejoin. */
export function decideRejoin(ctx: RejoinContext): RejoinDecision {
const { record, mode, now, myDeviceId } = ctx;
if (!record || mode === 'off') return { action: 'none' };
if (record.deviceId !== myDeviceId) return { action: 'none' };
if (now - record.lastSeen > CALL_REJOIN_MAX_AGE_MS) return { action: 'none' };
if (!ctx.roomJoined) return { action: 'none' };
// Already back in from another device/tab — don't double-join.
if (ctx.ownMemberDevices.some((d) => d !== myDeviceId)) return { action: 'none' };
// Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' };
return { action: mode, roomId: record.roomId };
}