feat(push): push marketplace events (offers/requests/confirms), not just DMs

The service now also subscribes to marketplace events addressed to subscribers
(kinds 30403/31872/31871) via #p filters built from the live subscription set,
re-subscribed on every subscribe/unsubscribe. Payloads stay generic ('New offer
on your listing', 'Your offer was accepted') - never the amount or item. Verified
locally: subscribe -> offer to #p -> push attempt.
This commit is contained in:
SkateSpots 2026-10-01 16:07:21 +07:00
parent a538bd7add
commit baa770f1b8
3 changed files with 101 additions and 12 deletions

View file

@ -697,8 +697,12 @@ session with **no other memory of this project**. So:
challenge proof), a **Background notifications** toggle in
`NotificationSettingsCard`, and `useSendDirectMessage` now marks the
**recipient** wrap `["skatespots","push"]` **and adds the community relay as
the notify relay**. **Remaining:** deploy (`npm run deploy:push` + Caddy) and
Android FCM (Phase 2). ⚠️ Web push works in browsers; the Capacitor WebView
the notify relay**. The service **also pushes marketplace events** addressed
to subscribers — offers (`30403`, the whole negotiation), requests (`31872`),
confirms (`31871`) — via `#p` filters built from the live subscription set
(so it only sees events for subscribers; the payload is a **generic body**,
never the amount/item). **Remaining:** deploy (`npm run deploy:push` + Caddy)
and Android FCM (Phase 2). ⚠️ Web push works in browsers; the Capacitor WebView
needs FCM for the APK. (Approach + tradeoffs came from the
Numo/Linky/BTCPay review this session.)
- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS

View file

@ -13,6 +13,12 @@ only *"this pubkey has something, at time T."*
2. This service subscribes to `{ kinds: [1059] }` on the configured relays, and
for each wrap: verifies the **outer** signature, requires exactly one `p`
recipient, and checks the marker.
It ALSO pushes **marketplace events** addressed to subscribers — offers
(`30403`, the whole negotiation), trade requests (`31872`) and confirmations
(`31871`) — via `#p` filters built from the current subscription set (so it
only receives events for subscribers; the payload is generic, never the
amount or item). Re-subscribed on every subscribe/unsubscribe.
3. For a marked wrap it looks up that recipient's subscriptions and sends a Web
Push payload: `{ title, body, wrapId, url }` — **content-free**.
4. The service worker shows an OS notification. On tap, the app fetches the

View file

