From 2a24ee60ff9081d681512aeeff6c51c56970c0c9 Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 28 May 2026 17:22:59 +0300 Subject: [PATCH] fix(presence): gate /sync set_presence on app foreground state so background long-polls stop marking idle users as online --- src/app/hooks/useAppActive.ts | 129 ++++++++++++++++++++ src/app/hooks/useSyncPresenceGate.ts | 71 +++++++++++ src/app/pages/IncomingCallStripRenderer.tsx | 60 ++------- src/app/pages/client/ClientRoot.tsx | 8 ++ src/app/pages/client/SyncIndicator.tsx | 71 ++++------- src/app/utils/capacitor.ts | 2 + src/client/initMatrix.ts | 11 ++ 7 files changed, 251 insertions(+), 101 deletions(-) create mode 100644 src/app/hooks/useAppActive.ts create mode 100644 src/app/hooks/useSyncPresenceGate.ts diff --git a/src/app/hooks/useAppActive.ts b/src/app/hooks/useAppActive.ts new file mode 100644 index 00000000..727c217b --- /dev/null +++ b/src/app/hooks/useAppActive.ts @@ -0,0 +1,129 @@ +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; +}; diff --git a/src/app/hooks/useSyncPresenceGate.ts b/src/app/hooks/useSyncPresenceGate.ts new file mode 100644 index 00000000..9d01590e --- /dev/null +++ b/src/app/hooks/useSyncPresenceGate.ts @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { ClientEvent, MatrixClient, SetPresence, SyncState } from 'matrix-js-sdk'; +import { useAppActive } from './useAppActive'; + +// Drives the `set_presence` query parameter on long-poll /sync so that +// Synapse stops marking the user as "active right now" whenever a +// background tab or backgrounded Android process keeps the sync loop +// running. Without this, every long-poll /sync — and there is one +// in-flight at all times — implicitly bumps `last_active_ago` to ~0 on +// the homeserver. Peer clients then read that via m.presence and render +// "был в сети только что" for a user whose phone has been in their +// pocket for hours. +// +// The matrix-js-sdk default is `presence === undefined`, which omits +// `set_presence` from the sync URL entirely — Synapse treats the +// missing param as `online`. By calling `setSyncPresence(Online)` +// whenever sync is ready and toggling to `Unavailable` whenever the app +// goes to background, we make the server-side `last_active_ago` +// actually reflect foreground activity instead of the JS sync loop's +// heartbeat. +// +// We deliberately do NOT call `setSyncPresence(Offline)` on cleanup: +// `Offline` means "I'm logged out" in Matrix-presence semantics, and +// the hook unmount on logout already triggers the explicit PUT +// /presence handled by `logoutClient`. A spurious Offline emit on +// transient hook remount would briefly take the user offline for peers. +// +// Foreground/background signal comes from `useAppActive`, which is the +// shared lifecycle hook that consolidates Capacitor App.pause / resume +// (Android + iOS) and App.appStateChange (web). +export const useSyncPresenceGate = (mx: MatrixClient | undefined): void => { + const active = useAppActive(); + + useEffect(() => { + if (!mx) return undefined; + + const desired = active ? SetPresence.Online : SetPresence.Unavailable; + mx.setSyncPresence(desired); + + // The very first /sync that starts the long-poll loop will almost + // always go out with our `desired` value already missed — by the + // time this effect runs, `startClient` may not yet have constructed + // `syncApi` (`mx.setSyncPresence` no-ops when `this.syncApi` is + // undefined), and `Prepared/Syncing` aren't emitted until the + // initial /sync response lands. We accept that: the cold-start + // user IS actively launching the app, so one bumped + // `last_active_ts` is honest. What this hook prevents is the + // *background long-poll loop* lying about activity for hours after. + // + // The Sync latch below replays `desired` once the SDK is past + // PREPARED, which guarantees that the FIRST long-poll /sync (the + // one that runs after the initial response) carries the right + // `set_presence`. For a cold-start launched into a hidden tab, + // this means a single Online ping then immediate Unavailable on + // the very next sync — acceptable noise vs the previous "always + // online for as long as the tab is open" behaviour. + let primed = false; + const onSync = (state: SyncState) => { + if (primed) return; + if (state === SyncState.Prepared || state === SyncState.Syncing) { + primed = true; + mx.setSyncPresence(desired); + } + }; + mx.on(ClientEvent.Sync, onSync); + + return () => { + mx.removeListener(ClientEvent.Sync, onSync); + }; + }, [mx, active]); +}; diff --git a/src/app/pages/IncomingCallStripRenderer.tsx b/src/app/pages/IncomingCallStripRenderer.tsx index 272b5048..e2f8648e 100644 --- a/src/app/pages/IncomingCallStripRenderer.tsx +++ b/src/app/pages/IncomingCallStripRenderer.tsx @@ -20,11 +20,11 @@ // miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) // is Phase 3 polish. -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useAtomValue } from 'jotai'; -import { App } from '@capacitor/app'; import { incomingCallsAtom } from '../state/incomingCalls'; import { useMatrixClient } from '../hooks/useMatrixClient'; +import { useAppActive } from '../hooks/useAppActive'; import { IncomingCallStrip } from '../features/call-status'; import { getIncomingCallKey } from '../utils/rtcNotification'; import { isAndroidPlatform } from '../utils/capacitor'; @@ -37,59 +37,15 @@ export function IncomingCallStripRenderer() { const mx = useMatrixClient(); const incoming = useAtomValue(incomingCallsAtom); const audioRef = useRef(null); - const [appActive, setAppActive] = useState( - () => typeof document === 'undefined' || document.visibilityState === 'visible' - ); + // Foreground/background signal — shared `useAppActive` registers a + // single Capacitor App.pause/resume listener (Android+iOS) and + // App.appStateChange (web). Used only to gate Android ring audio + // below; iOS / web never read the value because they have no native + // ring surface. + const appActive = useAppActive(); const hasIncoming = incoming.size > 0; - useEffect(() => { - let cancelled = false; - // Android: pause/resume fire on BridgeActivity.onPause/onResume via - // AppPlugin.handleOnPause/Resume — same Activity callbacks that flip - // MainActivity.isInForeground — reaching JS within ms via the WebView - // bridge. appStateChange on Android only fires in onStop (tens of ms to - // ~1s after onPause), which would let JS audio race the native ringtone - // in the "just backgrounded" window and double-ring. - // iOS/web keeps appStateChange because there it maps to - // willResignActive/didBecomeActive (iOS) or document.visibilitychange - // (web). The Capacitor pause/resume events on iOS bind to - // didEnterBackground/willEnterForeground — a different edge. - // Race-guard: a real lifecycle event arriving before getState() resolves - // must win over the stale snapshot. - let sawLifecycleEvent = false; - const handles: Array<{ remove: () => void }> = []; - const track = (h: { remove: () => void }) => { - if (cancelled) h.remove(); - else handles.push(h); - }; - - const apply = (isActive: boolean) => { - sawLifecycleEvent = true; - if (!cancelled) setAppActive(isActive); - }; - - if (isAndroidPlatform()) { - App.addListener('pause', () => apply(false)).then(track); - App.addListener('resume', () => apply(true)).then(track); - } else { - App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track); - } - - App.getState() - .then((state) => { - if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive); - }) - .catch(() => { - /* web fallback handled by initial document.visibilityState */ - }); - - return () => { - cancelled = true; - handles.forEach((h) => h.remove()); - }; - }, []); - useEffect(() => { const audio = audioRef.current; if (!audio) return; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index af9bce10..9b106796 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -10,6 +10,7 @@ import { MatrixClientProvider } from '../../hooks/useMatrixClient'; import { SpecVersions } from './SpecVersions'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; +import { useSyncPresenceGate } from '../../hooks/useSyncPresenceGate'; import { SyncIndicator } from './SyncIndicator'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; @@ -76,6 +77,13 @@ export function ClientRoot({ children }: ClientRootProps) { ); useLogoutListener(mx); + // Mark the user as Online during foreground and Unavailable while the + // tab is hidden / app is backgrounded, so background long-poll /sync + // calls stop fraudulently bumping last_active_ago on the homeserver. + // Without this every other Vojo client renders "был в сети только что" + // for a peer whose phone has been sleeping for hours — see + // useSyncPresenceGate.ts for the full rationale. + useSyncPresenceGate(mx); useEffect(() => { if (loadState.status === AsyncStatus.Idle) { diff --git a/src/app/pages/client/SyncIndicator.tsx b/src/app/pages/client/SyncIndicator.tsx index 5ae198c6..f8b01f40 100644 --- a/src/app/pages/client/SyncIndicator.tsx +++ b/src/app/pages/client/SyncIndicator.tsx @@ -1,7 +1,6 @@ -import React, { useEffect, useState } from 'react'; -import { App } from '@capacitor/app'; +import React, { useEffect, useRef, useState } from 'react'; import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk'; -import { isAndroidPlatform } from '../../utils/capacitor'; +import { useAppActive } from '../../hooks/useAppActive'; import * as css from './SyncIndicator.css'; // Suppress sub-100ms green flashes during fast transient state cycles @@ -88,52 +87,27 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) { // during the throttled JS window, lands on Error, and the user sees a // ~5s red flash on unlock before the SDK's keep-alive jitter recovers. // - // Listener split mirrors IncomingCallStripRenderer.tsx: Android pause/ - // resume fire on Activity onPause/onResume (ms latency), while - // appStateChange ties to onStop (10–1000ms after onPause) which is too - // late. iOS/web have no `pause` event — `appStateChange` is the canonical - // signal there. + // Foreground/background signal comes from the shared `useAppActive` + // hook (Android+iOS use pause/resume for ms latency; web uses + // appStateChange/visibilitychange). + const active = useAppActive(); + const hiddenAtRef = useRef(null); useEffect(() => { - let cancelled = false; - const handles: Array<{ remove: () => void }> = []; - const track = (h: { remove: () => void }) => { - if (cancelled) h.remove(); - else handles.push(h); - }; - - let hiddenAt: number | null = null; - - const onActiveChange = (isActive: boolean) => { - if (!isActive) { - hiddenAt = performance.now(); - return; - } - if (hiddenAt === null) return; - const gap = performance.now() - hiddenAt; - hiddenAt = null; - if (gap < RESUME_HIDDEN_THRESHOLD_MS) return; - setResumeGraceUntil(performance.now() + RESUME_GRACE_MS); - // retryImmediately is idempotent: a no-op when the SDK isn't waiting - // on a scheduled retry (i.e. healthy Syncing). Cheap to call. - if (mx.getSyncState() !== SyncState.Syncing) { - mx.retryImmediately(); - } - }; - - if (isAndroidPlatform()) { - App.addListener('pause', () => onActiveChange(false)).then(track); - App.addListener('resume', () => onActiveChange(true)).then(track); - } else { - App.addListener('appStateChange', ({ isActive }) => - onActiveChange(isActive) - ).then(track); + if (!active) { + hiddenAtRef.current = performance.now(); + return; } - - return () => { - cancelled = true; - handles.forEach((h) => h.remove()); - }; - }, [mx]); + if (hiddenAtRef.current === null) return; + const gap = performance.now() - hiddenAtRef.current; + hiddenAtRef.current = null; + if (gap < RESUME_HIDDEN_THRESHOLD_MS) return; + setResumeGraceUntil(performance.now() + RESUME_GRACE_MS); + // retryImmediately is idempotent: a no-op when the SDK isn't waiting + // on a scheduled retry (i.e. healthy Syncing). Cheap to call. + if (mx.getSyncState() !== SyncState.Syncing) { + mx.retryImmediately(); + } + }, [active, mx]); // Auto-clear the grace window when its timer expires. Skipped if the // first-Syncing handler above already cleared it. @@ -151,8 +125,7 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) { const baseVisual = stateToVisual(syncState); // Grace window only overrides Error → progress. Other transitions // (Syncing→hidden, etc) fire normally — the user's recovery is real. - const visual: Visual = - resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual; + const visual: Visual = resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual; // Three logical phases for the green progress bar (mirrors the bot // widget loading bar): diff --git a/src/app/utils/capacitor.ts b/src/app/utils/capacitor.ts index cbf4c46b..5489efa5 100644 --- a/src/app/utils/capacitor.ts +++ b/src/app/utils/capacitor.ts @@ -5,6 +5,8 @@ export const isNativePlatform = (): boolean => Capacitor.isNativePlatform(); export const isAndroidPlatform = (): boolean => Capacitor.getPlatform() === 'android'; +export const isIOSPlatform = (): boolean => Capacitor.getPlatform() === 'ios'; + export const openExternalUrl = async (url: string): Promise => { if (isNativePlatform()) { await Browser.open({ url }); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 54504290..a620f41b 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -116,6 +116,17 @@ export const logoutClient = async (mx: MatrixClient) => { // can't resurrect the old access_token via CallDeclineReceiver. await clearSessionBridge(); + // Tell the homeserver we're going offline before stopClient/logout + // tears down the access_token. Without this PUT, Synapse waits ~5 + // minutes for its own idle timer to flip the user Offline; peers see + // a stale "был в сети только что" during that gap. Best-effort — + // logout must complete even if this fails. + try { + await mx.setPresence({ presence: 'offline' }); + } catch { + // ignore — server will idle-timeout the presence eventually. + } + mx.stopClient(); try { await mx.logout();