vojo/src/app/hooks/useAppActive.ts

129 lines
4.9 KiB
TypeScript

import { useEffect, useState } from 'react';
import { App } from '@capacitor/app';
import { isAndroidPlatform, isIOSPlatform } from '../utils/capacitor';
// Single source of truth for "is the app foreground / active right
// now". Consolidates the App.pause / App.resume / App.appStateChange
// listeners that previously lived independently in `SyncIndicator`,
// `IncomingCallStripRenderer`, and `useSyncPresenceGate` — each used
// to register its own native bridge listener, so a single
// pause/resume event was reaching JS three times.
//
// Implementation: a module-level singleton owns one set of Capacitor
// listeners. React consumers subscribe via the `useAppActive` hook;
// when the subscriber count drops to zero, the native listeners are
// torn down. This keeps the WebView↔native bridge quiet while still
// letting any number of React components observe the active state.
//
// Platform branching:
// - Android: `App.pause` / `App.resume` fire on Activity onPause /
// onResume (ms latency). `appStateChange` ties to onStop which is
// 10-1000 ms later — too slow for ring-audio handoff and noisy
// for /sync set_presence updates.
// - iOS: same `pause` / `resume` pair binds to
// didEnterBackground / willEnterForeground. We deliberately do
// NOT use `appStateChange` on iOS because that maps to
// willResignActive — which fires for transient interruptions
// (Control Center swipe, app-switcher peek, incoming call
// prompt). For presence/sync purposes those interruptions are
// noise; we want only real backgrounding to flip state.
// - Web: no `pause` / `resume` equivalent — fall back to
// `appStateChange`, which Capacitor maps to
// `document.visibilitychange`.
type ActiveListener = (active: boolean) => void;
let currentActive: boolean | null = null;
const subscribers = new Set<ActiveListener>();
let nativeHandles: Array<{ remove: () => void }> = [];
let bootstrap: Promise<void> | null = null;
const readInitialActive = (): boolean => {
if (typeof document === 'undefined') return true;
return document.visibilityState !== 'hidden';
};
const broadcast = (next: boolean) => {
if (currentActive === next) return;
currentActive = next;
subscribers.forEach((cb) => cb(next));
};
const ensureNativeListeners = async (): Promise<void> => {
if (bootstrap) return bootstrap;
bootstrap = (async () => {
// Seed before listeners attach so an immediately-subscribed
// consumer can read a meaningful value via the initial useState
// initializer; the async getState below corrects it on native.
if (currentActive === null) currentActive = readInitialActive();
const sawLifecycleEventRef = { current: false };
const apply = (isActive: boolean) => {
sawLifecycleEventRef.current = true;
broadcast(isActive);
};
const pendingAttach: Array<Promise<{ remove: () => void }>> = [];
if (isAndroidPlatform() || isIOSPlatform()) {
pendingAttach.push(App.addListener('pause', () => apply(false)));
pendingAttach.push(App.addListener('resume', () => apply(true)));
} else {
pendingAttach.push(App.addListener('appStateChange', ({ isActive }) => apply(isActive)));
}
try {
nativeHandles = await Promise.all(pendingAttach);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[useAppActive] native listener registration failed', err);
nativeHandles = [];
}
// Authoritative initial value from native, but only if no real
// lifecycle event has landed in the meantime — events always win
// over a stale snapshot (mirrors the pattern that used to live
// inline in IncomingCallStripRenderer).
try {
const state = await App.getState();
if (!sawLifecycleEventRef.current) broadcast(state.isActive);
} catch {
// Web has no native getState; document.visibilityState seed
// above is enough.
}
})();
return bootstrap;
};
const teardownNativeListeners = () => {
nativeHandles.forEach((h) => h.remove());
nativeHandles = [];
bootstrap = null;
currentActive = null;
};
const subscribe = (cb: ActiveListener): (() => void) => {
subscribers.add(cb);
ensureNativeListeners().catch(() => {
// already logged inside ensureNativeListeners
});
// Hand the current value to a brand-new subscriber so it doesn't
// need to wait for the next lifecycle event.
if (currentActive !== null) cb(currentActive);
return () => {
subscribers.delete(cb);
if (subscribers.size === 0) teardownNativeListeners();
};
};
/**
* Returns `true` while the app is foregrounded / the tab is visible,
* `false` while backgrounded / hidden. See module header for the
* platform-event mapping.
*/
export const useAppActive = (): boolean => {
const [active, setActive] = useState<boolean>(() => currentActive ?? readInitialActive());
useEffect(() => subscribe(setActive), []);
return active;
};