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
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
197 lines
7.0 KiB
TypeScript
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]);
|
|
}
|