2026-01-22 01:19:24 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { isWebRuntime } from '@/lib/desktop';
|
2026-07-01 09:55:41 +03:00
|
|
|
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
|
2026-01-22 01:19:24 +02:00
|
|
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
const HEARTBEAT_MS = 20000;
|
2026-01-22 01:19:24 +02:00
|
|
|
|
2026-01-23 01:51:22 +02:00
|
|
|
const resolveVisibilityState = (): 'visible' | 'hidden' => {
|
|
|
|
|
if (typeof document === 'undefined') return 'visible';
|
2026-05-19 13:16:47 +03:00
|
|
|
return document.visibilityState === 'visible' && document.hasFocus() ? 'visible' : 'hidden';
|
2026-01-23 01:51:22 +02:00
|
|
|
};
|
|
|
|
|
|
2026-01-22 01:19:24 +02:00
|
|
|
const sendVisibility = (visible: boolean) => {
|
2026-07-01 09:55:41 +03:00
|
|
|
if (!isWebRuntime() && !isCapacitorApp()) {
|
2026-01-22 01:19:24 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const apis = getRegisteredRuntimeAPIs();
|
|
|
|
|
if (!apis?.push?.setVisibility) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
// platform lets the server distinguish mobile (push recipients) from interactive surfaces
|
|
|
|
|
// (desktop/web/vscode) so it can suppress phone push only while an interactive client is visible.
|
|
|
|
|
void apis.push.setVisibility({ visible, platform: getClientPlatform() });
|
2026-01-22 01:19:24 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => {
|
|
|
|
|
const enabled = options?.enabled ?? true;
|
2026-01-22 01:19:24 +02:00
|
|
|
React.useEffect(() => {
|
2026-07-01 09:55:41 +03:00
|
|
|
if (!enabled || (!isWebRuntime() && !isCapacitorApp()) || typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Native (Capacitor): drive visibility AUTHORITATIVELY from App.appStateChange. The
|
|
|
|
|
// web signals (document.visibilityState / hasFocus) are unreliable in a WKWebView —
|
|
|
|
|
// hasFocus() often returns false while the app is active — which made the app report
|
|
|
|
|
// "hidden" while foregrounded and leaked push notifications. The server's focus gate
|
|
|
|
|
// suppresses push whenever a UI client is visible, so getting this right is what
|
|
|
|
|
// guarantees "no push while the app is active".
|
|
|
|
|
if (isCapacitorApp()) {
|
|
|
|
|
let active = true;
|
|
|
|
|
let disposed = false;
|
|
|
|
|
let removeListener: (() => void) | null = null;
|
|
|
|
|
const reportActive = () => sendVisibility(active);
|
|
|
|
|
|
|
|
|
|
void import('@capacitor/app')
|
|
|
|
|
.then(async ({ App }) => {
|
|
|
|
|
if (disposed) return;
|
|
|
|
|
const state = await App.getState().catch(() => null);
|
|
|
|
|
if (state) active = state.isActive === true;
|
|
|
|
|
reportActive();
|
|
|
|
|
const handle = await App.addListener('appStateChange', ({ isActive }) => {
|
|
|
|
|
active = isActive === true;
|
|
|
|
|
reportActive();
|
|
|
|
|
});
|
|
|
|
|
if (disposed) {
|
|
|
|
|
void handle.remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
removeListener = () => void handle.remove();
|
|
|
|
|
})
|
|
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
|
|
|
|
// Heartbeat so the server's visibility TTL never expires while the app is active.
|
|
|
|
|
const interval = window.setInterval(() => {
|
|
|
|
|
if (active) sendVisibility(true);
|
|
|
|
|
}, HEARTBEAT_MS);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
disposed = true;
|
|
|
|
|
window.clearInterval(interval);
|
|
|
|
|
removeListener?.();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Web / desktop: document-based visibility.
|
|
|
|
|
if (typeof document === 'undefined') {
|
2026-01-22 01:19:24 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = () => {
|
2026-01-23 01:51:22 +02:00
|
|
|
sendVisibility(resolveVisibilityState() === 'visible');
|
2026-01-22 01:19:24 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reportVisibleOnly = () => {
|
2026-01-23 01:51:22 +02:00
|
|
|
if (resolveVisibilityState() === 'visible') {
|
2026-01-22 01:19:24 +02:00
|
|
|
sendVisibility(true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-12 04:06:51 -04:00
|
|
|
const reportPageHidden = () => {
|
|
|
|
|
sendVisibility(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-22 01:19:24 +02:00
|
|
|
report();
|
|
|
|
|
|
|
|
|
|
const interval = window.setInterval(reportVisibleOnly, HEARTBEAT_MS);
|
|
|
|
|
|
|
|
|
|
document.addEventListener('visibilitychange', report);
|
2026-05-12 04:06:51 -04:00
|
|
|
window.addEventListener('pagehide', reportPageHidden);
|
2026-01-22 01:19:24 +02:00
|
|
|
window.addEventListener('pageshow', report);
|
|
|
|
|
window.addEventListener('focus', report);
|
|
|
|
|
window.addEventListener('blur', report);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.clearInterval(interval);
|
|
|
|
|
document.removeEventListener('visibilitychange', report);
|
2026-05-12 04:06:51 -04:00
|
|
|
window.removeEventListener('pagehide', reportPageHidden);
|
2026-01-22 01:19:24 +02:00
|
|
|
window.removeEventListener('pageshow', report);
|
|
|
|
|
window.removeEventListener('focus', report);
|
|
|
|
|
window.removeEventListener('blur', report);
|
|
|
|
|
};
|
2026-03-02 02:11:33 +02:00
|
|
|
}, [enabled]);
|
2026-01-22 01:19:24 +02:00
|
|
|
};
|