/** * usePushNotifications — opt in/out of the SkateSpots push service * (`push-service/`) for this device. Two paths: * * - **Web / PWA** — service worker + VAPID Web Push. * - **Native (Android/iOS)** — FCM via `@capacitor/push-notifications` * (`/native/subscribe` + `/native/unsubscribe`; the plugin is imported lazily * so the web bundle never includes it). * * Both prove ownership of the pubkey with a NIP-98 challenge proof, and the * payload is content-free — the server never decrypts anything. */ import { useCallback, useEffect, useState } from 'react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isNativePlatform, getPlatform } from '@/lib/platform'; import { fetchVapidPublicKey, requestPushChallenge, subscribeToPush, unsubscribeFromPush, subscribeNativePush, unsubscribeNativePush, type PushSubscriptionJSON, } from '@/lib/pushService'; export type PushState = 'unsupported' | 'off' | 'on' | 'working' | 'error'; /** The FCM token of this device (native only), so we can unsubscribe later. */ const FCM_TOKEN_KEY = 'skatespots:fcm-token'; function webPushSupported(): boolean { return ( typeof navigator !== 'undefined' && 'serviceWorker' in navigator && typeof window !== 'undefined' && 'PushManager' in window ); } function urlBase64ToUint8Array(base64: string): Uint8Array { const padding = '='.repeat((4 - (base64.length % 4)) % 4); const normalized = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(normalized); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i); return out; } export function usePushNotifications() { const { user } = useCurrentUser(); const native = isNativePlatform(); const supported = native || webPushSupported(); const [state, setState] = useState(() => (supported ? 'off' : 'unsupported')); const [error, setError] = useState(null); // Reflect an existing subscription on mount / account switch. useEffect(() => { if (native) { setState(localStorage.getItem(FCM_TOKEN_KEY) ? 'on' : 'off'); return; } if (!webPushSupported()) { setState('unsupported'); return; } let cancelled = false; void (async () => { try { const reg = await navigator.serviceWorker.getRegistration(); const sub = await reg?.pushManager.getSubscription(); if (!cancelled) setState(sub ? 'on' : 'off'); } catch { if (!cancelled) setState('off'); } })(); return () => { cancelled = true; }; }, [user?.pubkey, native]); const signProof = useCallback( async (challenge: string) => { if (!user?.signer) throw new Error('Log in first.'); return user.signer.signEvent({ kind: 27235, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['challenge', challenge]], }); }, [user], ); const enableWeb = useCallback(async () => { const permission = await Notification.requestPermission(); if (permission !== 'granted') throw new Error('Notification permission was not granted.'); const reg = await navigator.serviceWorker.register('/push-sw.js'); await navigator.serviceWorker.ready; const vapid = await fetchVapidPublicKey(); const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapid), }); const challenge = await requestPushChallenge(user!.pubkey); const proof = await signProof(challenge); await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof); }, [user, signProof]); const enableNative = useCallback(async () => { const { PushNotifications } = await import('@capacitor/push-notifications'); const permission = await PushNotifications.requestPermissions(); if (permission.receive !== 'granted') throw new Error('Notification permission was not granted.'); await PushNotifications.removeAllListeners(); const token = await new Promise((resolve, reject) => { void (async () => { await PushNotifications.addListener('registration', (t) => resolve(t.value)); await PushNotifications.addListener('registrationError', (e) => reject(new Error(String((e as { error?: string }).error ?? 'Registration failed'))), ); // Tapping the notification deep-links into the app (HashRouter path). await PushNotifications.addListener('pushNotificationActionPerformed', (action) => { const url = (action.notification.data as Record | undefined)?.url; if (url) window.location.href = url; }); await PushNotifications.register(); })().catch(reject); }); localStorage.setItem(FCM_TOKEN_KEY, token); const challenge = await requestPushChallenge(user!.pubkey); const proof = await signProof(challenge); await subscribeNativePush(token, getPlatform(), proof); }, [user, signProof]); const enable = useCallback(async () => { if (!supported) { setState('unsupported'); return; } if (!user?.pubkey || !user.signer) { setError('Log in to enable background notifications.'); setState('error'); return; } setState('working'); setError(null); try { if (native) await enableNative(); else await enableWeb(); setState('on'); } catch (err) { setError(err instanceof Error ? err.message : 'Could not enable background notifications.'); setState('error'); } }, [supported, user, native, enableNative, enableWeb]); const disable = useCallback(async () => { if (!supported || !user?.pubkey) { setState('off'); return; } setState('working'); setError(null); try { if (native) { const token = localStorage.getItem(FCM_TOKEN_KEY); if (token) { const challenge = await requestPushChallenge(user.pubkey); const proof = await signProof(challenge); await unsubscribeNativePush(token, proof).catch(() => { /* best effort */ }); localStorage.removeItem(FCM_TOKEN_KEY); } const { PushNotifications } = await import('@capacitor/push-notifications'); await PushNotifications.removeAllListeners(); } else { const reg = await navigator.serviceWorker.getRegistration(); const sub = await reg?.pushManager.getSubscription(); if (sub) { const challenge = await requestPushChallenge(user.pubkey); const proof = await signProof(challenge); await unsubscribeFromPush(sub.endpoint, proof).catch(() => { /* best effort */ }); await sub.unsubscribe(); } } setState('off'); } catch (err) { setError(err instanceof Error ? err.message : 'Could not disable background notifications.'); setState('error'); } }, [supported, user, native, signProof]); return { state, error, supported, enable, disable }; }