SkateSpots/src/hooks/usePushNotifications.ts
SkateSpots 5f0a9537b0 feat(push): Android FCM (Phase 2) - native device support
Server: /native/subscribe + /native/unsubscribe (NIP-98 proof), FCM device store,
and FCM HTTP v1 sending (service-account OAuth2, cached ~1h); /health reports
native + device count. 503 without PUSH_FIREBASE_SERVICE_ACCOUNT_JSON.
Client: @capacitor/push-notifications (lazily imported, native-only) in
usePushNotifications - request permission, register, store the FCM token, and
subscribe via /native/subscribe; tap deep-links into the app.
Needs a Firebase project: google-services.json + the Gradle plugin (android/),
and the service-account JSON in the host .env.
2026-10-01 19:28:54 +07:00

202 lines
7 KiB
TypeScript

/**
* 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<PushState>(() => (supported ? 'off' : 'unsupported'));
const [error, setError] = useState<string | null>(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<string>((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<string, string> | 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 };
}