Files
cinny/src/app/hooks/useCallRejoin.ts
T
Lotus CIandClaude Opus 5.5 0a2f18e3d5
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 19s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m35s
fix(calls): DM-aware rejoin toast, and no rejoining a DM call that ended (#302)
After a reload during a DM call the toast said "You were in the call in
Bob Builder"; it now says "You were in a call with Bob Builder". DM
calls are never auto-rejoined (it could ring the other person), and if
they have left by the time you tap, the toast says "Call ended" instead
of starting a new, ringing call.

Fixes #302

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:20:12 -04:00

197 lines
7.0 KiB
TypeScript

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,
othersInCall,
rejoinToastBody,
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();
// [Gitea #302] A DM call the other person has since left is over;
// starting it again would ring them, so just say so.
const dm = l.directs.has(room.roomId);
if (
dm &&
!othersInCall(
session.memberships.map((m) => m.userId ?? ''),
mx.getUserId() ?? '',
)
) {
// Own id: clicking a toast removes it by id after its onClick runs.
setToast({
id: `${TOAST_ID}-ended`,
iconSrc: Icons.Phone,
displayName: 'Call ended',
body: `${room.name ?? 'They'} already left the call.`,
roomName: room.name ?? '',
roomId: room.roomId,
});
return;
}
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,
dm: latest.current.directs.has(room.roomId),
});
// 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: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
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]);
}