/** * Subscribing this browser to the instance's notifications. * * A push arrives through the service worker, so it reaches a phone with no tab * open — which is the whole point, and the reason this is not the in-app * notification stack. The engine decides *what* is worth sending in its alert * rules; this only says which browsers hear it. * * Everything here is per browser and per instance: the subscription is * stored by the instance it was made against, so a phone that opens two * houses through the portal is two subscriptions and hears each separately. */ import { AlertsService } from "@/client" import { appPath } from "@/lib/portal" /** Whether this browser can do push at all. * * Plain HTTP over a LAN cannot: service workers need a secure context, and * `http://…local` addresses are not one. Safari delivers push only to an * installed PWA, but says so itself by refusing the permission, so there is * nothing to detect here. */ export function supported(): boolean { return ( typeof window !== "undefined" && window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window ) } /** Register the worker, or reuse the registration this browser already has. */ async function worker(): Promise { // The scope comes from where the file is served, which is the app's root in // both contexts — `/sw.js` alone, `/i/{id}/sw.js` through the portal. return navigator.serviceWorker.register(appPath("/sw.js")) } /** Start the worker in the background; a push cannot arrive without it. */ export function registerSW(): void { if (!supported()) return worker().catch((error) => console.warn("No service worker:", error)) } /** The subscription this browser already holds here, if any. */ export async function current(): Promise { if (!supported()) return null const registration = await navigator.serviceWorker.getRegistration( appPath("/sw.js"), ) return (await registration?.pushManager.getSubscription()) ?? null } /** A base64url key as the `applicationServerKey` bytes `subscribe` wants. */ function keyBytes(key: string): Uint8Array { const padded = key.replace(/-/g, "+").replace(/_/g, "/") const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4)) const bytes = new Uint8Array(new ArrayBuffer(raw.length)) for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i) return bytes } /** * Ask permission, subscribe, and tell the instance where to reach us. * * Throws with something worth reading if the person says no — the caller puts * it on screen, because a silently ignored button is worse than a refusal. */ export async function subscribe(): Promise { if (!supported()) throw new Error("This browser cannot receive notifications") const permission = await Notification.requestPermission() if (permission !== "granted") { throw new Error( permission === "denied" ? "Notifications are blocked for this site in the browser's settings" : "Notifications were not allowed", ) } const registration = await worker() await navigator.serviceWorker.ready const { key } = await AlertsService.readWebpushKey() const subscription = (await registration.pushManager.getSubscription()) ?? (await registration.pushManager.subscribe({ // Every push carries a payload, and a browser will not deliver one it // cannot show; both Chrome and Firefox require this to be true anyway. userVisibleOnly: true, applicationServerKey: keyBytes(key), })) const { endpoint, keys } = subscription.toJSON() as { endpoint: string keys: Record } await AlertsService.addWebpushSubscription({ requestBody: { endpoint, keys }, }) } /** Stop this browser hearing about it, here and at the push service. */ export async function unsubscribe(): Promise { const subscription = await current() if (!subscription) return await AlertsService.removeWebpushSubscription({ requestBody: { endpoint: subscription.endpoint }, }) await subscription.unsubscribe() }