vojo/src/app/hooks/usePendingDeclinesFlusher.ts

95 lines
3.8 KiB
TypeScript

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