/** * Browser system-notification plumbing: Service Worker registration, the * `PushSubscription` lifecycle, and the server-side subscription row. * * The module is deliberately framework-free. Features must not import each * other, and both the settings section and any future "ask me again" surface * need the same three operations, so they live here rather than in the UI. */ import { unwrapApi } from '@app/lib' import type { PushSubscriptionPlatform } from '@app/services/api' import Apis, { trackFetch } from '@app/services/api' const SERVICE_WORKER_URL = '/sw.js' export type PushPermission = 'unsupported' | 'default' | 'granted' | 'denied' /** * `user_preferences.push_flags` bits, mirrored from `export::push`. * * The mask is a *disable* mask: the server treats a set bit as "do not push this * category", so the stored default of `0` keeps everything on for accounts that * never opened this page. */ export const PUSH_FLAG = { mention: 1 << 0, reply: 1 << 1, dm: 1 << 2, reminder: 1 << 3, } as const /** What the browser can actually do, independent of the server-side config. */ export function pushSupport(): PushPermission { if (typeof window === 'undefined') return 'unsupported' if (!('serviceWorker' in navigator) || !('PushManager' in window)) return 'unsupported' if (!('Notification' in window)) return 'unsupported' return Notification.permission as PushPermission } /** * base64url → bytes; `applicationServerKey` rejects a plain string. * * The buffer is allocated explicitly (`new ArrayBuffer`) instead of letting * `Uint8Array` pick one: the DOM type only accepts a view over a real * `ArrayBuffer`, while a bare `Uint8Array` is typed against `ArrayBufferLike` * (which also admits `SharedArrayBuffer`). */ function decodeBase64Url(value: string): Uint8Array { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=') const binary = atob(padded) const buffer = new ArrayBuffer(binary.length) const bytes = new Uint8Array(buffer) for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) return bytes } async function serviceWorkerRegistration(): Promise { return navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' }) } /** The browser's current subscription for this origin, if any. */ export async function currentSubscription(): Promise { if (pushSupport() !== 'granted') return null const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_URL) if (!registration) return null return registration.pushManager.getSubscription() } /** * Ask for permission and register this device. * * Returns `'denied'` when the user declines rather than throwing: a refusal is a * normal outcome of a click, not an error to surface as a failure banner. The * server-side config is checked first so a disabled deployment never triggers a * permission prompt. */ export async function enablePush(publicKey: string): Promise { const support = pushSupport() if (support === 'unsupported') return 'unsupported' const permission = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission() if (permission !== 'granted') return permission as PushPermission const registration = await serviceWorkerRegistration() await navigator.serviceWorker.ready const existing = await registration.pushManager.getSubscription() const subscription = existing ?? (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodeBase64Url(publicKey), })) await upsertSubscription(subscription) return 'granted' } /** Unsubscribe this device and drop the matching server row. */ export async function disablePush(): Promise { const subscription = await currentSubscription() if (!subscription) return const endpoint = subscription.endpoint await subscription.unsubscribe() const rows = await listSubscriptions() const row = rows.find((item) => item.endpoint === endpoint) if (row) await unwrapApi(Apis.user.delete_push_subscription({ pathParams: { push_id: row.id } })) } /** Re-register an existing browser subscription after the server lost the row. */ export async function syncExistingSubscription(): Promise { const subscription = await currentSubscription() if (subscription) await upsertSubscription(subscription) } async function listSubscriptions() { const page = await unwrapApi<{ items?: { id: string; endpoint: string }[] }>( Apis.user.list_push_subscriptions({ params: { limit: 100 } }), ) return page?.items ?? [] } /** * The generated client types `platform` as an open string union, so the value is * pinned here against the backend enum (`web_push`); the other two platforms are * reserved for the mobile apps and never produced by the browser. */ async function upsertSubscription(subscription: PushSubscription): Promise { const json = subscription.toJSON() const platform: PushSubscriptionPlatform = 'WebPush' await trackFetch('/api/v1/me/push-subscriptions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint, p256dh_key: json.keys?.p256dh ?? '', auth_key: json.keys?.auth ?? '', platform, user_agent: navigator.userAgent, }), }) }