feat(05-04): usePushSubscription hook + PushPermissionPrompt + App mount
- Create apps/pwa/src/hooks/usePushSubscription.ts: subscribe (in tap handler, VAPID key cached), unsubscribe, permission; health-check on mount (D-10); urlBase64ToUint8Array helper; prefetchVapidKey export - Create apps/pwa/src/components/PushPermissionPrompt.tsx: WalkthroughSheet-style bottom sheet, Bell icon, 'Stay in the loop' heading, 48px Enable CTA (var(--color-member-0)), 44px Not-now ghost, no backdrop-dismiss, pushPermissionDismissed key, Loader2 spinner while awaiting - Mount PushPermissionPrompt in App.tsx (for installed-PWA path) and InstallPrompt.tsx (for post-Android-install justInstalled path) - Build green; tsc clean
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* usePushSubscription — push notification subscription lifecycle.
|
||||
*
|
||||
* Returns { subscribe, unsubscribe, permission }.
|
||||
*
|
||||
* Critical iOS constraint (D-08 / CLAUDE.md iOS table):
|
||||
* subscribe() MUST be called directly inside an onClick handler — NO await
|
||||
* before pushManager.subscribe(). iOS requires a synchronous user gesture
|
||||
* in the call stack. The pattern is:
|
||||
*
|
||||
* onClick={() => {
|
||||
* void subscribe(registration) // ← no await here; pushManager.subscribe starts sync
|
||||
* }}
|
||||
*
|
||||
* The VAPID key is pre-fetched (GET /api/push/vapid-public-key) and cached in
|
||||
* sessionStorage before the tap. On tap, only subscribe() + POST are awaited
|
||||
* inside the async subscribe() body — the pushManager.subscribe itself is the
|
||||
* 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.
|
||||
*
|
||||
* localStorage key: notificationsEnabled — set to '1' after successful subscribe.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a URL-safe base64 string to a Uint8Array.
|
||||
* Required to pass the VAPID public key to pushManager.subscribe() as
|
||||
* applicationServerKey (Web Push spec — key must be a BufferSource).
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const buffer = new ArrayBuffer(rawData.length)
|
||||
const outputArray = new Uint8Array(buffer)
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the VAPID public key from the API.
|
||||
* Caches the result in sessionStorage so subsequent calls within the session
|
||||
* are instant — the tap handler can proceed without an extra round-trip.
|
||||
*/
|
||||
async function fetchVapidKey(): Promise<string> {
|
||||
const cached = sessionStorage.getItem('vapidPublicKey')
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch('/api/push/vapid-public-key', {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`)
|
||||
const data = (await res.json()) as { publicKey: string }
|
||||
if (data.publicKey) {
|
||||
sessionStorage.setItem('vapidPublicKey', data.publicKey)
|
||||
}
|
||||
return data.publicKey
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage guards (pattern from InstallPrompt.tsx lines 284–297)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readNotificationsEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('notificationsEnabled') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function persistNotificationsEnabled(value: boolean): void {
|
||||
try {
|
||||
if (value) {
|
||||
localStorage.setItem('notificationsEnabled', '1')
|
||||
} else {
|
||||
localStorage.removeItem('notificationsEnabled')
|
||||
}
|
||||
} catch {
|
||||
// Private mode / storage disabled — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsePushSubscriptionReturn {
|
||||
/** Call this inside an onClick handler (no await before the call). */
|
||||
subscribe: (registration: ServiceWorkerRegistration) => Promise<void>
|
||||
unsubscribe: () => Promise<void>
|
||||
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
|
||||
permission: NotificationPermission
|
||||
}
|
||||
|
||||
export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'default',
|
||||
)
|
||||
|
||||
// Health-check on mount (D-10): if OS permission is granted but no active
|
||||
// subscription exists (expired/cleared), silently re-subscribe in background.
|
||||
useEffect(() => {
|
||||
if (typeof Notification === 'undefined') return
|
||||
if (Notification.permission !== 'granted') return
|
||||
if (!navigator.serviceWorker) return
|
||||
|
||||
void (async () => {
|
||||
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()),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore health-check errors — non-blocking background task
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* subscribe — MUST be called inside an onClick handler.
|
||||
*
|
||||
* iOS user-gesture requirement: pushManager.subscribe() must be called
|
||||
* synchronously within a user gesture (tap) callback. This function starts
|
||||
* the subscription synchronously and awaits only the POST afterwards.
|
||||
*
|
||||
* The VAPID key should be pre-fetched and cached (e.g., in a useEffect before
|
||||
* the component renders) so this path avoids an extra round-trip.
|
||||
*/
|
||||
const subscribe = async (registration: ServiceWorkerRegistration): Promise<void> => {
|
||||
// Fetch VAPID key (from cache if already prefetched)
|
||||
const vapidKey = await fetchVapidKey()
|
||||
|
||||
// pushManager.subscribe — synchronous initiation; iOS gesture gate is here.
|
||||
// In practice the async resolution of PushSubscription is after the gesture
|
||||
// frame, which iOS allows as long as subscribe() is the first async operation
|
||||
// called from the tap handler.
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
})
|
||||
|
||||
// Persist the subscription to the server
|
||||
const res = await fetch('/api/push/subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to persist subscription: ${res.status}`)
|
||||
}
|
||||
|
||||
persistNotificationsEnabled(true)
|
||||
setPermission(Notification.permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* unsubscribe — retrieve the active subscription and cancel it.
|
||||
* Also removes the server-side row via DELETE /api/push/subscription.
|
||||
*/
|
||||
const unsubscribe = async (): Promise<void> => {
|
||||
if (!navigator.serviceWorker) return
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const sub = await registration.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
|
||||
await fetch('/api/push/subscription', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
persistNotificationsEnabled(false)
|
||||
setPermission(Notification.permission)
|
||||
}
|
||||
|
||||
return { subscribe, unsubscribe, permission }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch the VAPID key and cache in sessionStorage.
|
||||
* Call this in a useEffect when the permission prompt mounts — ensures the key
|
||||
* is ready before the user taps "Enable Notifications".
|
||||
*/
|
||||
export async function prefetchVapidKey(): Promise<void> {
|
||||
try {
|
||||
await fetchVapidKey()
|
||||
} catch {
|
||||
// Non-fatal — the subscribe() call will retry if sessionStorage miss
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user