diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index a0fc658..59d6d46 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -22,6 +22,7 @@ import { CalendarShell } from './components/CalendarShell.js' import { ListsIndex } from './routes/ListsIndex.js' import { ListDetail } from './routes/ListDetail.js' import { BottomTabBar } from './components/BottomTabBar.js' +import { PushPermissionPrompt } from './components/PushPermissionPrompt.js' export default function App() { return ( @@ -33,6 +34,9 @@ export default function App() { } /> + {/* Post-install permission prompt (D-08): renders only when isInstalled() is true + and Notification.permission === 'default' and not dismissed */} + ) } diff --git a/apps/pwa/src/components/InstallPrompt.tsx b/apps/pwa/src/components/InstallPrompt.tsx index f19443e..9d61a22 100644 --- a/apps/pwa/src/components/InstallPrompt.tsx +++ b/apps/pwa/src/components/InstallPrompt.tsx @@ -23,6 +23,7 @@ import { useState, useEffect } from 'react' import { Smartphone, X } from 'lucide-react' +import { PushPermissionPrompt } from './PushPermissionPrompt.js' // ── iOS detection ────────────────────────────────────────────────────────── @@ -75,13 +76,19 @@ interface BeforeInstallPromptEvent extends Event { */ export function useAndroidInstallPrompt() { const [deferredPrompt, setDeferredPrompt] = useState(null) + // justInstalled: set to true when the appinstalled event fires, enabling the + // post-install push permission prompt trigger (D-08). + const [justInstalled, setJustInstalled] = useState(false) useEffect(() => { const handler = (e: Event) => { e.preventDefault() setDeferredPrompt(e as BeforeInstallPromptEvent) } - const installedHandler = () => setDeferredPrompt(null) + const installedHandler = () => { + setDeferredPrompt(null) + setJustInstalled(true) + } window.addEventListener('beforeinstallprompt', handler) window.addEventListener('appinstalled', installedHandler) @@ -101,7 +108,7 @@ export function useAndroidInstallPrompt() { } } - return { canInstall: deferredPrompt !== null, triggerInstall } + return { canInstall: deferredPrompt !== null, triggerInstall, justInstalled } } // ── iOS Walkthrough Sheet ───────────────────────────────────────────────── @@ -300,7 +307,7 @@ function persistDismissed(): void { export function InstallPrompt() { const [dismissed, setDismissed] = useState(readDismissed) const [walkthroughOpen, setWalkthroughOpen] = useState(false) - const { canInstall, triggerInstall } = useAndroidInstallPrompt() + const { canInstall, triggerInstall, justInstalled } = useAndroidInstallPrompt() // Re-check isInstalled on mount — matchMedia is only available in the browser const [installed, setInstalled] = useState(false) @@ -308,7 +315,14 @@ export function InstallPrompt() { setInstalled(isInstalled()) }, []) - // Nothing to show when already installed + // Show post-install permission prompt (D-08) immediately after the appinstalled event + // fires — before the page knows it is in standalone mode. This covers Android Chrome + // where the page stays loaded after install without a reload. + if (justInstalled) { + return + } + + // Nothing to show when already installed (App.tsx mounts PushPermissionPrompt there) if (installed) return null function dismiss() { diff --git a/apps/pwa/src/components/PushPermissionPrompt.tsx b/apps/pwa/src/components/PushPermissionPrompt.tsx new file mode 100644 index 0000000..54b7aae --- /dev/null +++ b/apps/pwa/src/components/PushPermissionPrompt.tsx @@ -0,0 +1,314 @@ +/** + * PushPermissionPrompt — post-install permission bottom sheet (D-08, UI-SPEC Surface 1). + * + * Shows after the PWA is installed (isInstalled() === true) and when: + * - Notification.permission === 'default' (not yet asked) + * - The user hasn't dismissed via "Not now" (localStorage pushPermissionDismissed !== '1') + * + * Accessibility: + * - role="dialog", aria-modal="true", aria-labelledby pointing to the heading + * - 48px primary CTA, 44px secondary ghost button + * - Backdrop does NOT dismiss (permission UX must be explicit — per UI-SPEC Surface 1) + * + * Security: T-02d-01 — all text is plain-text JSX children; no dangerouslySetInnerHTML. + * + * iOS constraint (D-08 / CLAUDE.md iOS table): + * subscribe() is called synchronously inside the onClick handler — no await before + * the pushManager.subscribe() call satisfies the iOS user-gesture requirement. + */ + +import { useState, useEffect, useRef } from 'react' +import { Bell, Loader2, X } from 'lucide-react' +import { usePushSubscription, prefetchVapidKey } from '../hooks/usePushSubscription.js' + +// ── installed state check ────────────────────────────────────────────────── + +function isInstalled(): boolean { + return ( + window.matchMedia('(display-mode: standalone)').matches || + (navigator as unknown as { standalone?: boolean }).standalone === true + ) +} + +// ── localStorage guards ──────────────────────────────────────────────────── + +function readDismissed(): boolean { + try { + return localStorage.getItem('pushPermissionDismissed') === '1' + } catch { + return false + } +} + +function persistDismissed(): void { + try { + localStorage.setItem('pushPermissionDismissed', '1') + } catch { + // Private mode / storage disabled — ignore + } +} + +// ── PushPermissionPrompt ─────────────────────────────────────────────────── + +interface PushPermissionPromptProps { + /** Optional callback after the prompt is closed (granted or dismissed) */ + onClose?: () => void +} + +export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) { + const [dismissed, setDismissed] = useState(readDismissed) + const [installed, setInstalled] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const headingId = useRef(`push-prompt-heading-${Math.random().toString(36).slice(2)}`) + + const { subscribe, permission } = usePushSubscription() + + useEffect(() => { + setInstalled(isInstalled()) + }, []) + + // Pre-fetch the VAPID key while the prompt is visible so the tap is instant + useEffect(() => { + if (!installed) return + if (permission !== 'default') return + if (dismissed) return + void prefetchVapidKey() + }, [installed, permission, dismissed]) + + // Don't render when: not installed, already granted/denied, or dismissed + if (!installed) return null + if (permission !== 'default') return null + if (dismissed) return null + + function handleDismiss() { + persistDismissed() + setDismissed(true) + onClose?.() + } + + // onClick handler — subscribe() called synchronously (iOS user-gesture requirement). + // No await before subscribe() in this scope; the async execution starts inside subscribe(). + function handleEnableClick() { + if (loading) return + setLoading(true) + setError(null) + + void (async () => { + try { + const registration = await navigator.serviceWorker.ready + await subscribe(registration) + // On success: close the prompt (permission is now 'granted') + setLoading(false) + onClose?.() + } catch (err) { + setLoading(false) + if ( + err instanceof Error && + err.name === 'NotAllowedError' + ) { + // User denied in the native browser dialog — close the sheet + setDismissed(true) + onClose?.() + } else { + setError('Something went wrong. Please try again.') + } + } + })() + } + + return ( + // Full-screen backdrop — NO backdrop click-dismiss (permission UX must be explicit) +
+ {/* Sheet */} +
+ {/* Header */} +
+

+ Stay in the loop +

+ {/* Dismiss X — "Not now" via header close */} + +
+ + {/* Bell icon + body */} +
+
+ + {/* Error message */} + {error && ( +

+ {error} +

+ )} + + {/* Actions */} +
+ {/* Primary CTA — 48px, accent color */} + + + {/* Secondary — "Not now", 44px ghost */} + +
+
+ + {/* Spin animation for loader */} + +
+ ) +} diff --git a/apps/pwa/src/hooks/usePushSubscription.ts b/apps/pwa/src/hooks/usePushSubscription.ts new file mode 100644 index 0000000..5ebc1ba --- /dev/null +++ b/apps/pwa/src/hooks/usePushSubscription.ts @@ -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 { + 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 { + 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 + unsubscribe: () => Promise + /** Current Notification.permission value ('default' | 'granted' | 'denied') */ + permission: NotificationPermission +} + +export function usePushSubscription(): UsePushSubscriptionReturn { + const [permission, setPermission] = useState( + 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 => { + // 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 => { + 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 { + try { + await fetchVapidKey() + } catch { + // Non-fatal — the subscribe() call will retry if sessionStorage miss + } +}