Files
familysync/apps/pwa/src/hooks/usePushSubscription.ts
T
Lucas Berger 982438dc10 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.
2026-06-11 20:35:18 -04:00

341 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 284297)
// ---------------------------------------------------------------------------
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 };