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:
parent
9a9880d63c
commit
2a24ee60ff
7 changed files with 251 additions and 101 deletions
129
src/app/hooks/useAppActive.ts
Normal file
129
src/app/hooks/useAppActive.ts
Normal 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;
|
||||||
|
};
|
||||||
71
src/app/hooks/useSyncPresenceGate.ts
Normal file
71
src/app/hooks/useSyncPresenceGate.ts
Normal 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]);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,52 +87,27 @@ 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 (10–1000ms 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 }) => {
|
return;
|
||||||
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 (hiddenAtRef.current === null) return;
|
||||||
return () => {
|
const gap = performance.now() - hiddenAtRef.current;
|
||||||
cancelled = true;
|
hiddenAtRef.current = null;
|
||||||
handles.forEach((h) => h.remove());
|
if (gap < RESUME_HIDDEN_THRESHOLD_MS) return;
|
||||||
};
|
setResumeGraceUntil(performance.now() + RESUME_GRACE_MS);
|
||||||
}, [mx]);
|
// 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
|
// 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):
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue