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]); }