feat(05-08): extend usePushSubscription with isSubscribed, setEnabled, permission state (D-10)

- Add isSubscribed state (true when pushManager has active subscription)
- Add setEnabled(on) master toggle: off=unsubscribe, on+granted=silent subscribe, on+default/denied=no-op
- Health-check now respects readNotificationsDisabled() — skip re-subscribe if user explicitly disabled
- Export readNotificationsEnabled for PermissionDeniedBanner/SettingsSheet initial state
- Remove dead readNotificationsEnabled local-only usage (was unused in returned interface)
- persistNotificationsEnabled(false) now writes '0' instead of removing key for explicit off state
This commit is contained in:
Lucas Berger
2026-06-09 21:43:31 -04:00
parent 8e741cf528
commit 458d6e4fef
+117 -10
View File
@@ -1,7 +1,7 @@
/** /**
* usePushSubscription — push notification subscription lifecycle. * usePushSubscription — push notification subscription lifecycle.
* *
* Returns { subscribe, unsubscribe, permission }. * Returns { subscribe, unsubscribe, permission, isSubscribed, setEnabled }.
* *
* Critical iOS constraint (D-08 / CLAUDE.md iOS table): * Critical iOS constraint (D-08 / CLAUDE.md iOS table):
* subscribe() MUST be called directly inside an onClick handler — NO await * subscribe() MUST be called directly inside an onClick handler — NO await
@@ -18,10 +18,16 @@
* synchronous first call, satisfying the iOS gesture requirement. * synchronous first call, satisfying the iOS gesture requirement.
* *
* Health-check on mount (D-10): if OS permission is still 'granted' but there * Health-check on mount (D-10): if OS permission is still 'granted' but there
* is no active subscription (expired/cleared), silently re-subscribe in the * is no active subscription (expired/cleared) AND notifications were not
* background without user action. * explicitly disabled, silently re-subscribe in the background without user action.
* *
* localStorage key: notificationsEnabled — set to '1' after successful subscribe. * localStorage key: notificationsEnabled — '1' after successful subscribe,
* '0' after explicit user disable. Absence means never configured.
*
* setEnabled(true) — if permission 'granted': silently subscribe + set '1'.
* if permission 'default': no-op; caller must use tap-gated subscribe().
* if permission 'denied': no-op.
* setEnabled(false) — unsubscribe + set '0'.
*/ */
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
@@ -79,12 +85,24 @@ function readNotificationsEnabled(): boolean {
} }
} }
/**
* Returns true when the user has explicitly disabled notifications
* (localStorage.notificationsEnabled === '0').
*/
function readNotificationsDisabled(): boolean {
try {
return localStorage.getItem('notificationsEnabled') === '0'
} catch {
return false
}
}
function persistNotificationsEnabled(value: boolean): void { function persistNotificationsEnabled(value: boolean): void {
try { try {
if (value) { if (value) {
localStorage.setItem('notificationsEnabled', '1') localStorage.setItem('notificationsEnabled', '1')
} else { } else {
localStorage.removeItem('notificationsEnabled') localStorage.setItem('notificationsEnabled', '0')
} }
} catch { } catch {
// Private mode / storage disabled — ignore // Private mode / storage disabled — ignore
@@ -101,15 +119,31 @@ export interface UsePushSubscriptionReturn {
unsubscribe: () => Promise<void> unsubscribe: () => Promise<void>
/** Current Notification.permission value ('default' | 'granted' | 'denied') */ /** Current Notification.permission value ('default' | 'granted' | 'denied') */
permission: NotificationPermission permission: NotificationPermission
/** True when an active push subscription exists in pushManager. */
isSubscribed: boolean
/**
* Master on/off toggle (D-09).
*
* setEnabled(true):
* - permission 'granted': silently subscribes + persists '1'.
* - permission 'default': no-op; caller must trigger tap-gated subscribe() for the OS dialog.
* - permission 'denied': no-op; caller shows the permission-denied hint.
*
* setEnabled(false):
* - Calls unsubscribe() (DELETE server + browser) + persists '0'.
*/
setEnabled: (on: boolean) => Promise<void>
} }
export function usePushSubscription(): UsePushSubscriptionReturn { export function usePushSubscription(): UsePushSubscriptionReturn {
const [permission, setPermission] = useState<NotificationPermission>( const [permission, setPermission] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'default', typeof Notification !== 'undefined' ? Notification.permission : 'default',
) )
const [isSubscribed, setIsSubscribed] = useState(false)
// Health-check on mount (D-10): if OS permission is granted but no active // Health-check on mount (D-10): if OS permission is granted but no active
// subscription exists (expired/cleared), silently re-subscribe in background. // subscription exists (expired/cleared) AND user hasn't explicitly disabled,
// silently re-subscribe in background.
useEffect(() => { useEffect(() => {
if (typeof Notification === 'undefined') return if (typeof Notification === 'undefined') return
if (Notification.permission !== 'granted') return if (Notification.permission !== 'granted') return
@@ -119,8 +153,16 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
try { try {
const registration = await navigator.serviceWorker.ready const registration = await navigator.serviceWorker.ready
const existingSub = await registration.pushManager.getSubscription() const existingSub = await registration.pushManager.getSubscription()
if (!existingSub) {
// No active subscription — silently re-subscribe (D-10) if (existingSub) {
setIsSubscribed(true)
return
}
// No active subscription — only silently re-subscribe if the user
// hasn't explicitly turned notifications off (D-10).
if (readNotificationsDisabled()) return
const vapidKey = await fetchVapidKey() const vapidKey = await fetchVapidKey()
const sub = await registration.pushManager.subscribe({ const sub = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
@@ -132,7 +174,8 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
credentials: 'include', credentials: 'include',
body: JSON.stringify(sub.toJSON()), body: JSON.stringify(sub.toJSON()),
}) })
} persistNotificationsEnabled(true)
setIsSubscribed(true)
} catch { } catch {
// Ignore health-check errors — non-blocking background task // Ignore health-check errors — non-blocking background task
} }
@@ -176,6 +219,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
persistNotificationsEnabled(true) persistNotificationsEnabled(true)
setPermission(Notification.permission) setPermission(Notification.permission)
setIsSubscribed(true)
} }
/** /**
@@ -198,9 +242,65 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
persistNotificationsEnabled(false) persistNotificationsEnabled(false)
setPermission(Notification.permission) setPermission(Notification.permission)
setIsSubscribed(false)
} }
return { subscribe, unsubscribe, permission } /**
* setEnabled — master on/off for the settings toggle (D-09).
*
* on=true + permission granted: silently subscribes (no OS dialog needed).
* on=true + permission default: no-op (caller triggers tap-gated subscribe()).
* on=true + permission denied: no-op (caller shows permission-denied hint).
* on=false: unsubscribes (browser + server DELETE).
*/
const setEnabled = async (on: boolean): Promise<void> => {
if (!on) {
await unsubscribe()
return
}
// on=true path
const currentPermission =
typeof Notification !== 'undefined' ? Notification.permission : 'default'
if (currentPermission !== 'granted') {
// 'default' → caller must use tap-gated subscribe(); 'denied' → no-op
return
}
// Permission is granted — silently subscribe without a tap gesture
// (already have OS permission, no dialog required).
if (!navigator.serviceWorker) return
try {
const registration = await navigator.serviceWorker.ready
const existingSub = await registration.pushManager.getSubscription()
if (existingSub) {
// Already subscribed — just flip the stored flag back on.
persistNotificationsEnabled(true)
setIsSubscribed(true)
return
}
const vapidKey = await fetchVapidKey()
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
const res = await fetch('/api/push/subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(sub.toJSON()),
})
if (res.ok) {
persistNotificationsEnabled(true)
setIsSubscribed(true)
}
} catch {
// Ignore — non-fatal; user can retry via toggle
}
}
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled }
} }
/** /**
@@ -215,3 +315,10 @@ export async function prefetchVapidKey(): Promise<void> {
// Non-fatal — the subscribe() call will retry if sessionStorage miss // Non-fatal — the subscribe() call will retry if sessionStorage miss
} }
} }
/**
* Read whether the user has previously enabled notifications
* (localStorage.notificationsEnabled === '1').
* Exported for use in PermissionDeniedBanner and SettingsSheet initial state.
*/
export { readNotificationsEnabled }