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:
parent
a538bd7add
commit
baa770f1b8
3 changed files with 101 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue