Files
top-tickets/src/lib/push/client.ts
T
ogrechkoandClaude Sonnet 5 f5e49b5c53 Let users see and unsubscribe their own push devices
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
2026-09-07 06:49:34 +00:00

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);
}
}