main
/**
* 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<ArrayBuffer> {
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<ServiceWorkerRegistration> {
return navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' })
}
/** The browser's current subscription for this origin, if any. */
export async function currentSubscription(): Promise<PushSubscription | null> {
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<PushPermission> {
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<void> {
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<void> {
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<void> {
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,
}),
})
}