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:
committed by
GitHub
parent
51922d7b23
commit
71b9ec6e3d
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user