From ae2035f868f18630b09271285d053ee638bf174f Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 9 Oct 2026 20:06:04 -0500 Subject: [PATCH] Reload notifications page on push notification click --- package.json | 2 +- src/js/notificationServiceManager.js | 11 +++ src/js/views/notifications.view.js | 4 + src/sw.js | 7 +- .../specs/views/notifications.view.test.js | 75 ++++++++++++++++ tests/unit/specs/sw.test.js | 86 +++++++++++++++++++ 6 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 tests/unit/specs/sw.test.js diff --git a/package.json b/package.json index be0b36c4..667188c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.89", + "version": "0.20.90", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/notificationServiceManager.js b/src/js/notificationServiceManager.js index 848a901c..bfaa5c7d 100644 --- a/src/js/notificationServiceManager.js +++ b/src/js/notificationServiceManager.js @@ -9,6 +9,7 @@ import { html } from "/js/lib/lit-html.js"; export class NotificationServiceManager { constructor({ session, api, auth, router }) { + this.router = router; this.notificationService = session ? new NotificationService(api) : null; this.chatNotificationService = session ? new ChatNotificationService(api) @@ -81,10 +82,20 @@ export class NotificationServiceManager { this.chatNotificationService ?.fetchNumNotifications() .catch(console.error); + } else if (event.data?.type === "notification-click") { + this._openNotificationUrl(event.data.url); } }; navigator.serviceWorker.addEventListener("message", handleMessage); return () => navigator.serviceWorker.removeEventListener("message", handleMessage); } + + _openNotificationUrl(url) { + if (url === this.router.currentPath) { + window.dispatchEvent(new CustomEvent("notification-open")); + } else { + this.router.go(url); + } + } } diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index de75195a..910453c0 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -804,6 +804,10 @@ export default async function notificationsView({ scrollAndReloadNotifications(); }); + bindToPage(root, window, "notification-open", () => { + scrollAndReloadNotifications(); + }); + bindPageTitle(root, () => "Notifications"); pageEffect(root, () => { diff --git a/src/sw.js b/src/sw.js index 3d4a8881..edbbe3ba 100644 --- a/src/sw.js +++ b/src/sw.js @@ -52,10 +52,11 @@ self.addEventListener("notificationclick", (event) => { const target = new URL(url, self.location.origin); for (const client of allClients) { if (new URL(client.url).origin !== self.location.origin) continue; + client.postMessage({ + type: "notification-click", + url: target.pathname + target.search + target.hash, + }); await client.focus(); - if ("navigate" in client) { - await client.navigate(target.href); - } return; } await self.clients.openWindow(target.href); diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js index dafe8589..61adad82 100644 --- a/tests/e2e/specs/views/notifications.view.test.js +++ b/tests/e2e/specs/views/notifications.view.test.js @@ -96,6 +96,81 @@ test.describe("Notifications view", () => { await expect(view.locator(".notification-item")).toHaveCount(1); }); + test.describe("Push notification click", () => { + function dispatchNotificationClick(page, url) { + return page.evaluate((url) => { + navigator.serviceWorker.dispatchEvent( + new MessageEvent("message", { + data: { type: "notification-click", url }, + }), + ); + }, url); + } + + test("should reload notifications when already on the notifications page", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addNotifications([ + createNotification({ + reason: "follow", + author: alice, + indexedAt: new Date().toISOString(), + }), + ]); + await mockServer.setup(page); + + await login(page); + await page.goto("/notifications"); + + const view = page.locator("#notifications-view"); + await expect(view.locator(".notification-item")).toHaveCount(1, { + timeout: 10000, + }); + + mockServer.addNotifications([ + createNotification({ + reason: "follow", + author: bob, + indexedAt: new Date().toISOString(), + }), + ]); + await dispatchNotificationClick(page, "/notifications"); + + await expect( + view.locator(".notification-item .notification-avatar"), + ).toHaveCount(2, { timeout: 10000 }); + }); + + test("should navigate in-app to the notification url from another page", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addNotifications([ + createNotification({ + reason: "follow", + author: alice, + indexedAt: new Date().toISOString(), + }), + ]); + await mockServer.setup(page); + + await login(page); + await page.goto("/"); + await page.evaluate(() => { + window.__sameDocument = true; + }); + + await dispatchNotificationClick(page, "/notifications"); + + const view = page.locator("#notifications-view"); + await expect(view.locator(".notification-item")).toHaveCount(1, { + timeout: 10000, + }); + expect(await page.evaluate(() => window.__sameDocument)).toBe(true); + }); + }); + test("should display a like notification with post preview", async ({ page, }) => { diff --git a/tests/unit/specs/sw.test.js b/tests/unit/specs/sw.test.js new file mode 100644 index 00000000..024a8647 --- /dev/null +++ b/tests/unit/specs/sw.test.js @@ -0,0 +1,86 @@ +import { describe, it, beforeEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const swSource = readFileSync(new URL("../../../src/sw.js", import.meta.url), { + encoding: "utf8", +}); + +const ORIGIN = "https://impro.example"; + +function createWindowClient(url) { + return { + url, + focused: false, + postMessage: mock.fn(), + focus: mock.fn(async () => {}), + navigate: mock.fn(async () => {}), + }; +} + +function loadServiceWorker({ clients }) { + const listeners = new Map(); + const fakeSelf = { + location: new URL(ORIGIN), + navigator: {}, + registration: { showNotification: mock.fn(async () => {}) }, + clients: { + matchAll: mock.fn(async () => clients), + openWindow: mock.fn(async () => {}), + }, + addEventListener(type, listener) { + listeners.set(type, listener); + }, + }; + new Function("self", swSource)(fakeSelf); + return { self: fakeSelf, listeners }; +} + +async function clickNotification(listeners, data) { + let pending = null; + const event = { + notification: { data, close: mock.fn() }, + waitUntil(promise) { + pending = promise; + }, + }; + listeners.get("notificationclick")(event); + await pending; + return event; +} + +describe("service worker notificationclick", () => { + let client; + + beforeEach(() => { + client = createWindowClient(`${ORIGIN}/`); + }); + + it("messages and focuses an open window instead of navigating it", async () => { + const { listeners, self } = loadServiceWorker({ clients: [client] }); + + const event = await clickNotification(listeners, { + url: "/notifications?tab=mentions", + }); + + assert.equal(event.notification.close.mock.callCount(), 1); + assert.deepEqual(client.postMessage.mock.calls[0].arguments, [ + { type: "notification-click", url: "/notifications?tab=mentions" }, + ]); + assert.equal(client.focus.mock.callCount(), 1); + assert.equal(client.navigate.mock.callCount(), 0); + assert.equal(self.clients.openWindow.mock.callCount(), 0); + }); + + it("opens a new window when no same-origin window is open", async () => { + const otherOrigin = createWindowClient("https://other.example/"); + const { listeners, self } = loadServiceWorker({ clients: [otherOrigin] }); + + await clickNotification(listeners, { url: "/notifications" }); + + assert.equal(otherOrigin.postMessage.mock.callCount(), 0); + assert.deepEqual(self.clients.openWindow.mock.calls[0].arguments, [ + `${ORIGIN}/notifications`, + ]); + }); +}); -- 2.54.0