From 71b9ec6e3dd2ad541d43abb002d2e425ff2f05b8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 23 Jan 2026 01:51:22 +0200 Subject: [PATCH] fix(ui): improve visibility condition and push notification handling (#199) Resolve visibility state for beacon reporting to handle focus correctly Normalize push subscriptions per UI session and send to each endpoint Suppress push notifications when a visible window exists to avoid redundant alerts --- .../ui/src/hooks/usePushVisibilityBeacon.ts | 10 +- packages/web/server/index.js | 112 ++++++++---------- packages/web/src/sw.ts | 30 +++-- 3 files changed, 77 insertions(+), 75 deletions(-) diff --git a/packages/ui/src/hooks/usePushVisibilityBeacon.ts b/packages/ui/src/hooks/usePushVisibilityBeacon.ts index 5b48ea2b..b69dff11 100644 --- a/packages/ui/src/hooks/usePushVisibilityBeacon.ts +++ b/packages/ui/src/hooks/usePushVisibilityBeacon.ts @@ -4,6 +4,12 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; const HEARTBEAT_MS = 10000; +const resolveVisibilityState = (): 'visible' | 'hidden' => { + if (typeof document === 'undefined') return 'visible'; + const state = document.visibilityState; + return state === 'hidden' && document.hasFocus() ? 'visible' : state; +}; + const sendVisibility = (visible: boolean) => { if (!isWebRuntime()) { return; @@ -24,11 +30,11 @@ export const usePushVisibilityBeacon = () => { } const report = () => { - sendVisibility(document.visibilityState === 'visible'); + sendVisibility(resolveVisibilityState() === 'visible'); }; const reportVisibleOnly = () => { - if (document.visibilityState === 'visible') { + if (resolveVisibilityState() === 'visible') { sendVisibility(true); } }; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 4c6f2736..51ed41dc 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1009,10 +1009,7 @@ const getUiSessionTokenFromRequest = (req) => { return null; }; -const getPushSubscriptionsForUiSession = async (uiSessionToken) => { - if (!uiSessionToken) return []; - const store = await readPushSubscriptionsFromDisk(); - const record = store.subscriptionsBySession?.[uiSessionToken]; +const normalizePushSubscriptions = (record) => { if (!Array.isArray(record)) return []; return record .map((entry) => { @@ -1033,6 +1030,13 @@ const getPushSubscriptionsForUiSession = async (uiSessionToken) => { .filter(Boolean); }; +const getPushSubscriptionsForUiSession = async (uiSessionToken) => { + if (!uiSessionToken) return []; + const store = await readPushSubscriptionsFromDisk(); + const record = store.subscriptionsBySession?.[uiSessionToken]; + return normalizePushSubscriptions(record); +}; + const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => { if (!uiSessionToken) { return; @@ -1108,70 +1112,74 @@ const buildSessionDeepLinkUrl = (sessionId) => { return `/?session=${encodeURIComponent(sessionId)}`; }; -const sendPushToUiSession = async (uiSessionToken, payload) => { +const sendPushToSubscription = async (sub, payload) => { await ensurePushInitialized(); - - const subscriptions = await getPushSubscriptionsForUiSession(uiSessionToken); - if (subscriptions.length === 0) { - return; - } - const body = JSON.stringify(payload); - await Promise.all(subscriptions.map(async (sub) => { - const pushSubscription = { - endpoint: sub.endpoint, - keys: { - p256dh: sub.p256dh, - auth: sub.auth, - } - }; - - try { - await webPush.sendNotification(pushSubscription, body); - } catch (error) { - const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null; - if (statusCode === 410 || statusCode === 404) { - await removePushSubscriptionFromAllSessions(sub.endpoint); - return; - } - console.warn('[Push] Failed to send notification:', error); + const pushSubscription = { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, } - })); + }; + + try { + await webPush.sendNotification(pushSubscription, body); + } catch (error) { + const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null; + if (statusCode === 410 || statusCode === 404) { + await removePushSubscriptionFromAllSessions(sub.endpoint); + return; + } + console.warn('[Push] Failed to send notification:', error); + } }; const sendPushToAllUiSessions = async (payload, options = {}) => { const requireNoSse = options.requireNoSse === true; const store = await readPushSubscriptionsFromDisk(); - const tokens = Object.keys(store.subscriptionsBySession || {}); + const sessions = store.subscriptionsBySession || {}; + const subscriptionsByEndpoint = new Map(); - await Promise.all(tokens.map(async (token) => { - if (requireNoSse && isUiVisible(token)) { + for (const [token, record] of Object.entries(sessions)) { + const subscriptions = normalizePushSubscriptions(record); + if (subscriptions.length === 0) continue; + + for (const sub of subscriptions) { + if (!subscriptionsByEndpoint.has(sub.endpoint)) { + subscriptionsByEndpoint.set(sub.endpoint, sub); + } + } + } + + await Promise.all(Array.from(subscriptionsByEndpoint.entries()).map(async ([endpoint, sub]) => { + if (requireNoSse && isAnyUiVisible()) { return; } - await sendPushToUiSession(token, payload); + await sendPushToSubscription(sub, payload); })); }; let pushInitialized = false; -const activeUiSseConnections = new Set(); -const VISIBILITY_TTL_MS = 30000; const uiVisibilityByToken = new Map(); +let globalVisibilityState = false; const updateUiVisibility = (token, visible) => { if (!token) return; - uiVisibilityByToken.set(token, { visible: Boolean(visible), updatedAt: Date.now() }); + const now = Date.now(); + const nextVisible = Boolean(visible); + uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now }); + globalVisibilityState = nextVisible; + }; -const isUiVisible = (token) => { - const entry = uiVisibilityByToken.get(token); - if (!entry) return false; - if (Date.now() - entry.updatedAt > VISIBILITY_TTL_MS) return false; - return entry.visible === true; -}; +const isAnyUiVisible = () => globalVisibilityState === true; + +const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true; const resolveVapidSubject = async () => { const configured = process.env.OPENCHAMBER_VAPID_SUBJECT; @@ -2808,15 +2816,6 @@ async function main(options = {}) { }); app.get('/api/global/event', async (req, res) => { - const uiToken = getUiSessionTokenFromRequest(req); - if (uiToken) { - activeUiSseConnections.add(uiToken); - const cleanupUiToken = () => { - activeUiSseConnections.delete(uiToken); - }; - req.on('close', cleanupUiToken); - req.on('error', cleanupUiToken); - } let targetUrl; try { targetUrl = new URL(buildOpenCodeUrl('/global/event', '')); @@ -2928,15 +2927,6 @@ async function main(options = {}) { }); app.get('/api/event', async (req, res) => { - const uiToken = getUiSessionTokenFromRequest(req); - if (uiToken) { - activeUiSseConnections.add(uiToken); - const cleanupUiToken = () => { - activeUiSseConnections.delete(uiToken); - }; - req.on('close', cleanupUiToken); - req.on('error', cleanupUiToken); - } let targetUrl; try { targetUrl = new URL(buildOpenCodeUrl('/event', '')); diff --git a/packages/web/src/sw.ts b/packages/web/src/sw.ts index edaff405..7ed2a8cc 100644 --- a/packages/web/src/sw.ts +++ b/packages/web/src/sw.ts @@ -34,25 +34,31 @@ self.addEventListener('activate', (event) => { }); self.addEventListener('push', (event) => { - const payload = (event.data?.json() ?? null) as PushPayload | null; - if (!payload) { - return; - } + event.waitUntil((async () => { + const payload = (event.data?.json() ?? null) as PushPayload | null; + if (!payload) { + return; + } - const title = payload.title || 'OpenChamber'; - const body = payload.body ?? ''; - const icon = payload.icon ?? '/apple-touch-icon-180x180.png'; - const badge = payload.badge ?? '/favicon-32.png'; + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const hasVisibleClient = clients.some((client) => client.visibilityState === 'visible' || client.focused); + if (hasVisibleClient) { + return; + } - event.waitUntil( - self.registration.showNotification(title, { + const title = payload.title || 'OpenChamber'; + const body = payload.body ?? ''; + const icon = payload.icon ?? '/apple-touch-icon-180x180.png'; + const badge = payload.badge ?? '/favicon-32.png'; + + await self.registration.showNotification(title, { body, icon, badge, tag: payload.tag, data: payload.data, - }) - ); + }); + })()); }); self.addEventListener('notificationclick', (event) => {