/** * [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; 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 }; }