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:
@@ -22,6 +22,7 @@ import { CalendarShell } from './components/CalendarShell.js'
|
|||||||
import { ListsIndex } from './routes/ListsIndex.js'
|
import { ListsIndex } from './routes/ListsIndex.js'
|
||||||
import { ListDetail } from './routes/ListDetail.js'
|
import { ListDetail } from './routes/ListDetail.js'
|
||||||
import { BottomTabBar } from './components/BottomTabBar.js'
|
import { BottomTabBar } from './components/BottomTabBar.js'
|
||||||
|
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -33,6 +34,9 @@ export default function App() {
|
|||||||
<Route path="/lists/:listId" element={<ListDetail />} />
|
<Route path="/lists/:listId" element={<ListDetail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<BottomTabBar />
|
<BottomTabBar />
|
||||||
|
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||||
|
and Notification.permission === 'default' and not dismissed */}
|
||||||
|
<PushPermissionPrompt />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Smartphone, X } from 'lucide-react'
|
import { Smartphone, X } from 'lucide-react'
|
||||||
|
import { PushPermissionPrompt } from './PushPermissionPrompt.js'
|
||||||
|
|
||||||
// ── iOS detection ──────────────────────────────────────────────────────────
|
// ── iOS detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -75,13 +76,19 @@ interface BeforeInstallPromptEvent extends Event {
|
|||||||
*/
|
*/
|
||||||
export function useAndroidInstallPrompt() {
|
export function useAndroidInstallPrompt() {
|
||||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(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(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
||||||
}
|
}
|
||||||
const installedHandler = () => setDeferredPrompt(null)
|
const installedHandler = () => {
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
setJustInstalled(true)
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', handler)
|
window.addEventListener('beforeinstallprompt', handler)
|
||||||
window.addEventListener('appinstalled', installedHandler)
|
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 ─────────────────────────────────────────────────
|
// ── iOS Walkthrough Sheet ─────────────────────────────────────────────────
|
||||||
@@ -300,7 +307,7 @@ function persistDismissed(): void {
|
|||||||
export function InstallPrompt() {
|
export function InstallPrompt() {
|
||||||
const [dismissed, setDismissed] = useState<boolean>(readDismissed)
|
const [dismissed, setDismissed] = useState<boolean>(readDismissed)
|
||||||
const [walkthroughOpen, setWalkthroughOpen] = useState(false)
|
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
|
// Re-check isInstalled on mount — matchMedia is only available in the browser
|
||||||
const [installed, setInstalled] = useState(false)
|
const [installed, setInstalled] = useState(false)
|
||||||
@@ -308,7 +315,14 @@ export function InstallPrompt() {
|
|||||||
setInstalled(isInstalled())
|
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 <PushPermissionPrompt />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to show when already installed (App.tsx mounts PushPermissionPrompt there)
|
||||||
if (installed) return null
|
if (installed) return null
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
|
|||||||
@@ -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<boolean>(readDismissed)
|
||||||
|
const [installed, setInstalled] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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)
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={headingId.current}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface, #ffffff)',
|
||||||
|
borderRadius: '12px 12px 0 0',
|
||||||
|
padding: 'var(--space-6, 24px)',
|
||||||
|
maxHeight: '90dvh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-4, 16px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id={headingId.current}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--text-heading-size, 18px)',
|
||||||
|
fontWeight: 'var(--text-heading-weight, 600)',
|
||||||
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||||
|
color: 'var(--color-text-primary, #111318)',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stay in the loop
|
||||||
|
</h2>
|
||||||
|
{/* Dismiss X — "Not now" via header close */}
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
aria-label="Dismiss notification prompt"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bell icon + body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3, 12px)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bell
|
||||||
|
size={40}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ color: 'var(--color-member-0, #4A90D9)' }}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
lineHeight: 'var(--text-body-line-height, 1.5)',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Get notified when events are coming up or your family makes changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
color: 'var(--color-destructive, #DC2626)',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-2, 8px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Primary CTA — 48px, accent color */}
|
||||||
|
<button
|
||||||
|
onClick={handleEnableClick}
|
||||||
|
disabled={loading}
|
||||||
|
aria-busy={loading}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-member-0, #4A90D9)',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
minHeight: '48px',
|
||||||
|
padding: '0 var(--space-4, 16px)',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'var(--space-2, 8px)',
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
size={16}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ animation: 'spin 1s linear infinite' }}
|
||||||
|
/>
|
||||||
|
Enabling...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Enable Notifications'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Secondary — "Not now", 44px ghost */}
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
minHeight: '44px',
|
||||||
|
padding: '0 var(--space-4, 16px)',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 400,
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spin animation for loader */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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