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
88 lines
2.9 KiB
TypeScript
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 };
|
|
}
|