fix(presence): gate /sync set_presence on app foreground state so background long-polls stop marking idle users as online

This commit is contained in:
heaven 2026-05-28 17:22:59 +03:00
parent 9a9880d63c
commit 2a24ee60ff
7 changed files with 251 additions and 101 deletions

View file

@ -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<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;
};

View file

@ -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]);
};

View file

@ -20,11 +20,11 @@
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) // miss it. Fallback (click-to-enable, pulsing animation, Web Notifications)
// is Phase 3 polish. // is Phase 3 polish.
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { App } from '@capacitor/app';
import { incomingCallsAtom } from '../state/incomingCalls'; import { incomingCallsAtom } from '../state/incomingCalls';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAppActive } from '../hooks/useAppActive';
import { IncomingCallStrip } from '../features/call-status'; import { IncomingCallStrip } from '../features/call-status';
import { getIncomingCallKey } from '../utils/rtcNotification'; import { getIncomingCallKey } from '../utils/rtcNotification';
import { isAndroidPlatform } from '../utils/capacitor'; import { isAndroidPlatform } from '../utils/capacitor';
@ -37,59 +37,15 @@ export function IncomingCallStripRenderer() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const incoming = useAtomValue(incomingCallsAtom); const incoming = useAtomValue(incomingCallsAtom);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [appActive, setAppActive] = useState( // Foreground/background signal — shared `useAppActive` registers a
() => typeof document === 'undefined' || document.visibilityState === 'visible' // 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; 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(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;

View file

@ -10,6 +10,7 @@ import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { SpecVersions } from './SpecVersions'; import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState'; import { useSyncState } from '../../hooks/useSyncState';
import { useSyncPresenceGate } from '../../hooks/useSyncPresenceGate';
import { SyncIndicator } from './SyncIndicator'; import { SyncIndicator } from './SyncIndicator';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions'; import { getFallbackSession } from '../../state/sessions';
@ -76,6 +77,13 @@ export function ClientRoot({ children }: ClientRootProps) {
); );
useLogoutListener(mx); 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(() => { useEffect(() => {
if (loadState.status === AsyncStatus.Idle) { if (loadState.status === AsyncStatus.Idle) {

View file

@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { App } from '@capacitor/app';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk'; import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
import { isAndroidPlatform } from '../../utils/capacitor'; import { useAppActive } from '../../hooks/useAppActive';
import * as css from './SyncIndicator.css'; import * as css from './SyncIndicator.css';
// Suppress sub-100ms green flashes during fast transient state cycles // Suppress sub-100ms green flashes during fast transient state cycles
@ -88,29 +87,19 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
// during the throttled JS window, lands on Error, and the user sees a // 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. // ~5s red flash on unlock before the SDK's keep-alive jitter recovers.
// //
// Listener split mirrors IncomingCallStripRenderer.tsx: Android pause/ // Foreground/background signal comes from the shared `useAppActive`
// resume fire on Activity onPause/onResume (ms latency), while // hook (Android+iOS use pause/resume for ms latency; web uses
// appStateChange ties to onStop (101000ms after onPause) which is too // appStateChange/visibilitychange).
// late. iOS/web have no `pause` event — `appStateChange` is the canonical const active = useAppActive();
// signal there. const hiddenAtRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; if (!active) {
const handles: Array<{ remove: () => void }> = []; hiddenAtRef.current = performance.now();
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; return;
} }
if (hiddenAt === null) return; if (hiddenAtRef.current === null) return;
const gap = performance.now() - hiddenAt; const gap = performance.now() - hiddenAtRef.current;
hiddenAt = null; hiddenAtRef.current = null;
if (gap < RESUME_HIDDEN_THRESHOLD_MS) return; if (gap < RESUME_HIDDEN_THRESHOLD_MS) return;
setResumeGraceUntil(performance.now() + RESUME_GRACE_MS); setResumeGraceUntil(performance.now() + RESUME_GRACE_MS);
// retryImmediately is idempotent: a no-op when the SDK isn't waiting // retryImmediately is idempotent: a no-op when the SDK isn't waiting
@ -118,22 +107,7 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
if (mx.getSyncState() !== SyncState.Syncing) { if (mx.getSyncState() !== SyncState.Syncing) {
mx.retryImmediately(); mx.retryImmediately();
} }
}; }, [active, mx]);
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);
}
return () => {
cancelled = true;
handles.forEach((h) => h.remove());
};
}, [mx]);
// Auto-clear the grace window when its timer expires. Skipped if the // Auto-clear the grace window when its timer expires. Skipped if the
// first-Syncing handler above already cleared it. // first-Syncing handler above already cleared it.
@ -151,8 +125,7 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
const baseVisual = stateToVisual(syncState); const baseVisual = stateToVisual(syncState);
// Grace window only overrides Error → progress. Other transitions // Grace window only overrides Error → progress. Other transitions
// (Syncing→hidden, etc) fire normally — the user's recovery is real. // (Syncing→hidden, etc) fire normally — the user's recovery is real.
const visual: Visual = const visual: Visual = resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;
resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;
// Three logical phases for the green progress bar (mirrors the bot // Three logical phases for the green progress bar (mirrors the bot
// widget loading bar): // widget loading bar):

View file

@ -5,6 +5,8 @@ export const isNativePlatform = (): boolean => Capacitor.isNativePlatform();
export const isAndroidPlatform = (): boolean => Capacitor.getPlatform() === 'android'; export const isAndroidPlatform = (): boolean => Capacitor.getPlatform() === 'android';
export const isIOSPlatform = (): boolean => Capacitor.getPlatform() === 'ios';
export const openExternalUrl = async (url: string): Promise<void> => { export const openExternalUrl = async (url: string): Promise<void> => {
if (isNativePlatform()) { if (isNativePlatform()) {
await Browser.open({ url }); await Browser.open({ url });

View file

@ -116,6 +116,17 @@ export const logoutClient = async (mx: MatrixClient) => {
// can't resurrect the old access_token via CallDeclineReceiver. // can't resurrect the old access_token via CallDeclineReceiver.
await clearSessionBridge(); 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(); mx.stopClient();
try { try {
await mx.logout(); await mx.logout();