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(); let nativeHandles: Array<{ remove: () => void }> = []; let bootstrap: Promise | 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 => { 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 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(() => currentActive ?? readInitialActive()); useEffect(() => subscribe(setActive), []); return active; };