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.
This commit is contained in:
parent
4b831ebcc9
commit
5f0a9537b0
8 changed files with 308 additions and 44 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -786,8 +786,16 @@ session with **no other memory of this project**. So:
|
|||
`https://push.skate-spots.org/health` → 200. ⚠️ 000 from this box = a **stale
|
||||
local resolver cache** (NXDOMAIN); confirm with
|
||||
`curl --resolve push.skate-spots.org:443:185.18.221.2 …`. **Remaining:**
|
||||
Android FCM (Phase 2). ⚠️ Web push works in browsers; the Capacitor WebView
|
||||
needs FCM for the APK. (Approach + tradeoffs came from the
|
||||
Android FCM (**Phase 2 — code landed 2026-10-01**): the service has
|
||||
`/native/subscribe` + `/native/unsubscribe` (same NIP-98 proof) and sends via
|
||||
**FCM HTTP v1** (service-account OAuth2, token cached ~1h); client uses
|
||||
`@capacitor/push-notifications` (lazily imported, native-only) in
|
||||
`usePushNotifications`, storing the FCM token in `skatespots:fcm-token`.
|
||||
⚠️ **Needs a Firebase project** (user-only): `google-services.json` →
|
||||
`android/app/` + the Gradle google-services plugin, and the **service-account
|
||||
JSON** in the host's `.env` as `PUSH_FIREBASE_SERVICE_ACCOUNT_JSON` (single
|
||||
line) — without it `/native/subscribe` → 503 and web push still works. Not
|
||||
testable without a device. (Approach + tradeoffs came from the
|
||||
Numo/Linky/BTCPay review this session.)
|
||||
- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
|
||||
delivery coordinator** exposed as a **ContextVM server** over Nostr relays
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
|||
"@capacitor/core": "^8.4.2",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/local-notifications": "^8.3.1",
|
||||
"@capacitor/push-notifications": "^8.1.2",
|
||||
"@cashu/cashu-ts": "2.8.1",
|
||||
"@contextvm/sdk": "^0.12.0",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
|
|
@ -391,6 +392,15 @@
|
|||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/push-notifications": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.2.tgz",
|
||||
"integrity": "sha512-TQl4XxqJfNF+KbSYBFMXYYT5WXUaleVKXlByhh/8+KySMVNjp+l5OqAUoGe/sY1IAnGXEuUeCjA/WLZEVvGwrA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"@capacitor/core": "^8.4.2",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/local-notifications": "^8.3.1",
|
||||
"@capacitor/push-notifications": "^8.1.2",
|
||||
"@cashu/cashu-ts": "2.8.1",
|
||||
"@contextvm/sdk": "^0.12.0",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
|
|
|
|||
|
|
@ -24,3 +24,8 @@ PUSH_LOOKBACK_SECONDS=259200
|
|||
|
||||
# Origins allowed to call /subscribe (comma-separated).
|
||||
PUSH_ALLOWED_ORIGINS=https://skate-spots.org,http://localhost:8080
|
||||
|
||||
# Native (FCM) push. A SINGLE-LINE Firebase service-account JSON (project_id,
|
||||
# client_email, private_key). Without it, /native/subscribe answers 503 and they
|
||||
# native path is disabled — web push is unaffected.
|
||||
PUSH_FIREBASE_SERVICE_ACCOUNT_JSON=
|
||||
|
|
|
|||
|
|
@ -80,6 +80,29 @@ Caddy — copy `Caddyfile` to `/etc/caddy/Caddyfile` and reload once.
|
|||
|
||||
Logs: `npm run deploy:push -- --logs`.
|
||||
|
||||
## Native push (FCM, Android/iOS)
|
||||
|
||||
The same service also delivers **FCM** notifications to the native app, so
|
||||
closed-app alerts work in the APK too. Setup (one-time, needs a Google account):
|
||||
|
||||
1. **Firebase project** → add an Android app with the package id
|
||||
`org.skatespots.app`. Download **`google-services.json`** into
|
||||
`android/app/` (gitignored — never commit it).
|
||||
2. **Project settings → Service accounts → Generate new private key** → a
|
||||
service-account JSON. Put it, single-line, in the server's `.env`:
|
||||
```
|
||||
PUSH_FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"…","client_email":"…","private_key":"…"}
|
||||
```
|
||||
3. Rebuild the APK (`npm run android:build`) and add the Gradle google-services
|
||||
plugin (the Capacitor push plugin requires it).
|
||||
|
||||
Endpoints (native): `POST /native/subscribe` `{ token, platform, proof }` and
|
||||
`POST /native/unsubscribe` `{ token, proof }` — same NIP-98 challenge proof as
|
||||
web. `/health` reports `native: true|false` and the device count. The service
|
||||
mints a Google OAuth2 token from the service account (cached ~1h) and posts to
|
||||
`fcm.googleapis.com/v1/projects/<project_id>/messages:send`; an `UNREGISTERED`
|
||||
token is dropped.
|
||||
|
||||
## Privacy
|
||||
|
||||
The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
* State is a small JSON file (subscriptions + challenges). Env + deploy: README.md.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createSign } from 'node:crypto';
|
||||
import webpush from 'web-push';
|
||||
import { verifyEvent, type NostrEvent } from 'nostr-tools';
|
||||
|
||||
|
|
@ -55,20 +56,39 @@ if (!vapidPublic || !vapidPrivate) {
|
|||
}
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
|
||||
|
||||
// ─── FCM (native push) ───────────────────────────────────────────────────────
|
||||
// Absent service account → /native/subscribe answers 503 and no native sends.
|
||||
interface FcmServiceAccount { project_id: string; client_email: string; private_key: string }
|
||||
let fcmServiceAccount: FcmServiceAccount | null = null;
|
||||
try {
|
||||
const raw = (env.PUSH_FIREBASE_SERVICE_ACCOUNT_JSON ?? '').trim();
|
||||
if (raw) fcmServiceAccount = JSON.parse(raw) as FcmServiceAccount;
|
||||
} catch {
|
||||
console.warn('[push] PUSH_FIREBASE_SERVICE_ACCOUNT_JSON is not valid JSON — native push disabled.');
|
||||
}
|
||||
|
||||
// ─── State (JSON file) ───────────────────────────────────────────────────────
|
||||
|
||||
interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string }
|
||||
/** A native (FCM) device token — Android APK / iOS. */
|
||||
interface StoredDevice { pubkey: string; platform: string; updatedAt: number }
|
||||
interface State {
|
||||
subscriptions: Record<string, StoredSubscription>; // endpoint → sub
|
||||
/** FCM token → device (native push). */
|
||||
devices: Record<string, StoredDevice>;
|
||||
challenges: Record<string, { pubkey: string; expiresAt: number }>;
|
||||
}
|
||||
|
||||
function load(): State {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial<State>;
|
||||
return { subscriptions: parsed.subscriptions ?? {}, challenges: parsed.challenges ?? {} };
|
||||
return {
|
||||
subscriptions: parsed.subscriptions ?? {},
|
||||
devices: parsed.devices ?? {},
|
||||
challenges: parsed.challenges ?? {},
|
||||
};
|
||||
} catch {
|
||||
return { subscriptions: {}, challenges: {} };
|
||||
return { subscriptions: {}, devices: {}, challenges: {} };
|
||||
}
|
||||
}
|
||||
const state = load();
|
||||
|
|
@ -107,6 +127,92 @@ function verifyProof(proof: unknown, expectedPubkey: string): { ok: boolean; err
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─── FCM send (OAuth2 service-account) ───────────────────────────────────────
|
||||
|
||||
/** FCM device tokens registered for a pubkey. */
|
||||
const devicesFor = (pubkey: string): string[] =>
|
||||
Object.entries(state.devices)
|
||||
.filter(([, d]) => d.pubkey === pubkey)
|
||||
.map(([token]) => token);
|
||||
|
||||
let cachedAccessToken: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
const base64url = (data: string | Buffer): string => Buffer.from(data).toString('base64url');
|
||||
|
||||
/** Mint (and cache) a Google OAuth2 access token from the service account. */
|
||||
async function getFcmAccessToken(sa: FcmServiceAccount): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (cachedAccessToken && cachedAccessToken.expiresAt > now + 60) return cachedAccessToken.token;
|
||||
|
||||
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const claim = base64url(
|
||||
JSON.stringify({
|
||||
iss: sa.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}),
|
||||
);
|
||||
const signingInput = `${header}.${claim}`;
|
||||
const signer = createSign('RSA-SHA256');
|
||||
signer.update(signingInput);
|
||||
signer.end();
|
||||
const assertion = `${signingInput}.${signer.sign(sa.private_key).toString('base64url')}`;
|
||||
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${assertion}`,
|
||||
});
|
||||
if (!res.ok) throw new Error(`FCM token ${res.status}`);
|
||||
const data = (await res.json()) as { access_token: string; expires_in: number };
|
||||
cachedAccessToken = { token: data.access_token, expiresAt: now + (data.expires_in ?? 3600) };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** Send a notification to native device tokens (best-effort, never throws). */
|
||||
async function sendFcm(
|
||||
tokens: string[],
|
||||
payload: { title: string; body: string; data?: Record<string, string> },
|
||||
): Promise<void> {
|
||||
if (!fcmServiceAccount || tokens.length === 0) return;
|
||||
let access: string;
|
||||
try {
|
||||
access = await getFcmAccessToken(fcmServiceAccount);
|
||||
} catch (err) {
|
||||
console.warn('[push] FCM auth failed:', (err as Error).message);
|
||||
return;
|
||||
}
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://fcm.googleapis.com/v1/projects/${fcmServiceAccount.project_id}/messages:send`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${access}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
token,
|
||||
notification: { title: payload.title, body: payload.body },
|
||||
data: payload.data ?? {},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (res.status === 404 || res.status === 400) {
|
||||
// UNREGISTERED / invalid token — drop it.
|
||||
delete state.devices[token];
|
||||
save();
|
||||
} else if (!res.ok) {
|
||||
console.warn(`[push] FCM send failed (${res.status})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[push] FCM send error:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Web Push ────────────────────────────────────────────────────────────────
|
||||
|
||||
function subsFor(pubkey: string): StoredSubscription[] {
|
||||
|
|
@ -131,6 +237,16 @@ async function pushTo(pubkey: string, payload: Record<string, unknown>): Promise
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Native (FCM) devices registered for the same pubkey.
|
||||
await sendFcm(devicesFor(pubkey), {
|
||||
title: String(payload.title ?? 'SkateSpots'),
|
||||
body: String(payload.body ?? 'New notification'),
|
||||
data: {
|
||||
...(payload.wrapId ? { wrapId: String(payload.wrapId) } : {}),
|
||||
...(payload.url ? { url: String(payload.url) } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Relay watcher (no keys, no decryption) ──────────────────────────────────
|
||||
|
|
@ -285,6 +401,8 @@ Bun.serve({
|
|||
return json({
|
||||
ok: true,
|
||||
subscriptions: Object.keys(state.subscriptions).length,
|
||||
devices: Object.keys(state.devices).length,
|
||||
native: !!fcmServiceAccount,
|
||||
relays: relayStatus,
|
||||
}, 200, headers);
|
||||
}
|
||||
|
|
@ -328,6 +446,35 @@ Bun.serve({
|
|||
return json({ ok: true }, 200, headers);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/native/subscribe') {
|
||||
if (!fcmServiceAccount) return json({ error: 'native_push_unavailable' }, 503, headers);
|
||||
const body = (await req.json().catch(() => null)) as
|
||||
| { token?: string; platform?: string; proof?: NostrEvent }
|
||||
| null;
|
||||
if (!body?.token) return json({ error: 'token required' }, 400, headers);
|
||||
if (!body?.proof) return json({ error: 'proof required' }, 400, headers);
|
||||
const check = verifyProof(body.proof, body.proof.pubkey);
|
||||
if (!check.ok) return json({ error: check.error }, 401, headers);
|
||||
state.devices[body.token] = {
|
||||
pubkey: body.proof.pubkey.toLowerCase(),
|
||||
platform: body.platform ?? 'android',
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
save();
|
||||
console.log(`[push] 📱 native device for ${body.proof.pubkey.slice(0, 12)}…`);
|
||||
return json({ ok: true }, 200, headers);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/native/unsubscribe') {
|
||||
const body = (await req.json().catch(() => null)) as { token?: string; proof?: NostrEvent } | null;
|
||||
if (!body?.token || !body?.proof) return json({ error: 'token and proof required' }, 400, headers);
|
||||
const check = verifyProof(body.proof, body.proof.pubkey);
|
||||
if (!check.ok) return json({ error: check.error }, 401, headers);
|
||||
delete state.devices[body.token];
|
||||
save();
|
||||
return json({ ok: true }, 200, headers);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/unsubscribe') {
|
||||
const body = (await req.json().catch(() => null)) as { endpoint?: string; proof?: NostrEvent } | null;
|
||||
if (!body?.endpoint || !body?.proof) return json({ error: 'endpoint and proof required' }, 400, headers);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,34 @@
|
|||
/**
|
||||
* usePushNotifications — opt in/out of the SkateSpots push service (see
|
||||
* `push-service/`) for this device.
|
||||
* usePushNotifications — opt in/out of the SkateSpots push service
|
||||
* (`push-service/`) for this device. Two paths:
|
||||
*
|
||||
* Enabling: register the push service worker, ask for permission, subscribe with
|
||||
* the service's VAPID key, sign a NIP-98 (kind 27235) challenge proof, and POST
|
||||
* it to the service. Disabling reverses it. Nothing is sent anywhere except the
|
||||
* pubkey + this device's push endpoint.
|
||||
* - **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';
|
||||
|
||||
function pushSupported(): boolean {
|
||||
/** 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 &&
|
||||
|
|
@ -39,12 +48,18 @@ function urlBase64ToUint8Array(base64: string): Uint8Array {
|
|||
|
||||
export function usePushNotifications() {
|
||||
const { user } = useCurrentUser();
|
||||
const [state, setState] = useState<PushState>(() => (pushSupported() ? 'off' : 'unsupported'));
|
||||
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 (!pushSupported()) {
|
||||
if (native) {
|
||||
setState(localStorage.getItem(FCM_TOKEN_KEY) ? 'on' : 'off');
|
||||
return;
|
||||
}
|
||||
if (!webPushSupported()) {
|
||||
setState('unsupported');
|
||||
return;
|
||||
}
|
||||
|
|
@ -61,7 +76,7 @@ export function usePushNotifications() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.pubkey]);
|
||||
}, [user?.pubkey, native]);
|
||||
|
||||
const signProof = useCallback(
|
||||
async (challenge: string) => {
|
||||
|
|
@ -76,8 +91,54 @@ export function usePushNotifications() {
|
|||
[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 (!pushSupported()) {
|
||||
if (!supported) {
|
||||
setState('unsupported');
|
||||
return;
|
||||
}
|
||||
|
|
@ -89,53 +150,53 @@ export function usePushNotifications() {
|
|||
setState('working');
|
||||
setError(null);
|
||||
try {
|
||||
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);
|
||||
if (native) await enableNative();
|
||||
else await enableWeb();
|
||||
setState('on');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not enable background notifications.');
|
||||
setState('error');
|
||||
}
|
||||
}, [user, signProof]);
|
||||
}, [supported, user, native, enableNative, enableWeb]);
|
||||
|
||||
const disable = useCallback(async () => {
|
||||
if (!pushSupported() || !user?.pubkey) {
|
||||
if (!supported || !user?.pubkey) {
|
||||
setState('off');
|
||||
return;
|
||||
}
|
||||
setState('working');
|
||||
setError(null);
|
||||
try {
|
||||
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 — the local unsubscribe below still runs */
|
||||
});
|
||||
await sub.unsubscribe();
|
||||
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');
|
||||
}
|
||||
}, [user, signProof]);
|
||||
}, [supported, user, native, signProof]);
|
||||
|
||||
return { state, error, supported: pushSupported(), enable, disable };
|
||||
return { state, error, supported, enable, disable };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,3 +56,12 @@ export async function subscribeToPush(subscription: PushSubscriptionJSON, proof:
|
|||
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
|
||||
await post('/unsubscribe', { endpoint, proof });
|
||||
}
|
||||
|
||||
/** Native (FCM) device registration — Android/iOS. */
|
||||
export async function subscribeNativePush(token: string, platform: string, proof: unknown): Promise<void> {
|
||||
await post('/native/subscribe', { token, platform, proof });
|
||||
}
|
||||
|
||||
export async function unsubscribeNativePush(token: string, proof: unknown): Promise<void> {
|
||||
await post('/native/unsubscribe', { token, proof });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue