Files
familysync/apps/pwa/src/components/PushPermissionPrompt.tsx
T
Lucas Berger c7ef5811d1 fix(05-review): NEW-CR-01 pre-resolve SW registration to eliminate await before pushManager.subscribe
Pre-fetch ServiceWorkerRegistration into component state via useEffect in both
PushPermissionPrompt and SettingsSheet. Gate the subscribe tap target as disabled
until both vapidKey AND swRegistration are ready. The tap handler now has zero
awaits between the user gesture and registration.pushManager.subscribe(), fully
satisfying the iOS user-gesture requirement.
2026-06-09 22:39:05 -04:00

367 lines
12 KiB
TypeScript

/**
* 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, useId } from 'react'
import { Bell, Loader2, X } from 'lucide-react'
import { usePushSubscription } from '../hooks/usePushSubscription.js'
// fetchVapidKey is the internal helper; we import it directly via the module
// rather than re-exporting it through usePushSubscription, since we need to
// store the resolved key in state (not just warm the cache).
async function fetchVapidKeyForPrompt(): Promise<string | null> {
try {
const cached = sessionStorage.getItem('vapidPublicKey')
if (cached) return cached
const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' })
if (!res.ok) return null
const data = (await res.json()) as { publicKey: string }
if (data.publicKey) {
sessionStorage.setItem('vapidPublicKey', data.publicKey)
}
return data.publicKey ?? null
} catch {
return null
}
}
// ── 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)
// CR-04: Pre-fetch the VAPID key into state so the tap handler can call
// subscribe(registration, vapidKey) without any await before pushManager.subscribe().
// Button is disabled until the key is ready (null = not yet loaded).
const [vapidKey, setVapidKey] = useState<string | null>(null)
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the tap
// handler has ZERO awaits between the user gesture and pushManager.subscribe().
// Any await (including navigator.serviceWorker.ready) between the tap and
// pushManager.subscribe() breaks the iOS user-gesture requirement.
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
const headingId = useId()
const { subscribe, permission } = usePushSubscription()
useEffect(() => {
setInstalled(isInstalled())
}, [])
// Pre-fetch the VAPID key into state while the prompt is visible.
// CR-04: We store the resolved key in state (not just sessionStorage) so the
// tap handler has synchronous access — no network await inside the tap path.
useEffect(() => {
if (!installed) return
if (permission !== 'default') return
if (dismissed) return
void fetchVapidKeyForPrompt().then((key) => {
if (key) setVapidKey(key)
})
}, [installed, permission, dismissed])
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration in a useEffect so the
// tap handler never has to await navigator.serviceWorker.ready.
// navigator.serviceWorker.ready resolves once the SW is active; doing this
// eagerly means the result is in state before the user can tap the button.
useEffect(() => {
if (!installed) return
if (permission !== 'default') return
if (dismissed) return
if (!navigator.serviceWorker) return
void navigator.serviceWorker.ready.then((reg) => {
setSwRegistration(reg)
})
}, [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).
// NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved in state (useEffects above).
// There is ZERO await between the tap gesture and registration.pushManager.subscribe()
// inside subscribe() — the iOS gesture gate is fully satisfied.
function handleEnableClick() {
if (loading || !vapidKey || !swRegistration) return
setLoading(true)
setError(null)
// Capture both synchronously — no await in this scope before subscribe().
const resolvedVapidKey = vapidKey
const resolvedRegistration = swRegistration
void (async () => {
try {
await subscribe(resolvedRegistration, resolvedVapidKey)
// 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}
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}
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 */}
{/* NEW-CR-01: disabled until BOTH vapidKey and swRegistration are ready */}
<button
onClick={handleEnableClick}
disabled={loading || !vapidKey || !swRegistration}
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>
)
}