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:
@@ -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,20 +153,29 @@ 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) {
|
||||||
const vapidKey = await fetchVapidKey()
|
setIsSubscribed(true)
|
||||||
const sub = await registration.pushManager.subscribe({
|
return
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
|
||||||
})
|
|
||||||
await fetch('/api/push/subscription', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(sub.toJSON()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 sub = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||||
|
})
|
||||||
|
await fetch('/api/push/subscription', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user