style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -17,26 +17,26 @@
|
||||
* 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'
|
||||
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 }
|
||||
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)
|
||||
sessionStorage.setItem('vapidPublicKey', data.publicKey);
|
||||
}
|
||||
return data.publicKey ?? null
|
||||
return data.publicKey ?? null;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,22 +46,22 @@ 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'
|
||||
return localStorage.getItem('pushPermissionDismissed') === '1';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissed(): void {
|
||||
try {
|
||||
localStorage.setItem('pushPermissionDismissed', '1')
|
||||
localStorage.setItem('pushPermissionDismissed', '1');
|
||||
} catch {
|
||||
// Private mode / storage disabled — ignore
|
||||
}
|
||||
@@ -71,66 +71,66 @@ function persistDismissed(): void {
|
||||
|
||||
interface PushPermissionPromptProps {
|
||||
/** Optional callback after the prompt is closed (granted or dismissed) */
|
||||
onClose?: () => void
|
||||
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 [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)
|
||||
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 [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
||||
const headingId = useId();
|
||||
|
||||
const { subscribe, permission } = usePushSubscription()
|
||||
const { subscribe, permission } = usePushSubscription();
|
||||
|
||||
useEffect(() => {
|
||||
setInstalled(isInstalled())
|
||||
}, [])
|
||||
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
|
||||
if (!installed) return;
|
||||
if (permission !== 'default') return;
|
||||
if (dismissed) return;
|
||||
void fetchVapidKeyForPrompt().then((key) => {
|
||||
if (key) setVapidKey(key)
|
||||
})
|
||||
}, [installed, permission, dismissed])
|
||||
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
|
||||
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])
|
||||
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
|
||||
if (!installed) return null;
|
||||
if (permission !== 'default') return null;
|
||||
if (dismissed) return null;
|
||||
|
||||
function handleDismiss() {
|
||||
persistDismissed()
|
||||
setDismissed(true)
|
||||
onClose?.()
|
||||
persistDismissed();
|
||||
setDismissed(true);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
// onClick handler — subscribe() called synchronously (iOS user-gesture requirement).
|
||||
@@ -138,34 +138,31 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
|
||||
// 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)
|
||||
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
|
||||
const resolvedVapidKey = vapidKey;
|
||||
const resolvedRegistration = swRegistration;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await subscribe(resolvedRegistration, resolvedVapidKey)
|
||||
await subscribe(resolvedRegistration, resolvedVapidKey);
|
||||
// On success: close the prompt (permission is now 'granted')
|
||||
setLoading(false)
|
||||
onClose?.()
|
||||
setLoading(false);
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setLoading(false)
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.name === 'NotAllowedError'
|
||||
) {
|
||||
setLoading(false);
|
||||
if (err instanceof Error && err.name === 'NotAllowedError') {
|
||||
// User denied in the native browser dialog — close the sheet
|
||||
setDismissed(true)
|
||||
onClose?.()
|
||||
setDismissed(true);
|
||||
onClose?.();
|
||||
} else {
|
||||
setError('Something went wrong. Please try again.')
|
||||
setError('Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
})()
|
||||
})();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -250,11 +247,7 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Bell
|
||||
size={40}
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-member-0, #4A90D9)' }}
|
||||
/>
|
||||
<Bell size={40} aria-hidden="true" style={{ color: 'var(--color-member-0, #4A90D9)' }} />
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
@@ -353,7 +346,6 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user