diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 35274a9a9..f4a61c1dd 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -48,6 +48,7 @@ import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast'; import { useCallEndedToast } from '../hooks/useCallEndedToast'; +import { useCallRejoin } from '../hooks/useCallRejoin'; import { useCallAnnouncements } from '../hooks/useCallAnnouncements'; import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning'; import { callAnnouncementAtom } from '../state/callAnnouncement'; @@ -842,6 +843,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) { type CallEmbedProviderProps = { 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) { const callEmbed = useAtomValue(callEmbedAtom); const callEmbedRef = useRef(null) as React.RefObject; @@ -1294,6 +1301,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { + {children}
+ + value={callRejoin} + onChange={setCallRejoin} + options={[ + { value: 'ask', label: 'Ask' }, + { value: 'auto', label: 'Rejoin automatically' }, + { value: 'off', label: 'Do nothing' }, + ]} + /> + } + /> { + 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]); +} diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index 45b6e04f3..8efa97e3a 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -6,6 +6,7 @@ import { clearRecentGifs } from './recentGifs'; import { clearRecentStickers } from './recentStickers'; import { clearNavToActivePathStore } from './navToActivePath'; import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft'; +import { clearCallSession } from '../utils/callRejoin'; /** * [Gitea #41] Wipe every persisted composer draft (`draft-msg-`). Drafts @@ -92,6 +93,7 @@ export const clearPlaintextCaches = (userId?: string): void => { clearRecentGifs(); clearRecentStickers(); clearMsgDrafts(); + clearCallSession(); clearStatusMessage(); if (userId) clearNavToActivePathStore(userId); }; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index a747f56af..522dc2771 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -271,6 +271,8 @@ export interface Settings { // [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP // uploads without re-encoding. Default on. 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 // so other devices pick them up. Device-local itself (utils/settingsSync). @@ -394,6 +396,7 @@ const defaultSettings: Settings = { stripTrackingParams: true, stripImageMetadata: true, + callRejoinAfterRestart: 'ask', settingsSync: true, diff --git a/src/app/utils/callRejoin.test.ts b/src/app/utils/callRejoin.test.ts new file mode 100644 index 000000000..9f5eb62fd --- /dev/null +++ b/src/app/utils/callRejoin.test.ts @@ -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', + }); + }); +}); diff --git a/src/app/utils/callRejoin.ts b/src/app/utils/callRejoin.ts new file mode 100644 index 000000000..93cfb365b --- /dev/null +++ b/src/app/utils/callRejoin.ts @@ -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; + 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 }; +}