import { useEffect } from 'react'; import { App } from '@capacitor/app'; import { isNativePlatform } from '../utils/capacitor'; import { useMatrixClient } from './useMatrixClient'; const PENDING_PREFIX = 'vojo.pendingDeclines.'; // Drains the tombstones CallDeclineReceiver leaves behind when its HTTP PUT // fails (network drop, 4xx/5xx, process killed mid-request). Matches the // native-side contract in CallDeclineReceiver.java — the receiver writes the // tombstone BEFORE the PUT and removes it only on 2xx. // // Runs on mount (covers "app opened after a failed receiver path") and on // Capacitor `App` resume (covers "receiver ran while app was paused, HTTP // died, user unlocks and brings app to foreground"). // // Dedup: the receiver generates its own txnId; retries here generate another // via matrix-js-sdk's sendRtcDecline → cosmetic chance of two decline events // in the timeline if the receiver actually succeeded but the process was // killed before clearing the tombstone. A-side auto-hangup is idempotent on // the first decline, so this is timeline-clutter only. Fixing requires // plumbing txnId through prefs, skipped for MVP. export const usePendingDeclinesFlusher = (): void => { const mx = useMatrixClient(); useEffect(() => { if (!isNativePlatform()) return undefined; let disposed = false; let inFlight = false; let resumeHandle: { remove: () => Promise } | undefined; // Mount + an immediate `resume` (e.g. receiver fired, app foregrounded // right after the hook remounts) can both call drain() before the first // run finishes its per-key `get → sendRtcDecline → remove` loop. Without // this lock both passes observe the same tombstone and fire parallel // sendRtcDecline requests — benign at the server (idempotent on the // rel event_id) but it doubles the timeline noise. const drain = async () => { if (inFlight) return; inFlight = true; try { const { Preferences } = await import('@capacitor/preferences'); const { keys } = await Preferences.keys(); const tombstones = keys.filter((k) => k.startsWith(PENDING_PREFIX)); // Sequential drain is intentional — see the race-condition rationale // in the comment above the hook (paralleling sendRtcDecline doubles // timeline noise on the A-side). Don't convert to Promise.all. /* eslint-disable no-restricted-syntax, no-await-in-loop, no-continue */ for (const key of tombstones) { if (disposed) return; const notifEventId = key.slice(PENDING_PREFIX.length); try { const { value } = await Preferences.get({ key }); if (!value) { await Preferences.remove({ key }); continue; } const parsed = JSON.parse(value) as { roomId?: string }; if (!parsed.roomId || !notifEventId) { await Preferences.remove({ key }); continue; } await mx.sendRtcDecline(parsed.roomId, notifEventId); await Preferences.remove({ key }); } catch (err) { // Leave tombstone for the next resume — could be 401 (token // rotated) or transient network. Don't spam logs on every resume. // eslint-disable-next-line no-console console.warn('[call] pendingDeclines flush failed', notifEventId, err); } } /* eslint-enable no-restricted-syntax, no-await-in-loop, no-continue */ } catch { /* preferences import failed — non-fatal */ } finally { inFlight = false; } }; drain(); App.addListener('resume', () => { drain(); }).then((h) => { if (disposed) h.remove(); else resumeHandle = h; }); return () => { disposed = true; resumeHandle?.remove(); }; }, [mx]); };