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 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) => {
|
const sendVisibility = (visible: boolean) => {
|
||||||
if (!isWebRuntime()) {
|
if (!isWebRuntime()) {
|
||||||
return;
|
return;
|
||||||
@@ -24,11 +30,11 @@ export const usePushVisibilityBeacon = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const report = () => {
|
const report = () => {
|
||||||
sendVisibility(document.visibilityState === 'visible');
|
sendVisibility(resolveVisibilityState() === 'visible');
|
||||||
};
|
};
|
||||||
|
|
||||||
const reportVisibleOnly = () => {
|
const reportVisibleOnly = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (resolveVisibilityState() === 'visible') {
|
||||||
sendVisibility(true);
|
sendVisibility(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1009,10 +1009,7 @@ const getUiSessionTokenFromRequest = (req) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
|
const normalizePushSubscriptions = (record) => {
|
||||||
if (!uiSessionToken) return [];
|
|
||||||
const store = await readPushSubscriptionsFromDisk();
|
|
||||||
const record = store.subscriptionsBySession?.[uiSessionToken];
|
|
||||||
if (!Array.isArray(record)) return [];
|
if (!Array.isArray(record)) return [];
|
||||||
return record
|
return record
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
@@ -1033,6 +1030,13 @@ const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
|
|||||||
.filter(Boolean);
|
.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) => {
|
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
|
||||||
if (!uiSessionToken) {
|
if (!uiSessionToken) {
|
||||||
return;
|
return;
|
||||||
@@ -1108,70 +1112,74 @@ const buildSessionDeepLinkUrl = (sessionId) => {
|
|||||||
return `/?session=${encodeURIComponent(sessionId)}`;
|
return `/?session=${encodeURIComponent(sessionId)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendPushToUiSession = async (uiSessionToken, payload) => {
|
const sendPushToSubscription = async (sub, payload) => {
|
||||||
await ensurePushInitialized();
|
await ensurePushInitialized();
|
||||||
|
|
||||||
const subscriptions = await getPushSubscriptionsForUiSession(uiSessionToken);
|
|
||||||
if (subscriptions.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = JSON.stringify(payload);
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
await Promise.all(subscriptions.map(async (sub) => {
|
const pushSubscription = {
|
||||||
const pushSubscription = {
|
endpoint: sub.endpoint,
|
||||||
endpoint: sub.endpoint,
|
keys: {
|
||||||
keys: {
|
p256dh: sub.p256dh,
|
||||||
p256dh: sub.p256dh,
|
auth: sub.auth,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
|
|
||||||
|
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 sendPushToAllUiSessions = async (payload, options = {}) => {
|
||||||
const requireNoSse = options.requireNoSse === true;
|
const requireNoSse = options.requireNoSse === true;
|
||||||
const store = await readPushSubscriptionsFromDisk();
|
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) => {
|
for (const [token, record] of Object.entries(sessions)) {
|
||||||
if (requireNoSse && isUiVisible(token)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await sendPushToUiSession(token, payload);
|
await sendPushToSubscription(sub, payload);
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
let pushInitialized = false;
|
let pushInitialized = false;
|
||||||
const activeUiSseConnections = new Set();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const VISIBILITY_TTL_MS = 30000;
|
|
||||||
const uiVisibilityByToken = new Map();
|
const uiVisibilityByToken = new Map();
|
||||||
|
let globalVisibilityState = false;
|
||||||
|
|
||||||
const updateUiVisibility = (token, visible) => {
|
const updateUiVisibility = (token, visible) => {
|
||||||
if (!token) return;
|
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 isAnyUiVisible = () => globalVisibilityState === true;
|
||||||
const entry = uiVisibilityByToken.get(token);
|
|
||||||
if (!entry) return false;
|
const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true;
|
||||||
if (Date.now() - entry.updatedAt > VISIBILITY_TTL_MS) return false;
|
|
||||||
return entry.visible === true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveVapidSubject = async () => {
|
const resolveVapidSubject = async () => {
|
||||||
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
|
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
|
||||||
@@ -2808,15 +2816,6 @@ async function main(options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/global/event', async (req, res) => {
|
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;
|
let targetUrl;
|
||||||
try {
|
try {
|
||||||
targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
|
targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
|
||||||
@@ -2928,15 +2927,6 @@ async function main(options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/event', async (req, res) => {
|
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;
|
let targetUrl;
|
||||||
try {
|
try {
|
||||||
targetUrl = new URL(buildOpenCodeUrl('/event', ''));
|
targetUrl = new URL(buildOpenCodeUrl('/event', ''));
|
||||||
|
|||||||
+18
-12
@@ -34,25 +34,31 @@ self.addEventListener('activate', (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
const payload = (event.data?.json() ?? null) as PushPayload | null;
|
event.waitUntil((async () => {
|
||||||
if (!payload) {
|
const payload = (event.data?.json() ?? null) as PushPayload | null;
|
||||||
return;
|
if (!payload) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const title = payload.title || 'OpenChamber';
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
const body = payload.body ?? '';
|
const hasVisibleClient = clients.some((client) => client.visibilityState === 'visible' || client.focused);
|
||||||
const icon = payload.icon ?? '/apple-touch-icon-180x180.png';
|
if (hasVisibleClient) {
|
||||||
const badge = payload.badge ?? '/favicon-32.png';
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
const title = payload.title || 'OpenChamber';
|
||||||
self.registration.showNotification(title, {
|
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,
|
body,
|
||||||
icon,
|
icon,
|
||||||
badge,
|
badge,
|
||||||
tag: payload.tag,
|
tag: payload.tag,
|
||||||
data: payload.data,
|
data: payload.data,
|
||||||
})
|
});
|
||||||
);
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user