From 458d6e4fef31c63eddb198b396a29611863e4731 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 21:43:31 -0400 Subject: [PATCH] feat(05-08): extend usePushSubscription with isSubscribed, setEnabled, permission state (D-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/pwa/src/hooks/usePushSubscription.ts | 147 +++++++++++++++++++--- 1 file changed, 127 insertions(+), 20 deletions(-) diff --git a/apps/pwa/src/hooks/usePushSubscription.ts b/apps/pwa/src/hooks/usePushSubscription.ts index 5ebc1ba..1714fb6 100644 --- a/apps/pwa/src/hooks/usePushSubscription.ts +++ b/apps/pwa/src/hooks/usePushSubscription.ts @@ -1,7 +1,7 @@ /** * usePushSubscription — push notification subscription lifecycle. * - * Returns { subscribe, unsubscribe, permission }. + * Returns { subscribe, unsubscribe, permission, isSubscribed, setEnabled }. * * Critical iOS constraint (D-08 / CLAUDE.md iOS table): * subscribe() MUST be called directly inside an onClick handler — NO await @@ -18,10 +18,16 @@ * synchronous first call, satisfying the iOS gesture requirement. * * 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 - * background without user action. + * is no active subscription (expired/cleared) AND notifications were not + * 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' @@ -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 { try { if (value) { localStorage.setItem('notificationsEnabled', '1') } else { - localStorage.removeItem('notificationsEnabled') + localStorage.setItem('notificationsEnabled', '0') } } catch { // Private mode / storage disabled — ignore @@ -101,15 +119,31 @@ export interface UsePushSubscriptionReturn { unsubscribe: () => Promise /** Current Notification.permission value ('default' | 'granted' | 'denied') */ 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 } export function usePushSubscription(): UsePushSubscriptionReturn { const [permission, setPermission] = useState( 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 - // 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(() => { if (typeof Notification === 'undefined') return if (Notification.permission !== 'granted') return @@ -119,20 +153,29 @@ export function usePushSubscription(): UsePushSubscriptionReturn { try { const registration = await navigator.serviceWorker.ready const existingSub = await registration.pushManager.getSubscription() - if (!existingSub) { - // No active subscription — silently re-subscribe (D-10) - 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()), - }) + + 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 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 { // Ignore health-check errors — non-blocking background task } @@ -176,6 +219,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn { persistNotificationsEnabled(true) setPermission(Notification.permission) + setIsSubscribed(true) } /** @@ -198,9 +242,65 @@ export function usePushSubscription(): UsePushSubscriptionReturn { persistNotificationsEnabled(false) 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 => { + 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 { // 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 }