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.
202 lines
7 KiB
TypeScript
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 };
|
|
}
|