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
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
|
||||
|
||||
const base: RejoinContext = {
|
||||
record: {
|
||||
roomId: '!r',
|
||||
deviceId: 'DEV',
|
||||
joinedAt: 900_000,
|
||||
lastSeen: 1_000_000,
|
||||
microphone: true,
|
||||
video: false,
|
||||
},
|
||||
mode: 'ask',
|
||||
now: 1_060_000,
|
||||
myDeviceId: 'DEV',
|
||||
roomJoined: true,
|
||||
ownMemberDevices: [],
|
||||
memberCount: 2,
|
||||
};
|
||||
|
||||
describe('decideRejoin', () => {
|
||||
it('asks by default when the call is still going', () => {
|
||||
assert.deepEqual(decideRejoin(base), { action: 'ask', roomId: '!r' });
|
||||
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
|
||||
});
|
||||
|
||||
it('does nothing when off, stale, another device, or no record', () => {
|
||||
assert.deepEqual(decideRejoin({ ...base, mode: 'off' }), { action: 'none' });
|
||||
assert.deepEqual(decideRejoin({ ...base, record: undefined }), { action: 'none' });
|
||||
assert.deepEqual(decideRejoin({ ...base, now: 1_000_000 + CALL_REJOIN_MAX_AGE_MS + 1 }), {
|
||||
action: 'none',
|
||||
});
|
||||
assert.deepEqual(decideRejoin({ ...base, myDeviceId: 'OTHER' }), { action: 'none' });
|
||||
assert.deepEqual(decideRejoin({ ...base, roomJoined: false }), { action: 'none' });
|
||||
});
|
||||
|
||||
it('skips when we already rejoined from another device', () => {
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['PHONE'], memberCount: 2 }), {
|
||||
action: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when only our own stale membership is left', () => {
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 1 }), {
|
||||
action: 'none',
|
||||
});
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: [], memberCount: 0 }), {
|
||||
action: 'none',
|
||||
});
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 2 }), {
|
||||
action: 'ask',
|
||||
roomId: '!r',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* [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 };
|
||||
}
|
||||
Reference in New Issue
Block a user