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.
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/**
|
||
* usePushSubscription — push notification subscription lifecycle.
|
||
*
|
||
* Returns { subscribe, unsubscribe, permission, isSubscribed, setEnabled }.
|
||
*
|
||
* 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) AND notifications were not
|
||
* explicitly disabled, silently re-subscribe in the background without user action.
|
||
*
|
||
* localStorage key: notificationsEnabled — '1' after successful subscribe,
|
||
* '0' after explicit user disable. Absence means never configured.
|
||
*
|
||
* setEnabled(true) — if permission 'granted': silently subscribe + set '1'.
|
||
* if permission 'default': no-op; caller must use tap-gated subscribe().
|
||
* if permission 'denied': no-op.
|
||
* setEnabled(false) — unsubscribe + set '0'.
|
||
*/
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns true when the user has explicitly disabled notifications
|
||
* (localStorage.notificationsEnabled === '0').
|
||
*/
|
||
function readNotificationsDisabled(): boolean {
|
||
try {
|
||
return localStorage.getItem('notificationsEnabled') === '0';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function persistNotificationsEnabled(value: boolean): void {
|
||
try {
|
||
if (value) {
|
||
localStorage.setItem('notificationsEnabled', '1');
|
||
} else {
|
||
localStorage.setItem('notificationsEnabled', '0');
|
||
}
|
||
} catch {
|
||
// Private mode / storage disabled — ignore
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Hook
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface UsePushSubscriptionReturn {
|
||
/**
|
||
* Call this inside an onClick handler (no await before the call).
|
||
*
|
||
* CR-04: `vapidKey` must be pre-fetched before the tap (e.g. via prefetchVapidKey()
|
||
* in a useEffect) and passed in here. This eliminates the network round-trip inside
|
||
* the tap handler, preserving the iOS user-gesture requirement for pushManager.subscribe().
|
||
*/
|
||
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>;
|
||
unsubscribe: () => Promise<void>;
|
||
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
|
||
permission: NotificationPermission;
|
||
/** True when an active push subscription exists in pushManager. */
|
||
isSubscribed: boolean;
|
||
/**
|
||
* Master on/off toggle (D-09).
|
||
*
|
||
* setEnabled(true):
|
||
* - permission 'granted': silently subscribes + persists '1'.
|
||
* - permission 'default': no-op; caller must trigger tap-gated subscribe() for the OS dialog.
|
||
* - permission 'denied': no-op; caller shows the permission-denied hint.
|
||
*
|
||
* setEnabled(false):
|
||
* - Calls unsubscribe() (DELETE server + browser) + persists '0'.
|
||
*/
|
||
setEnabled: (on: boolean) => Promise<void>;
|
||
}
|
||
|
||
export function usePushSubscription(): UsePushSubscriptionReturn {
|
||
const [permission, setPermission] = useState<NotificationPermission>(
|
||
typeof Notification !== 'undefined' ? Notification.permission : 'default',
|
||
);
|
||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||
|
||
// Health-check on mount (D-10): if OS permission is granted but no active
|
||
// subscription exists (expired/cleared) AND user hasn't explicitly disabled,
|
||
// 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) {
|
||
// Re-confirm server-side record (handles 410-prune recovery — D-10).
|
||
// The upsert on endpoint is idempotent; this is a no-op if the record exists.
|
||
await fetch('/api/push/subscription', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(existingSub.toJSON()),
|
||
}).catch(() => {}); // non-blocking — ignore network failures
|
||
setIsSubscribed(true);
|
||
return;
|
||
}
|
||
|
||
// No active subscription — only silently re-subscribe if the user
|
||
// hasn't explicitly turned notifications off (D-10).
|
||
if (readNotificationsDisabled()) return;
|
||
|
||
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()),
|
||
});
|
||
persistNotificationsEnabled(true);
|
||
setIsSubscribed(true);
|
||
} 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.
|
||
*
|
||
* CR-04: The VAPID key MUST be pre-fetched before the tap (e.g., via
|
||
* prefetchVapidKey() in a useEffect) and passed in as `vapidKey`. Fetching
|
||
* it here (inside the async function) would insert an await-fetch before
|
||
* pushManager.subscribe() and break the iOS gesture gate on cache miss.
|
||
*/
|
||
const subscribe = async (
|
||
registration: ServiceWorkerRegistration,
|
||
vapidKey: string,
|
||
): Promise<void> => {
|
||
// pushManager.subscribe — synchronous initiation; iOS gesture gate is here.
|
||
// No await before this call: vapidKey is already resolved by the caller.
|
||
// iOS allows the async resolution of PushSubscription after the gesture frame,
|
||
// as long as subscribe() is the first async operation in 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);
|
||
setIsSubscribed(true);
|
||
};
|
||
|
||
/**
|
||
* 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);
|
||
setIsSubscribed(false);
|
||
};
|
||
|
||
/**
|
||
* setEnabled — master on/off for the settings toggle (D-09).
|
||
*
|
||
* on=true + permission granted: silently subscribes (no OS dialog needed).
|
||
* on=true + permission default: no-op (caller triggers tap-gated subscribe()).
|
||
* on=true + permission denied: no-op (caller shows permission-denied hint).
|
||
* on=false: unsubscribes (browser + server DELETE).
|
||
*/
|
||
const setEnabled = async (on: boolean): Promise<void> => {
|
||
if (!on) {
|
||
await unsubscribe();
|
||
return;
|
||
}
|
||
|
||
// on=true path
|
||
const currentPermission =
|
||
typeof Notification !== 'undefined' ? Notification.permission : 'default';
|
||
if (currentPermission !== 'granted') {
|
||
// 'default' → caller must use tap-gated subscribe(); 'denied' → no-op
|
||
return;
|
||
}
|
||
|
||
// Permission is granted — silently subscribe without a tap gesture
|
||
// (already have OS permission, no dialog required).
|
||
if (!navigator.serviceWorker) return;
|
||
try {
|
||
const registration = await navigator.serviceWorker.ready;
|
||
const existingSub = await registration.pushManager.getSubscription();
|
||
if (existingSub) {
|
||
// Already subscribed — just flip the stored flag back on.
|
||
persistNotificationsEnabled(true);
|
||
setIsSubscribed(true);
|
||
return;
|
||
}
|
||
|
||
const vapidKey = await fetchVapidKey();
|
||
const sub = await registration.pushManager.subscribe({
|
||
userVisibleOnly: true,
|
||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||
});
|
||
const res = await fetch('/api/push/subscription', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(sub.toJSON()),
|
||
});
|
||
if (res.ok) {
|
||
persistNotificationsEnabled(true);
|
||
setIsSubscribed(true);
|
||
}
|
||
} catch {
|
||
// Ignore — non-fatal; user can retry via toggle
|
||
}
|
||
};
|
||
|
||
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled };
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read whether the user has previously enabled notifications
|
||
* (localStorage.notificationsEnabled === '1').
|
||
* Exported for use in PermissionDeniedBanner and SettingsSheet initial state.
|
||
*/
|
||
export { readNotificationsEnabled };
|