mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Push receiver: import displayed notifications on open; pull-to-refresh listens on the window
A session-watch push showed on the iPhone but never reached the history, and a pull on the empty part of the page did nothing. (1) The history now heals itself: on every load the page asks the service-worker registration for the notifications still in the notification centre and writes the ones it lacks (entryFromNotification, the rule the click handler already used, now in the worker core) — a push the worker could not persist is caught up as long as its notification has not been cleared. (2) The pull gesture is registered on the window (passive), not on the content wrapper, so a pull that starts below the list counts; the wrapper keeps a minimum height. Tests pin the entry rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div onTouchStart={puller.onTouchStart} onTouchMove={puller.onTouchMove} onTouchEnd={puller.onTouchEnd} onTouchCancel={puller.onTouchCancel}>
|
||||
<div className="min-h-[70vh]">
|
||||
<div
|
||||
className="overflow-hidden flex items-end justify-center text-xs text-gray-500"
|
||||
style={{ height: pull.distance, transition: pull.pulling ? "none" : "height 150ms ease-out" }}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
detectPlatform,
|
||||
DEVICE_TOKEN_HEADER,
|
||||
entryFragment,
|
||||
entryFromNotification,
|
||||
GENERIC_BODY,
|
||||
GENERIC_TITLE,
|
||||
HISTORY_MAX_AGE_MS,
|
||||
@@ -253,6 +254,30 @@ describe("planSubscriptionKeys (worker repair after a browser-side subscription
|
||||
});
|
||||
});
|
||||
|
||||
describe("entryFromNotification (click handler and the on-open import of displayed notifications)", () => {
|
||||
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);
|
||||
|
||||
@@ -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<boolean | null> {
|
||||
|
||||
// --- 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<number> {
|
||||
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<void> {
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user