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
This commit is contained in:
Bohdan Triapitsyn
2026-01-23 01:51:22 +02:00
committed by GitHub
parent 51922d7b23
commit 71b9ec6e3d
3 changed files with 77 additions and 75 deletions
@@ -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);
}
};
+51 -61
View File
@@ -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', ''));
+18 -12
View File
@@ -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) => {