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:
@@ -48,6 +48,7 @@ import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
|||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
||||||
import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||||
|
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||||
@@ -842,6 +843,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
|||||||
type CallEmbedProviderProps = {
|
type CallEmbedProviderProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
/** [Gitea #118] Heartbeat + rejoin after a crash/restart; needs the embed container mounted. */
|
||||||
|
function CallRejoin({ callEmbed, joined }: { callEmbed?: CallEmbed; joined: boolean }) {
|
||||||
|
useCallRejoin(callEmbed, joined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||||
const callEmbed = useAtomValue(callEmbedAtom);
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
@@ -1294,6 +1301,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
<CallAnnouncementRegion />
|
<CallAnnouncementRegion />
|
||||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||||
|
<CallRejoin callEmbed={callEmbed} joined={joined} />
|
||||||
{children}
|
{children}
|
||||||
</CallEmbedRefContextProvider>
|
</CallEmbedRefContextProvider>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1651,6 +1651,7 @@ function Calls() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
|
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
||||||
@@ -1946,6 +1947,21 @@ function Calls() {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="After a Restart"
|
||||||
|
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
|
||||||
|
after={
|
||||||
|
<SettingsSelect<'ask' | 'auto' | 'off'>
|
||||||
|
value={callRejoin}
|
||||||
|
onChange={setCallRejoin}
|
||||||
|
options={[
|
||||||
|
{ value: 'ask', label: 'Ask' },
|
||||||
|
{ value: 'auto', label: 'Rejoin automatically' },
|
||||||
|
{ value: 'off', label: 'Do nothing' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Push to Talk"
|
title="Push to Talk"
|
||||||
description="Mute your microphone by default. Hold the PTT key to speak."
|
description="Mute your microphone by default. Hold the PTT key to speak."
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||||
|
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import { ElementWidgetActions } from '../plugins/call/types';
|
||||||
|
import { CallEmbed, useCallControlState, useClientWidgetApiEvent } from '../plugins/call';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useSyncState } from './useSyncState';
|
||||||
|
import { useCallStart } from './useCallEmbed';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||||
|
import { toastQueueAtom, dismissToastAtom } from '../state/toast';
|
||||||
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
import {
|
||||||
|
CALL_SESSION_HEARTBEAT_MS,
|
||||||
|
clearCallSession,
|
||||||
|
decideRejoin,
|
||||||
|
readCallSession,
|
||||||
|
writeCallSession,
|
||||||
|
} from '../utils/callRejoin';
|
||||||
|
|
||||||
|
const TOAST_ID = 'call-rejoin';
|
||||||
|
/** How long after the first sync to wait for the RTC session to report members. */
|
||||||
|
const MEMBERSHIP_WAIT_MS = 10_000;
|
||||||
|
|
||||||
|
const isSynced = (mx: MatrixClient): boolean => {
|
||||||
|
const state = mx.getSyncState();
|
||||||
|
return state === SyncState.Prepared || state === SyncState.Syncing;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #118] Keep a heartbeat record of the voice room you are in, clear
|
||||||
|
* it on a deliberate hangup, and on the next start put you back (or ask)
|
||||||
|
* when the call is still going and the record is fresh.
|
||||||
|
*/
|
||||||
|
export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [mode] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||||
|
const { microphone, video } = useCallControlState(embed?.control);
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const dismissToast = useSetAtom(dismissToastAtom);
|
||||||
|
const directs = useAtomValue(mDirectAtom);
|
||||||
|
const { microphone: prefMic, sound } = useCallPreferences();
|
||||||
|
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||||
|
const startRoomCall = useCallStart(false);
|
||||||
|
const startDmCall = useCallStart(true);
|
||||||
|
|
||||||
|
// --- record while joined
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embed || !joined) return undefined;
|
||||||
|
const deviceId = mx.getDeviceId() ?? '';
|
||||||
|
const write = () =>
|
||||||
|
writeCallSession({
|
||||||
|
roomId: embed.roomId,
|
||||||
|
deviceId,
|
||||||
|
joinedAt: embed.joinedAt ?? Date.now(),
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
microphone,
|
||||||
|
video,
|
||||||
|
});
|
||||||
|
write();
|
||||||
|
const timer = window.setInterval(write, CALL_SESSION_HEARTBEAT_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [embed, joined, mx, microphone, video]);
|
||||||
|
|
||||||
|
// --- deliberate hangup clears it
|
||||||
|
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.HangupCall, clearCallSession);
|
||||||
|
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.Close, clearCallSession);
|
||||||
|
|
||||||
|
// --- startup: offer / perform a rejoin once
|
||||||
|
// The startup effect must run exactly once and outlive re-renders (its
|
||||||
|
// membership wait spans several seconds), so it reads these through a ref.
|
||||||
|
const latest = useRef({
|
||||||
|
mode,
|
||||||
|
prefMic,
|
||||||
|
sound,
|
||||||
|
cameraOnJoin,
|
||||||
|
directs,
|
||||||
|
startRoomCall,
|
||||||
|
startDmCall,
|
||||||
|
});
|
||||||
|
latest.current = { mode, prefMic, sound, cameraOnJoin, directs, startRoomCall, startDmCall };
|
||||||
|
const checkedRef = useRef(false);
|
||||||
|
const [synced, setSynced] = useState(() => isSynced(mx));
|
||||||
|
useSyncState(
|
||||||
|
mx,
|
||||||
|
useCallback(() => {
|
||||||
|
if (isSynced(mx)) setSynced(true);
|
||||||
|
}, [mx]),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait for the first sync so the room and its call memberships are known;
|
||||||
|
// deciding earlier would read an empty session and wrongly drop the record.
|
||||||
|
if (checkedRef.current || embed || !synced) return undefined;
|
||||||
|
checkedRef.current = true;
|
||||||
|
const record = readCallSession();
|
||||||
|
if (!record) return undefined;
|
||||||
|
const room = mx.getRoom(record.roomId);
|
||||||
|
if (!room) {
|
||||||
|
clearCallSession();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const session = mx.matrixRTC.getRoomSession(room);
|
||||||
|
const rejoin = () => {
|
||||||
|
const l = latest.current;
|
||||||
|
const pref = {
|
||||||
|
microphone: record.microphone ?? l.prefMic,
|
||||||
|
video: l.cameraOnJoin && record.video,
|
||||||
|
sound: l.sound,
|
||||||
|
};
|
||||||
|
dismissToast(TOAST_ID);
|
||||||
|
clearCallSession();
|
||||||
|
try {
|
||||||
|
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||||
|
} catch {
|
||||||
|
/* no embed container yet — the user can join from the room */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let done = false;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const evaluate = (final: boolean) => {
|
||||||
|
if (done) return;
|
||||||
|
const memberships = session.memberships;
|
||||||
|
const decision = decideRejoin({
|
||||||
|
record,
|
||||||
|
mode: latest.current.mode,
|
||||||
|
now: Date.now(),
|
||||||
|
myDeviceId: mx.getDeviceId() ?? '',
|
||||||
|
roomJoined: room.getMyMembership() === 'join',
|
||||||
|
ownMemberDevices: memberships
|
||||||
|
.filter((m) => m.userId === mx.getUserId())
|
||||||
|
.map((m) => m.deviceId),
|
||||||
|
memberCount: memberships.length,
|
||||||
|
});
|
||||||
|
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||||
|
// empty unless this is the final (timed-out) look.
|
||||||
|
if (decision.action === 'none' && memberships.length === 0 && !final) return;
|
||||||
|
done = true;
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
|
||||||
|
if (decision.action === 'none') {
|
||||||
|
clearCallSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decision.action === 'auto') {
|
||||||
|
rejoin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToast({
|
||||||
|
id: TOAST_ID,
|
||||||
|
iconSrc: Icons.Phone,
|
||||||
|
displayName: 'Rejoin voice?',
|
||||||
|
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
||||||
|
roomName: room.name ?? '',
|
||||||
|
roomId: room.roomId,
|
||||||
|
sticky: true,
|
||||||
|
onClick: rejoin,
|
||||||
|
onDismiss: clearCallSession,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onChange = () => evaluate(false);
|
||||||
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, onChange);
|
||||||
|
timer = window.setTimeout(() => evaluate(true), MEMBERSHIP_WAIT_MS);
|
||||||
|
evaluate(false);
|
||||||
|
return () => {
|
||||||
|
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [embed, synced, mx, setToast, dismissToast]);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { clearRecentGifs } from './recentGifs';
|
|||||||
import { clearRecentStickers } from './recentStickers';
|
import { clearRecentStickers } from './recentStickers';
|
||||||
import { clearNavToActivePathStore } from './navToActivePath';
|
import { clearNavToActivePathStore } from './navToActivePath';
|
||||||
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
|
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
|
||||||
|
import { clearCallSession } from '../utils/callRejoin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
|
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
|
||||||
@@ -92,6 +93,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
|||||||
clearRecentGifs();
|
clearRecentGifs();
|
||||||
clearRecentStickers();
|
clearRecentStickers();
|
||||||
clearMsgDrafts();
|
clearMsgDrafts();
|
||||||
|
clearCallSession();
|
||||||
clearStatusMessage();
|
clearStatusMessage();
|
||||||
if (userId) clearNavToActivePathStore(userId);
|
if (userId) clearNavToActivePathStore(userId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ export interface Settings {
|
|||||||
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
|
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
|
||||||
// uploads without re-encoding. Default on.
|
// uploads without re-encoding. Default on.
|
||||||
stripImageMetadata: boolean;
|
stripImageMetadata: boolean;
|
||||||
|
// [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing.
|
||||||
|
callRejoinAfterRestart: 'ask' | 'auto' | 'off';
|
||||||
|
|
||||||
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
|
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
|
||||||
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
||||||
@@ -394,6 +396,7 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
stripTrackingParams: true,
|
stripTrackingParams: true,
|
||||||
stripImageMetadata: true,
|
stripImageMetadata: true,
|
||||||
|
callRejoinAfterRestart: 'ask',
|
||||||
|
|
||||||
settingsSync: true,
|
settingsSync: true,
|
||||||
|
|
||||||
|
|||||||
@@ -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