diff --git a/AGENTS.md b/AGENTS.md index 3f173fd..5db20f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -813,6 +813,14 @@ session with **no other memory of this project**. So: `android/` is regenerated, so the `file:` dep must stay in `package.json` for `cap sync` to add the module; `build.gradle`/manifest may need tuning per the app's Capacitor version. + **Category-aware (2026-10-01):** `/subscribe` and `/native/poll/register` + carry `categories`; the server stores them per subscription/token and only + pushes matching ones (DM gift-wrap → `dms`; offer/counter/accept/attestation/ + shipment → `trades` — `social`/`crew`/`newsletter` are **in-app only**, never + pushed). `/pending` uses a **per-category activity** breakdown, so it only + moves for the token's categories. `NotificationSettingsCard` **re-sends** the + choices on toggle change (`push.resubscribe()` / `background.resubscribe()`). + ⚠️ An **absent** `categories` = legacy (allow all); an **empty array** = none. ⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers defer it); `fast` needs a sticky notification. Never the only path. (Approach + tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.) diff --git a/push-service/server.ts b/push-service/server.ts index ecaa715..60d0107 100644 --- a/push-service/server.ts +++ b/push-service/server.ts @@ -57,17 +57,43 @@ webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate); // ─── State (JSON file) ─────────────────────────────────────────────────────── -interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string } +interface StoredSubscription { + endpoint: string; + pubkey: string; + p256dh: string; + auth: string; + /** Which categories this device wants. Empty/absent = all (back-compat). */ + categories?: string[]; +} + +/** + * Event kind → notification category, mirroring the app's `NotificationCategory` + * (only `dms` and `trades` are pushed — social/crew/newsletter are in-app only). + */ +const CATEGORY_BY_KIND: Record = { + 1059: 'dms', // NIP-17 gift wrap + 30403: 'trades', // marketplace offer / counter / accept / decline / withdraw + 31871: 'trades', // trade attestation (sold / bought) + 31872: 'trades', // confirm-request + 34967: 'trades', // shipment status +}; + +function allows(categories: string[] | undefined, category: string): boolean { + // ABSENT = legacy subscriber → allow everything. An EMPTY array = the user + // explicitly wants no push categories. + if (categories === undefined) return true; + return categories.includes(category); +} interface State { subscriptions: Record; // endpoint → sub - /** Poll token → the pubkey it may read activity for (native background polling). */ - pollTokens: Record; + /** Poll token → the pubkey + categories it may read activity for (native polling). */ + pollTokens: Record; /** - * Per-pubkey activity cursor, bumped whenever something arrives for a pubkey, - * so a native poller can ask "anything new since N?" WITHOUT the server ever - * revealing *what* (content-free — the same guarantee as web push). + * Per-pubkey activity cursor with a per-category breakdown, so a native poller + * can ask "anything new since N?" for ONLY the categories the user wants — + * WITHOUT the server ever revealing *what* (content-free, like web push). */ - activity: Record; + activity: Record }>; challenges: Record; } @@ -126,16 +152,23 @@ function subsFor(pubkey: string): StoredSubscription[] { return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey); } -/** Bump the content-free activity cursor for a pubkey (native pollers read it). */ -function recordActivity(pubkey: string): void { - const cur = state.activity[pubkey]; - state.activity[pubkey] = { seq: (cur?.seq ?? 0) + 1, at: Math.floor(Date.now() / 1000) }; +/** Bump the content-free activity cursor for a pubkey + category. */ +function recordActivity(pubkey: string, category: string): void { + const cur = state.activity[pubkey] ?? { seq: 0, at: 0, byCategory: {} }; + const seq = cur.seq + 1; + state.activity[pubkey] = { + seq, + at: Math.floor(Date.now() / 1000), + byCategory: { ...cur.byCategory, [category]: seq }, + }; save(); } -async function pushTo(pubkey: string, payload: Record): Promise { - recordActivity(pubkey); +async function pushTo(pubkey: string, payload: Record, category: string): Promise { + recordActivity(pubkey, category); for (const s of subsFor(pubkey)) { + // Respect the device's category choices. + if (!allows(s.categories, category)) continue; try { await webpush.sendNotification( { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, @@ -234,7 +267,7 @@ function handleEvent(ev: NostrEvent): void { if (recipients.length !== 1) return; // fan-out wraps aren't pushable if (!ev.tags.some(([n, v]) => n === MARKER && v === 'push')) return; console.log(`[push] 🔔 marked wrap ${ev.id.slice(0, 12)}… → ${recipients[0].slice(0, 12)}…`); - void pushTo(recipients[0], { title: 'SkateSpots', body: 'New message', wrapId: ev.id, url: '/#/messages' }); + void pushTo(recipients[0], { title: 'SkateSpots', body: 'New message', wrapId: ev.id, url: '/#/messages' }, 'dms'); return; } @@ -245,7 +278,7 @@ function handleEvent(ev: NostrEvent): void { for (const r of recipients) { if (r === ev.pubkey) continue; // never notify the author about their own message console.log(`[push] 🔔 market ${ev.kind} ${ev.id.slice(0, 12)}… → ${r.slice(0, 12)}…`); - void pushTo(r, { title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: '/#/trades' }); + void pushTo(r, { title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: '/#/trades' }, 'trades'); } } } @@ -332,6 +365,8 @@ Bun.serve({ const body = (await req.json().catch(() => null)) as { subscription?: { endpoint?: string; keys?: { p256dh?: string; auth?: string } }; proof?: NostrEvent; + /** The device's chosen categories (only dms/trades are pushed). */ + categories?: string[]; } | null; const sub = body?.subscription; if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) { @@ -345,6 +380,7 @@ Bun.serve({ pubkey: body.proof.pubkey.toLowerCase(), p256dh: sub.keys.p256dh, auth: sub.keys.auth, + categories: Array.isArray(body.categories) ? body.categories : undefined, }; save(); console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`); @@ -368,13 +404,21 @@ Bun.serve({ // token; it then polls `/pending` and shows a LOCAL notification when the // content-free activity cursor moves. The server never reveals what arrived. if (req.method === 'POST' && url.pathname === '/native/poll/register') { - const body = (await req.json().catch(() => null)) as { pubkey?: string; proof?: NostrEvent } | null; + const body = (await req.json().catch(() => null)) as + | { pubkey?: string; proof?: NostrEvent; categories?: string[] } + | null; if (!body?.pubkey || !body?.proof) return json({ error: 'pubkey and proof required' }, 400, headers); const check = verifyProof(body.proof, body.pubkey); if (!check.ok) return json({ error: check.error }, 401, headers); + // One token per pubkey: re-registering (e.g. after a settings change) + // replaces the old one so nothing is orphaned. + for (const [t, e] of Object.entries(state.pollTokens)) { + if (e.pubkey === body.pubkey.toLowerCase()) delete state.pollTokens[t]; + } const token = crypto.randomUUID(); state.pollTokens[token] = { pubkey: body.pubkey.toLowerCase(), + categories: Array.isArray(body.categories) ? body.categories : undefined, createdAt: Math.floor(Date.now() / 1000), }; save(); @@ -401,12 +445,14 @@ Bun.serve({ const entry = state.pollTokens[token]; if (!entry) return json({ error: 'unknown token' }, 401, headers); const since = Number(url.searchParams.get('since') ?? 0); - const activity = state.activity[entry.pubkey] ?? { seq: 0, at: 0 }; - return json( - { seq: activity.seq, at: activity.at, changed: activity.seq > (Number.isFinite(since) ? since : 0) }, - 200, - headers, - ); + const activity = state.activity[entry.pubkey] ?? { seq: 0, at: 0, byCategory: {} }; + // Only the categories THIS token asked for can move `changed`. + const allowed = Array.isArray(entry.categories) + ? entry.categories + : Object.keys(activity.byCategory); + const cutoff = Number.isFinite(since) ? since : 0; + const changed = allowed.some((c) => (activity.byCategory[c] ?? 0) > cutoff); + return json({ seq: activity.seq, at: activity.at, changed }, 200, headers); } return json({ error: 'not found' }, 404, headers); diff --git a/src/components/NotificationSettingsCard.tsx b/src/components/NotificationSettingsCard.tsx index 7212453..cffb93b 100644 --- a/src/components/NotificationSettingsCard.tsx +++ b/src/components/NotificationSettingsCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Bell, Loader2, Radio, ShieldCheck, TriangleAlert } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; @@ -30,6 +30,20 @@ export function NotificationSettingsCard() { const background = useBackgroundDelivery(); const { toast } = useToast(); + // When the category choices change, re-send them to whatever channel is on, so + // a closed-app push respects the toggles (the server filters by category). + const categoriesKey = push.categories.join(','); + const firstCategoriesRun = useRef(true); + useEffect(() => { + if (firstCategoriesRun.current) { + firstCategoriesRun.current = false; + return; + } + if (push.state === 'on') void push.resubscribe(); + if (background.mode !== 'off') void background.resubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [categoriesKey]); + useEffect(() => { let cancelled = false; void getNotificationPermission().then((result) => { diff --git a/src/hooks/useBackgroundDelivery.ts b/src/hooks/useBackgroundDelivery.ts index b5bf4e0..e5c289d 100644 --- a/src/hooks/useBackgroundDelivery.ts +++ b/src/hooks/useBackgroundDelivery.ts @@ -7,10 +7,12 @@ * Two modes (chosen in Settings): `standard` = WorkManager ~15 min (best-effort), * `fast` = a foreground service ~5 min (sticky notification). */ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { registerPlugin } from '@capacitor/core'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNotificationPrefs } from '@/hooks/useNotificationPrefs'; +import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs'; import { isNativePlatform, getPlatform } from '@/lib/platform'; import { pushServiceBase, requestPushChallenge } from '@/lib/pushService'; @@ -32,6 +34,11 @@ export function useBackgroundDelivery() { const { user } = useCurrentUser(); const supported = isNativePlatform() && getPlatform() === 'android'; const [mode, setMode] = useState('off'); + const [prefs] = useNotificationPrefs(); + const categories = useMemo( + () => NOTIFICATION_CATEGORIES.map((c) => c.key).filter((k) => k !== 'reminders' && prefs[k]), + [prefs], + ); const enable = useCallback( async (nextMode: Exclude, intervalMinutes = 5) => { @@ -49,7 +56,7 @@ export function useBackgroundDelivery() { const res = await fetch(`${pushServiceBase()}/native/poll/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ pubkey: user.pubkey, proof }), + body: JSON.stringify({ pubkey: user.pubkey, proof, categories }), }); if (!res.ok) throw new Error('Could not register for background delivery.'); const { token, seq } = (await res.json()) as { token: string; seq: number }; @@ -58,9 +65,15 @@ export function useBackgroundDelivery() { await SkateSpotsBackground.configure({ mode: nextMode, intervalMinutes }); setMode(nextMode); }, - [supported, user], + [supported, user, categories], ); + /** Re-register with the current category choices (after a toggle change). */ + const resubscribe = useCallback(async () => { + if (mode === 'off') return; + await enable(mode, mode === 'fast' ? 5 : undefined); + }, [mode, enable]); + const disable = useCallback(async () => { if (!supported) return; await SkateSpotsBackground.configure({ mode: 'off' }); @@ -68,5 +81,5 @@ export function useBackgroundDelivery() { setMode('off'); }, [supported]); - return { supported, mode, enable, disable }; + return { supported, mode, enable, disable, resubscribe, categories }; } diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts index dab4a18..1d2b867 100644 --- a/src/hooks/usePushNotifications.ts +++ b/src/hooks/usePushNotifications.ts @@ -7,8 +7,10 @@ * it to the service. Disabling reverses it. Nothing is sent anywhere except the * pubkey + this device's push endpoint. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNotificationPrefs } from '@/hooks/useNotificationPrefs'; +import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs'; import { fetchVapidPublicKey, requestPushChallenge, @@ -41,6 +43,12 @@ export function usePushNotifications() { const { user } = useCurrentUser(); const [state, setState] = useState(() => (pushSupported() ? 'off' : 'unsupported')); const [error, setError] = useState(null); + const [prefs] = useNotificationPrefs(); + // Only the categories the device wants (the server pushes dms/trades only). + const categories = useMemo( + () => NOTIFICATION_CATEGORIES.map((c) => c.key).filter((k) => k !== 'reminders' && prefs[k]), + [prefs], + ); // Reflect an existing subscription on mount / account switch. useEffect(() => { @@ -104,13 +112,24 @@ export function usePushNotifications() { const challenge = await requestPushChallenge(user.pubkey); const proof = await signProof(challenge); - await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof); + await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof, categories); setState('on'); } catch (err) { setError(err instanceof Error ? err.message : 'Could not enable background notifications.'); setState('error'); } - }, [user, signProof]); + }, [user, signProof, categories]); + + /** Re-send the current category choices after a toggle changes. */ + const resubscribe = useCallback(async () => { + if (!pushSupported() || !user?.pubkey) return; + const reg = await navigator.serviceWorker.getRegistration(); + const sub = await reg?.pushManager.getSubscription(); + if (!sub) return; + const challenge = await requestPushChallenge(user.pubkey); + const proof = await signProof(challenge); + await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof, categories); + }, [user, signProof, categories]); const disable = useCallback(async () => { if (!pushSupported() || !user?.pubkey) { @@ -137,5 +156,5 @@ export function usePushNotifications() { } }, [user, signProof]); - return { state, error, supported: pushSupported(), enable, disable }; + return { state, error, supported: pushSupported(), enable, disable, resubscribe, categories }; } diff --git a/src/lib/pushService.ts b/src/lib/pushService.ts index b0bba17..9f52360 100644 --- a/src/lib/pushService.ts +++ b/src/lib/pushService.ts @@ -49,8 +49,12 @@ export async function requestPushChallenge(pubkey: string): Promise { return data.challenge; } -export async function subscribeToPush(subscription: PushSubscriptionJSON, proof: unknown): Promise { - await post('/subscribe', { subscription, proof }); +export async function subscribeToPush( + subscription: PushSubscriptionJSON, + proof: unknown, + categories?: string[], +): Promise { + await post('/subscribe', { subscription, proof, categories }); } export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise {