diff --git a/package.json b/package.json index ea8cccca..ac7f1e31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.19.0", + "version": "0.19.1", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/app.js b/src/js/app.js index e3cfbf3f..4f5e13f7 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -140,6 +140,18 @@ export async function main() { const pushNotificationService = session ? new PushNotificationService(api, auth) : null; + + // The service worker skips the notification when a focused window exists and + // sends a message instead, so refresh the counts immediately. + if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type === "push-received") { + notificationService?.fetchNumNotifications().catch(console.error); + chatNotificationService?.fetchNumNotifications().catch(console.error); + } + }); + } + const postComposerService = session ? new PostComposerService(dataLayer, identityResolver, pluginService, { draftsEnabled: await checkDraftsEnabled(auth), diff --git a/src/js/notificationService.js b/src/js/notificationService.js index d414484f..9db8bf4c 100644 --- a/src/js/notificationService.js +++ b/src/js/notificationService.js @@ -13,28 +13,13 @@ export class NotificationService { this._lastVerifiedTopUri = null; } - snooze(timeoutMinutes = 120) { - const snoozedUntil = new Date(Date.now() + timeoutMinutes * 60 * 1000); - localStorage.setItem( - "notifications-snoozed-until", - snoozedUntil.toISOString(), - ); - } - - get isSnoozed() { - const snoozedUntil = localStorage.getItem("notifications-snoozed-until"); - return snoozedUntil ? new Date(snoozedUntil) > new Date() : false; - } - startPolling() { const pollingInterval = POLLING_INTERVAL_SECONDS * 1000; let stopped = false; const poll = async () => { while (!stopped) { try { - if (!this.isSnoozed) { - await this.fetchNumNotifications(); - } + await this.fetchNumNotifications(); } catch (error) { console.error(error); } diff --git a/src/js/systemNotificationService.js b/src/js/systemNotificationService.js index 50bdf70c..45e9c0d8 100644 --- a/src/js/systemNotificationService.js +++ b/src/js/systemNotificationService.js @@ -72,6 +72,10 @@ export class SystemNotificationService { return this.isSupported ? Notification.permission : "unsupported"; } + get isTabActive() { + return document.visibilityState === "visible" && document.hasFocus(); + } + async requestPermission() { if (!this.isSupported) return "unsupported"; const result = await Notification.requestPermission(); @@ -90,7 +94,7 @@ export class SystemNotificationService { !this.isSupported || !this.isEnabled || Notification.permission !== "granted" || - this.notificationService.isSnoozed + this.isTabActive ) { return; } diff --git a/src/sw.js b/src/sw.js index e587ff73..3d4a8881 100644 --- a/src/sw.js +++ b/src/sw.js @@ -10,12 +10,24 @@ self.addEventListener("push", (event) => { event.waitUntil( (async () => { - await self.registration.showNotification(title || "Impro", { - body, - tag, - icon: "/img/impro-logo-192.png", - data: { url: url || "/" }, + const clients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, }); + const focused = clients.filter((client) => client.focused); + // If a client is focused, skip showing the notification + if (focused.length > 0) { + for (const client of focused) { + client.postMessage({ type: "push-received" }); + } + } else { + await self.registration.showNotification(title || "Impro", { + body, + tag, + icon: "/img/impro-logo-192.png", + data: { url: url || "/" }, + }); + } if (typeof badge === "number" && "setAppBadge" in self.navigator) { try { await self.navigator.setAppBadge(badge); diff --git a/tests/unit/specs/systemNotificationService.test.js b/tests/unit/specs/systemNotificationService.test.js index f9ad97c0..3a74ba6d 100644 --- a/tests/unit/specs/systemNotificationService.test.js +++ b/tests/unit/specs/systemNotificationService.test.js @@ -3,13 +3,9 @@ import assert from "node:assert/strict"; import { Signal } from "/js/signals.js"; import { SystemNotificationService } from "/js/systemNotificationService.js"; -function createMockNotificationService({ - numNotifications = 0, - isSnoozed = false, -} = {}) { +function createMockNotificationService({ numNotifications = 0 } = {}) { return { $numNotifications: new Signal.State(numNotifications), - isSnoozed, }; } @@ -36,6 +32,15 @@ describe("SystemNotificationService", () => { let disposers; let navigations; let router; + let originalHasFocus; + + function simulateTabState({ visible, focused }) { + Object.defineProperty(document, "visibilityState", { + value: visible ? "visible" : "hidden", + configurable: true, + }); + document.hasFocus = () => focused; + } function simulateTouchOnlyDevice() { window.matchMedia = (query) => ({ @@ -63,6 +68,8 @@ describe("SystemNotificationService", () => { router = { go: (path) => navigations.push(path) }; originalNotification = globalThis.Notification; originalMatchMedia = window.matchMedia; + originalHasFocus = document.hasFocus; + simulateTabState({ visible: true, focused: false }); globalThis.Notification = class { static permission = "granted"; static async requestPermission() { @@ -84,6 +91,8 @@ describe("SystemNotificationService", () => { } globalThis.Notification = originalNotification; window.matchMedia = originalMatchMedia; + document.hasFocus = originalHasFocus; + delete document.visibilityState; localStorage.clear(); }); @@ -237,12 +246,11 @@ describe("SystemNotificationService", () => { assert.deepEqual(instances.length, 0); }); - it("does not notify when snoozed", async () => { - const notificationService = createMockNotificationService({ - isSnoozed: true, - }); + it("does not notify while the tab is visible and focused", async () => { + const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); + simulateTabState({ visible: true, focused: true }); startService(notificationService, chatNotificationService); notificationService.$numNotifications.set(3); @@ -250,6 +258,32 @@ describe("SystemNotificationService", () => { assert.deepEqual(instances.length, 0); }); + + it("notifies when the window is focused but not visible", async () => { + const notificationService = createMockNotificationService(); + const chatNotificationService = createMockChatNotificationService(); + enable(); + simulateTabState({ visible: false, focused: true }); + startService(notificationService, chatNotificationService); + + notificationService.$numNotifications.set(3); + await flushEffects(); + + assert.deepEqual(instances.length, 1); + }); + + it("notifies when the tab is visible but unfocused", async () => { + const notificationService = createMockNotificationService(); + const chatNotificationService = createMockChatNotificationService(); + enable(); + simulateTabState({ visible: true, focused: false }); + startService(notificationService, chatNotificationService); + + notificationService.$numNotifications.set(3); + await flushEffects(); + + assert.deepEqual(instances.length, 1); + }); }); describe("touch-only devices", () => {