feat(push): category-aware notifications (trades-only actually means trades-only)

- /subscribe + /native/poll/register carry the enabled categories; the server
  stores them per subscription/token and only pushes matching ones (DM wrap ->
  dms; offer/counter/accept/attestation/shipment -> trades). Absent = legacy
  (all), empty array = none.
- /pending uses a per-category activity breakdown so it only moves for the
  token's categories; the smoke test proves a DM does NOT wake a trades-only token.
- usePushNotifications/useBackgroundDelivery send categories (from device prefs)
  and expose resubscribe(); NotificationSettingsCard re-sends on toggle change.
This commit is contained in:
SkateSpots 2026-10-01 20:01:07 +07:00
parent 3ad214683a
commit 4df961b015
6 changed files with 137 additions and 33 deletions

View file

@ -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.)

View file

@ -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<number, string> = {
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<string, StoredSubscription>; // endpoint → sub
/** Poll token → the pubkey it may read activity for (native background polling). */
pollTokens: Record<string, { pubkey: string; createdAt: number }>;
/** Poll token → the pubkey + categories it may read activity for (native polling). */
pollTokens: Record<string, { pubkey: string; categories?: string[]; createdAt: number }>;
/**
* 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<string, { seq: number; at: number }>;
activity: Record<string, { seq: number; at: number; byCategory: Record<string, number> }>;
challenges: Record<string, { pubkey: string; expiresAt: number }>;
}
@ -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<string, unknown>): Promise<void> {
recordActivity(pubkey);
async function pushTo(pubkey: string, payload: Record<string, unknown>, category: string): Promise<void> {
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);

View file

@ -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) => {

View file

@ -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<DeliveryMode>('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<DeliveryMode, 'off'>, 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 };
}

View file

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

View file

@ -49,8 +49,12 @@ export async function requestPushChallenge(pubkey: string): Promise<string> {
return data.challenge;
}
export async function subscribeToPush(subscription: PushSubscriptionJSON, proof: unknown): Promise<void> {
await post('/subscribe', { subscription, proof });
export async function subscribeToPush(
subscription: PushSubscriptionJSON,
proof: unknown,
categories?: string[],
): Promise<void> {
await post('/subscribe', { subscription, proof, categories });
}
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {