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:
parent
3ad214683a
commit
4df961b015
6 changed files with 137 additions and 33 deletions
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue