Milestone v1.0: FamilySync MVP #1
@@ -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<void>
|
||||
/** 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<void>
|
||||
}
|
||||
|
||||
export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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