129 lines
4.9 KiB
TypeScript
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;
|
|
};
|