@ -113,12 +113,12 @@ function subsFor(pubkey: string): StoredSubscription[] {
return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey);
}
async function pushTo(pubkey: string, wrapId: string): Promise<void> {
async function pushTo(pubkey: string, payload: Record<string, unknown>): Promise<void> {
for (const s of subsFor(pubkey)) {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
JSON.stringify({ title: 'SkateSpots', body: 'New message', wrapId, url: '/#/messages' }),
JSON.stringify(payload),
);
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
@ -136,6 +136,87 @@ async function pushTo(pubkey: string, wrapId: string): Promise<void> {
// ─── Relay watcher (no keys, no decryption) ──────────────────────────────────
const relayStatus: Record<string, string> = {};
const relaySockets = new Map<string, WebSocket>();
/** Marketplace kinds we push for. Offers carry the whole negotiation state. */
const OFFER_KIND = 30403; // offer / counter / accepted / declined / withdrawn
const TRADE_CONFIRM_KIND = 31871; // counterparty recorded the trade
const TRADE_REQUEST_KIND = 31872; // "please confirm our trade"
const MARKET_KINDS = [OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND];
function subscribedPubkeys(): string[] {
return [...new Set(Object.values(state.subscriptions).map((s) => s.pubkey))];
}
/** Content-free body — no amounts, no item text (that stays out of the push). */
function marketBody(ev: NostrEvent): string {
if (ev.kind === TRADE_REQUEST_KIND) return 'asked you to confirm a trade';
if (ev.kind === TRADE_CONFIRM_KIND) return 'confirmed your trade';
const status = ev.tags.find(([n]) => n === 'status')?.[1] ?? 'open';
if (status === 'accepted') return 'Your offer was accepted';
if (status === 'declined') return 'Your offer was declined';
if (status === 'withdrawn') return 'An offer was withdrawn';
const role = ev.tags.find(([n]) => n === 'role')?.[1];
return role === 'offer' ? 'New offer on your listing' : 'You got a counter-offer';
}
let marketSubSeq = 0;
let currentMarketSubId: string | null = null;
/** Ask ONE relay for marketplace events addressed to the CURRENT subscribers. */
function sendMarketSubscription(ws: WebSocket, since: number): void {
if (currentMarketSubId) {
try { ws.send(JSON.stringify(['CLOSE', currentMarketSubId])); } catch { /* socket gone */ }
}
const pubkeys = subscribedPubkeys();
if (pubkeys.length === 0) return;
const id = `skatespots-push-mkt-${++marketSubSeq}`;
// `#p` filters so we only receive marketplace events addressed to subscribers,
// chunked (a REQ carries multiple filters).
const filters: unknown[] = [];
for (let i = 0; i < pubkeys.length; i += 50) {
filters.push({ kinds: MARKET_KINDS, '#p': pubkeys.slice(i, i + 50), since });
}
ws.send(JSON.stringify(['REQ', id, ...filters]));
currentMarketSubId = id;
}
let marketRefreshTimer: ReturnType<typeof setTimeout> | null = null;
/** Rebuild the marketplace subscription after a subscribe/unsubscribe (debounced). */
function refreshMarketSubscription(): void {
if (marketRefreshTimer) clearTimeout(marketRefreshTimer);
marketRefreshTimer = setTimeout(() => {
const since = Math.floor(Date.now() / 1000) - LOOKBACK_SECONDS;
for (const ws of relaySockets.values()) {
if (ws.readyState === 1) sendMarketSubscription(ws, since);
}
}, 1500);
}
function handleEvent(ev: NostrEvent): void {
if (!verifyEvent(ev)) return;
// 1) NIP-17 gift wraps: push-marked, exactly one recipient, never decrypted.
if (ev.kind === 1059) {
const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))];
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' });
return;
}
// 2) Marketplace events addressed to a subscriber (public — metadata only;
// we only ever send a generic body, never the amount or item).
if (MARKET_KINDS.includes(ev.kind)) {
const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))];
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' });
}
}
}
function watchRelay(url: string): void {
relayStatus[url] = 'connecting';
@ -144,7 +225,9 @@ function watchRelay(url: string): void {
ws.addEventListener('open', () => {
relayStatus[url] = 'live';
relaySockets.set(url, ws);
ws.send(JSON.stringify(['REQ', 'skatespots-push', { kinds: [1059], since }]));
sendMarketSubscription(ws, since);
console.log(`[push] watching ${url}`);
});
@ -152,16 +235,10 @@ function watchRelay(url: string): void {
let parsed: unknown;
try { parsed = JSON.parse(String(msg.data)); } catch { return; }
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return;
const ev = parsed[2] as NostrEvent;
if (ev?.kind !== 1059 || !verifyEvent(ev)) return;
const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))];
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)}… → recipient ${recipients[0].slice(0, 12)}…`);
void pushTo(recipients[0], ev.id);
handleEvent(parsed[2] as NostrEvent);
});
const retry = () => { relayStatus[url] = 'down'; setTimeout(() => watchRelay(url), 5000); };
const retry = () => { relayStatus[url] = 'down'; relaySockets.delete(url); setTimeout(() => watchRelay(url), 5000); };
ws.addEventListener('close', retry);
ws.addEventListener('error', () => { try { ws.close(); } catch { /* noop */ } });
}
@ -239,6 +316,7 @@ Bun.serve({
};
save();
console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`);
refreshMarketSubscription();
return json({ ok: true }, 200, headers);
}
@ -249,6 +327,7 @@ Bun.serve({
if (!check.ok) return json({ error: check.error }, 401, headers);
delete state.subscriptions[body.endpoint];
save();
refreshMarketSubscription();
return json({ ok: true }, 200, headers);
}