diff --git a/src/Web/autopilot-monitor-web/app/push/page.tsx b/src/Web/autopilot-monitor-web/app/push/page.tsx index 05e38f59..48d895a5 100644 --- a/src/Web/autopilot-monitor-web/app/push/page.tsx +++ b/src/Web/autopilot-monitor-web/app/push/page.tsx @@ -19,7 +19,7 @@ import { writeMeta, type HistoryEntry, } from "@/lib/push/pushCore"; -import { readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient"; +import { importDisplayedNotifications, readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient"; import { formatDateTime, formatRelativeTime } from "@/lib/push/pushFormat"; import { EntryActionSheet } from "./EntryActionSheet"; import { useIsClient, useLocationHash } from "./pushEnvironment"; @@ -69,6 +69,7 @@ export default function PushHistoryPage() { const loadHistory = useCallback(async () => { let list: HistoryEntry[] = []; try { + await importDisplayedNotifications(); await pruneStoredHistory(); list = await readHistory(); } catch { @@ -134,7 +135,22 @@ export default function PushHistoryPage() { onChange: setPull, }), ); - useEffect(() => () => puller.dispose(), [puller]); + // The gesture listens on the window, not on the content: most of the page is empty space + // below the list, and a pull that starts there must count too. Passive — nothing is prevented. + useEffect(() => { + const opts: AddEventListenerOptions = { passive: true }; + window.addEventListener("touchstart", puller.onTouchStart, opts); + window.addEventListener("touchmove", puller.onTouchMove, opts); + window.addEventListener("touchend", puller.onTouchEnd, opts); + window.addEventListener("touchcancel", puller.onTouchCancel, opts); + return () => { + window.removeEventListener("touchstart", puller.onTouchStart); + window.removeEventListener("touchmove", puller.onTouchMove); + window.removeEventListener("touchend", puller.onTouchEnd); + window.removeEventListener("touchcancel", puller.onTouchCancel); + puller.dispose(); + }; + }, [puller]); // The filter is a per-device preference, stored next to the retention; a stale key keeps everything. useEffect(() => { @@ -214,7 +230,7 @@ export default function PushHistoryPage() { const pullLabel = pull.refreshing ? "Refreshing…" : pull.armed ? "Release to refresh" : "Pull to refresh"; return ( -
+
{ + it("takes the entry the push handler attached", () => { + const attached = normalizePayload(FULL_PAYLOAD, NOW); + const entry = entryFromNotification({ title: "x", body: "y", data: { id: attached.id, entry: attached } }, NOW); + expect(entry).toBe(attached); + }); + + it("rebuilds the entry from a platform-displayed notification's data member", () => { + const n = { title: "Enrollment finished", body: "PHS-1 (…36GJ) finished enrollment.", tag: "session_watch", data: FULL_PAYLOAD.notification.data }; + const entry = entryFromNotification(n, NOW)!; + expect(entry.id).toBe(FULL_PAYLOAD.notification.data.id); + expect(entry.title).toBe("Enrollment finished"); + expect(entry.type).toBe("enrollment_failed"); + expect(entry.tag).toBe("session_watch"); + expect(entry.portalUrl).toBe(FULL_PAYLOAD.notification.data.portalUrl); + }); + + it("ignores notifications that are not ours", () => { + expect(entryFromNotification({ title: "foreign", body: "", data: null }, NOW)).toBeNull(); + expect(entryFromNotification({ title: "foreign", body: "", data: { other: 1 } }, NOW)).toBeNull(); + expect(entryFromNotification({ title: "foreign", body: "" }, NOW)).toBeNull(); + }); +}); + describe("normalizeRetentionDays", () => { it("accepts integers 0–365 as numbers or stored strings", () => { expect(normalizeRetentionDays(0)).toBe(0); diff --git a/src/Web/autopilot-monitor-web/lib/push/pushClient.ts b/src/Web/autopilot-monitor-web/lib/push/pushClient.ts index 46d9cce7..53155383 100644 --- a/src/Web/autopilot-monitor-web/lib/push/pushClient.ts +++ b/src/Web/autopilot-monitor-web/lib/push/pushClient.ts @@ -8,11 +8,13 @@ import { base64UrlToUint8Array, bufferToBase64Url, clearPushDb, + entryFromNotification, META_KEYS, PAIR_COOKIE_NAME, readMeta, - writeMeta, type PlatformInput, + writeHistoryEntry, + writeMeta, } from "./pushCore"; import { deleteDevice, @@ -128,6 +130,34 @@ export async function isStoragePersisted(): Promise { // --- Device lifecycle ----------------------------------------------------------------------- +/** + * The notifications still in the notification centre are the one record outside IndexedDB of + * what was delivered. On every open the page imports the ones the history lacks — a push the + * worker could not persist (killed early, a stuck database) is then caught up as long as the + * notification has not been cleared. Returns how many entries were added. + */ +export async function importDisplayedNotifications(): Promise { + const registration = await getPushRegistration().catch(() => null); + if (!registration || typeof registration.getNotifications !== "function") return 0; + let notifications: Notification[]; + try { + notifications = await registration.getNotifications(); + } catch { + return 0; + } + let added = 0; + for (const notification of notifications) { + const entry = entryFromNotification(notification, Date.now()); + if (!entry) continue; + try { + if (await writeHistoryEntry(entry)) added++; + } catch { + // No IndexedDB: nothing to import into. + } + } + return added; +} + /** Drops the subscription and every local trace; used by unpair, the wipe command and "device gone". */ export async function wipeLocalDevice(): Promise { try { diff --git a/src/Web/autopilot-monitor-web/public/push/sw-core.js b/src/Web/autopilot-monitor-web/public/push/sw-core.js index 8d4fdd38..dc1d90c9 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw-core.js +++ b/src/Web/autopilot-monitor-web/public/push/sw-core.js @@ -270,6 +270,23 @@ export function normalizePayload(input, nowMs) { }; } +/** + * The history entry behind a displayed notification: the one the push handler attached + * (data.entry), or — for a notification the platform displayed straight from the declarative + * JSON, without the worker running — rebuilt from the Notification itself, whose data is the + * payload's data member. Used by the click handler and by the pages, which import whatever is + * still in the notification centre on open so a push the worker missed is not lost. + * @param {{ title?: unknown, body?: unknown, tag?: unknown, data?: unknown }} notification + * @param {number} nowMs + * @returns {HistoryEntry | null} null when nothing identifies the notification as ours + */ +export function entryFromNotification(notification, nowMs) { + const data = isRecord(notification.data) ? notification.data : null; + if (data && isRecord(data.entry) && typeof data.entry.id === "string") return /** @type {HistoryEntry} */ (data.entry); + if (!data || typeof data.id !== "string") return null; + return normalizePayload({ title: notification.title, body: notification.body, tag: notification.tag, data }, nowMs); +} + /** * @param {HistoryEntry} entry * @returns {boolean} true for the wipe command (push_revoked): the device must forget everything diff --git a/src/Web/autopilot-monitor-web/public/push/sw.js b/src/Web/autopilot-monitor-web/public/push/sw.js index 944c1183..1ed554e2 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw.js +++ b/src/Web/autopilot-monitor-web/public/push/sw.js @@ -14,6 +14,7 @@ import { bufferToBase64Url, clearPushDb, entryFragment, + entryFromNotification, GENERIC_BODY, GENERIC_TITLE, isWipeCommand, @@ -151,23 +152,9 @@ self.addEventListener("pushnotification", (event) => { handlePush(event, proposed ?? safeParse(event.data ?? null)); }); -/** - * The history entry behind a clicked notification: the one the push handler attached, or — - * for a notification the platform displayed straight from the declarative JSON, without the - * worker running — rebuilt from the Notification itself (its data is the payload's data member). - * @param {Notification} notification - * @returns {import("./sw-core.js").HistoryEntry | null} - */ -function entryFromClicked(notification) { - const data = notification.data ?? {}; - if (data.entry && typeof data.entry === "object") return data.entry; - if (typeof data.id !== "string") return null; - return normalizePayload({ title: notification.title, body: notification.body, tag: notification.tag, data }, Date.now()); -} - self.addEventListener("notificationclick", (event) => { event.notification.close(); - const entry = entryFromClicked(event.notification); + const entry = entryFromNotification(event.notification, Date.now()); const id = entry ? entry.id : null; const target = new URL("/push/" + (id ? entryFragment(id) : ""), self.location.origin).toString(); event.waitUntil(