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,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]);
|
||||
}
|
||||
Reference in New Issue
Block a user