Fixes duplicate/triple OS notifications on machines where more than one browser (e.g. both Chrome and Edge on Windows) independently got notification permission for the site — each held its own valid subscription, so every event pushed to both. Adds a userAgent column (captured client-side at subscribe time, display-only) and a Settings > Account section listing a user's own subscribed browsers with a friendly label plus an unsubscribe button, backed by new GET/DELETE /api/push/subscriptions endpoints scoped to the caller's own rows. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
71 lines
2.9 KiB
TypeScript
71 lines
2.9 KiB
TypeScript
"use client";
|
|
|
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
for (let i = 0; i < rawData.length; i++) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
/**
|
|
* Registers the service worker and makes sure a push subscription exists
|
|
* and is saved server-side. Never throws or blocks the page on failure —
|
|
* this is an enhancement on top of the in-page toast — but every step logs
|
|
* to the console (visible in DevTools) since a silent no-op is otherwise
|
|
* impossible to tell apart from "it worked" when diagnosing remotely.
|
|
*/
|
|
export async function ensurePushSubscribed(): Promise<void> {
|
|
if (typeof window === "undefined") return;
|
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
console.warn("[push] not supported in this browser");
|
|
return;
|
|
}
|
|
if (Notification.permission !== "granted") {
|
|
console.warn("[push] Notification permission is", Notification.permission, "- skipping subscribe");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const registration = await navigator.serviceWorker.register("/sw.js");
|
|
await navigator.serviceWorker.ready;
|
|
console.log("[push] service worker registered:", registration.scope);
|
|
|
|
let subscription = await registration.pushManager.getSubscription();
|
|
if (subscription) {
|
|
console.log("[push] reusing existing subscription:", subscription.endpoint);
|
|
} else {
|
|
const keyRes = await fetch("/api/push/vapid-public-key");
|
|
if (!keyRes.ok) {
|
|
console.error("[push] /api/push/vapid-public-key returned", keyRes.status, "- is VAPID configured server-side?");
|
|
return;
|
|
}
|
|
const { publicKey } = await keyRes.json();
|
|
subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
// lib.dom's BufferSource typing is stricter than the runtime value
|
|
// actually needs to satisfy — this Uint8Array is always backed by a
|
|
// plain (non-shared) ArrayBuffer.
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
|
|
});
|
|
console.log("[push] created new subscription:", subscription.endpoint);
|
|
}
|
|
|
|
const saveRes = await fetch("/api/push/subscribe", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...subscription.toJSON(), userAgent: navigator.userAgent }),
|
|
});
|
|
if (saveRes.ok) {
|
|
console.log("[push] subscription saved server-side — done");
|
|
} else {
|
|
console.error("[push] failed to save subscription server-side:", saveRes.status, await saveRes.text());
|
|
}
|
|
} catch (err) {
|
|
console.error("[push] ensurePushSubscribed failed:", err);
|
|
}
|
|
}
|