diff --git a/src/js/notificationServiceManager.js b/src/js/notificationServiceManager.js index bfaa5c7d..f7ac0900 100644 --- a/src/js/notificationServiceManager.js +++ b/src/js/notificationServiceManager.js @@ -72,12 +72,20 @@ export class NotificationServiceManager { }; } - // The service worker skips the notification when a focused window exists - // and sends a message instead, so refresh the counts immediately. + // The service worker skips the system notification when a focused window + // answers its liveness check, so confirm the message arrived and refresh + // the counts immediately. _listenForPushMessages() { if (!("serviceWorker" in navigator)) return null; const handleMessage = (event) => { if (event.data?.type === "push-received") { + const port = event.ports?.[0]; + if (port && event.data.requestId) { + port.postMessage({ + type: "push-received-ack", + requestId: event.data.requestId, + }); + } this.notificationService?.fetchNumNotifications().catch(console.error); this.chatNotificationService ?.fetchNumNotifications() diff --git a/src/sw.js b/src/sw.js index edbbe3ba..99b565ad 100644 --- a/src/sw.js +++ b/src/sw.js @@ -15,12 +15,34 @@ self.addEventListener("push", (event) => { includeUncontrolled: true, }); const focused = clients.filter((client) => client.focused); - // If a client is focused, skip showing the notification + // A focused window is asked to confirm it is alive before the system + // notification is skipped. A window whose renderer is suspended (the + // OS froze the app) still reports focused=true but never answers, so + // without the reply check those pushes vanish with no trace. + let answered = false; if (focused.length > 0) { + const requestId = crypto.randomUUID(); + const channel = new MessageChannel(); + const reply = new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 1000); + // Keep a pending fallback from holding the worker (or a test + // process) open. + if (typeof timer.unref === "function") timer.unref(); + channel.port1.onmessage = (event) => { + if (event.data?.type !== "push-received-ack") return; + if (event.data?.requestId !== requestId) return; + clearTimeout(timer); + resolve(true); + }; + }); for (const client of focused) { - client.postMessage({ type: "push-received" }); + client.postMessage({ type: "push-received", requestId }, [ + channel.port2, + ]); } - } else { + answered = await reply; + } + if (!answered) { await self.registration.showNotification(title || "Impro", { body, tag, diff --git a/tests/unit/specs/sw.test.js b/tests/unit/specs/sw.test.js index 024a8647..6213551b 100644 --- a/tests/unit/specs/sw.test.js +++ b/tests/unit/specs/sw.test.js @@ -49,6 +49,35 @@ async function clickNotification(listeners, data) { return event; } +async function receivePush(listeners, payload) { + let pending = null; + const event = { + data: { json: () => payload }, + waitUntil(promise) { + pending = promise; + }, + }; + listeners.get("push")(event); + await pending; + return event; +} + +// A focused client whose page is alive: postMessage answers over the +// transferred port the same way the real page does. +function createAnsweringClient(url) { + const client = createWindowClient(url); + client.focused = true; + client.postMessage = mock.fn((message, [port]) => { + queueMicrotask(() => + port.postMessage({ + type: "push-received-ack", + requestId: message.requestId, + }), + ); + }); + return client; +} + describe("service worker notificationclick", () => { let client; @@ -84,3 +113,55 @@ describe("service worker notificationclick", () => { ]); }); }); + +describe("service worker push", () => { + const payload = { + title: "Impro", + body: "alice liked your post", + url: "/notifications", + tag: "n:abc123", + }; + + it("skips the system notification when a focused window answers", async () => { + const client = createAnsweringClient(`${ORIGIN}/`); + const { listeners, self } = loadServiceWorker({ clients: [client] }); + + await receivePush(listeners, payload); + + const [message] = client.postMessage.mock.calls[0].arguments; + assert.equal(message.type, "push-received"); + assert.equal(typeof message.requestId, "string"); + assert.equal(self.registration.showNotification.mock.callCount(), 0); + }); + + it("notifies when the focused window never answers", async () => { + const frozen = createWindowClient(`${ORIGIN}/`); + frozen.focused = true; + const { listeners, self } = loadServiceWorker({ clients: [frozen] }); + + await receivePush(listeners, payload); + + assert.deepEqual( + self.registration.showNotification.mock.calls[0].arguments, + [ + payload.title, + { + body: payload.body, + tag: payload.tag, + icon: "/img/impro-logo-192.png", + data: { url: payload.url }, + }, + ], + ); + }); + + it("notifies when no window is focused", async () => { + const client = createWindowClient(`${ORIGIN}/`); + const { listeners, self } = loadServiceWorker({ clients: [client] }); + + await receivePush(listeners, payload); + + assert.equal(client.postMessage.mock.callCount(), 0); + assert.equal(self.registration.showNotification.mock.callCount(), 1); + }); +});