Notification preferences API and screen (1n) (Feature 19) (#166)
CI / ci (push) Successful in 5m53s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #166.
This commit is contained in:
2026-10-09 15:19:36 -04:00
committed by approver_agent
parent fc5b926edf
commit bbe031ec8d
25 changed files with 2068 additions and 113 deletions
+29
View File
@@ -6,6 +6,7 @@ import {
decodeVapidKey,
encodeBase64url,
PermissionGestureError,
pushStatus,
subscribePush,
subscriptionBody,
unsubscribePush,
@@ -306,3 +307,31 @@ describe('unsubscribePush', () => {
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
})
})
describe('pushStatus', () => {
it('is on with permission granted and a subscription', async () => {
const { env } = setup({ permission: 'granted', current: subscription() })
expect(await pushStatus(env)).toBe('on')
})
it.each([
['not subscribed', { permission: 'granted' as const }],
['permission not yet asked', { permission: 'default' as const }],
['no service worker registered', { permission: 'granted' as const, registered: false }],
])('is off when %s', async (_, options) => {
const { env, notification } = setup(options)
expect(await pushStatus(env)).toBe('off')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('is denied when the browser blocks notifications', async () => {
const { env } = setup({ permission: 'denied', current: subscription() })
expect(await pushStatus(env)).toBe('denied')
})
it('is unsupported without Web Push', async () => {
const { env } = setup()
expect(await pushStatus({ ...env, pushManager: false })).toBe('unsupported')
expect(await pushStatus({ ...env, notification: undefined })).toBe('unsupported')
})
})
+18
View File
@@ -19,6 +19,11 @@ export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsup
// to remove, or a browser without Web Push. Any other failure rejects.
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
// PushStatus is whether Web Push is on for this device: subscribed with
// permission granted, off (not subscribed, or permission not yet given),
// blocked by the browser's permission, or a browser without Web Push.
export type PushStatus = 'on' | 'off' | 'denied' | 'unsupported'
// The parts of the browser's Push API this module uses.
export type BrowserPushSubscription = {
readonly endpoint: string
@@ -207,3 +212,16 @@ export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promi
await removeSubscription(api, subscription)
return 'unsubscribed'
}
// pushStatus reads whether Web Push is on for this device, without asking for
// permission or calling the server. A page with no service worker registered
// is off: subscribePush then says why it cannot turn on.
export async function pushStatus(env: PushEnvironment): Promise<PushStatus> {
const { serviceWorker, notification } = env
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
if (notification.permission === 'denied') return 'denied'
const found = await serviceWorker.getRegistration()
if (found === undefined) return 'off'
const subscription = await found.pushManager.getSubscription()
return subscription !== null && notification.permission === 'granted' ? 'on' : 'off'
}
+7 -2
View File
@@ -2,25 +2,30 @@ import { useMemo } from 'react'
import { useApi } from '../api/ApiContext.tsx'
import {
browserPushEnvironment,
pushStatus,
subscribePush,
unsubscribePush,
type PushStatus,
type SubscribePushResult,
type UnsubscribePushResult,
} from './subscribe.ts'
export type Push = {
// pushStatus reads whether Web Push is on for this device.
pushStatus(): Promise<PushStatus>
// subscribePush turns on Web Push on this device; call it only from a tap.
subscribePush(): Promise<SubscribePushResult>
// unsubscribePush turns off Web Push on this device.
unsubscribePush(): Promise<UnsubscribePushResult>
}
// usePush binds subscribePush and unsubscribePush to the app's API client
// and this browser.
// usePush binds pushStatus, subscribePush and unsubscribePush to the app's
// API client and this browser.
export function usePush(): Push {
const api = useApi()
return useMemo(
() => ({
pushStatus: () => pushStatus(browserPushEnvironment()),
subscribePush: () => subscribePush(api, browserPushEnvironment()),
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
}),