90 lines
3.5 KiB
TypeScript
90 lines
3.5 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));
|
|
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);
|
|
}
|
|
}
|
|
} 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]);
|
|
};
|