From d60697c8d1cc0b862d7488be4cf8ee21cafdfd1d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 2 Aug 2026 22:41:39 -0700 Subject: Push notifications for payments and messages; offline Spending notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nwcpush notifier grows a second registration kind alongside NWC: a notify watch of what it can see without keys — kind-1059 wraps p-tagged at the user (DMs, direct invites), zap receipts (9735/9737), and wraps authored by the user's community channel keys (chat). It pushes a typed nudge, throttled per device (chat bursts collapse); the service worker shows a generic notification unless a window is visible, and tapping opens the app. The names registrar reports settled lightning-address receives server-to-server (token-gated /notify), so the common payment path notifies even with no nostr event in sight. The app registers silently whenever permission is already granted and keeps the watch current as communities change; a dismissible card on the chat home offers to enable it otherwise. Ark zap receipts (9737) join the relay whitelist so the payment watch can see them. Also: the offline Spending receive pane now says you're offline and offers a one-tap switch to Savings instead of spinning forever. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- names/server.js | 7 +++ nwcpush/server.js | 117 +++++++++++++++++++++++++++++++++++++++++++++-- src/app.js | 10 +++- src/features/messages.js | 67 +++++++++++++++++++++++++++ src/i18n.js | 6 +++ src/sw-nwc.js | 21 +++++++++ 6 files changed, 222 insertions(+), 6 deletions(-) diff --git a/names/server.js b/names/server.js index d9aa299..99552dc 100644 --- a/names/server.js +++ b/names/server.js @@ -301,6 +301,13 @@ async function settleLoop() { if (!name) continue; // not one of ours (e.g. the ASP's own invoices) const sat = Math.floor((inv.amount_received_msat?.msat ?? inv.amount_received_msat ?? 0) / 1000); if (!sat) continue; + // money has arrived for this name — push-notify their devices (best effort) + if (CFG.push && CFG.push.url && state.names[name]) { + fetch(`${CFG.push.url}/notify`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: CFG.push.token, pubkey: state.names[name].pubkey, amountSat: sat }), + }).catch(() => {}); + } if (pending) { if (pending.zap) publishZapReceipt(pending, inv).catch(() => {}); delete state.invoices[inv.payment_hash]; diff --git a/nwcpush/server.js b/nwcpush/server.js index ededa8e..5095151 100644 --- a/nwcpush/server.js +++ b/nwcpush/server.js @@ -54,13 +54,22 @@ const log = (...a) => console.log(new Date().toISOString(), ...a); // registrations: { [servicePubkey]: [{ sub, id, updated }] } // --------------------------------------------------------------------------- -let regs = (() => { - try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return {}; } +let regs = {}; +// user-facing notifications: { [endpointId]: { sub, ptags: [], authors: [], updated } } +// ptags → DMs / zap receipts p-tagged at the user; authors → community +// stream pubkeys (kind 1059 wraps authored by a channel's derived key). +let notifyRegs = {}; +(() => { + try { + const raw = JSON.parse(readFileSync(STATE, 'utf8')); + if (raw && raw.nwc) { regs = raw.nwc; notifyRegs = raw.notify || {}; } + else regs = raw || {}; // pre-notify state file + } catch {} })(); function persist() { mkdirSync(dirname(STATE), { recursive: true }); const tmp = STATE + '.tmp'; - writeFileSync(tmp, JSON.stringify(regs)); + writeFileSync(tmp, JSON.stringify({ nwc: regs, notify: notifyRegs })); renameSync(tmp, STATE); } // A device that stops refreshing drops off, so a stale endpoint can't keep us @@ -72,7 +81,10 @@ function prune() { regs[pk] = (regs[pk] || []).filter((r) => (r.updated || 0) > cutoff); if (!regs[pk].length) { delete regs[pk]; dropped++; } } - if (dropped) { persist(); log(`pruned ${dropped} stale pubkey(s)`); resubscribe(); } + for (const id of Object.keys(notifyRegs)) { + if ((notifyRegs[id].updated || 0) <= cutoff) { delete notifyRegs[id]; dropped++; } + } + if (dropped) { persist(); log(`pruned ${dropped} stale registration(s)`); resubscribe(); } } const watchedPubkeys = () => Object.keys(regs); @@ -85,11 +97,76 @@ const pool = new SimplePool(); let sub = null; let lastResub = 0; +const notifyPtags = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.ptags || []))]; +const notifyAuthors = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.authors || []))]; + +let notifySub = null; +function resubscribeNotify() { + try { notifySub?.close(); } catch {} + notifySub = null; + const ptags = notifyPtags(); + const authors = notifyAuthors(); + if (!ptags.length && !authors.length) return; + const filters = []; + // DMs / direct invites / zap receipts land p-tagged at the user + if (ptags.length) filters.push({ kinds: [1059, 9735, 9737], '#p': ptags, since: Math.floor(Date.now() / 1000) }); + // community chat: wraps authored by a channel's derived stream key + if (authors.length) filters.push({ kinds: [1059], authors, since: Math.floor(Date.now() / 1000) }); + // one sub per filter — the pool API takes a single filter object + notifySub = filters.map((f) => pool.subscribeMany(RELAYS, f, { onevent: onNotifyEvent })); + notifySub.close = function () { for (const x of this) { try { x.close(); } catch {} } }; + log(`notify: watching ${ptags.length} ptag(s), ${authors.length} stream author(s)`); +} + +// throttle: chat bursts collapse to one push per device per window +const COOLDOWN = { payment: 10_000, dm: 10_000, chat: 90_000 }; +const lastPush = new Map(); // endpointId:reason -> ts +const seenNotifyEvents = new Map(); // event id -> ts +function onNotifyEvent(ev) { + if (seenNotifyEvents.has(ev.id)) return; + seenNotifyEvents.set(ev.id, Date.now()); + if (seenNotifyEvents.size > 4000) { + const cut = Date.now() - 600_000; + for (const [k, t] of seenNotifyEvents) if (t < cut) seenNotifyEvents.delete(k); + } + const p = ev.tags?.find((t) => t[0] === 'p')?.[1]; + const reason = ev.kind === 9735 || ev.kind === 9737 ? 'payment' + : p && notifyPtags().includes(p) ? 'dm' : 'chat'; + for (const [id, r] of Object.entries(notifyRegs)) { + const hit = reason === 'chat' + ? (r.authors || []).includes(ev.pubkey) + : p && (r.ptags || []).includes(p); + if (hit) pushNotify(id, r, reason, {}).catch(() => {}); + } +} + +async function pushNotify(id, r, reason, extra) { + const key = id + ':' + reason; + const last = lastPush.get(key) || 0; + if (Date.now() - last < (COOLDOWN[reason] || 10_000)) return; + lastPush.set(key, Date.now()); + const payload = JSON.stringify({ type: 'notify', reason, ...extra }); + try { + await webpush.sendNotification(r.sub, payload, { TTL: 3600, urgency: 'normal' }); + log(`notify(${reason}) -> device ${id}`); + } catch (e) { + if (e.statusCode === 404 || e.statusCode === 410) { + delete notifyRegs[id]; + persist(); + resubscribeNotify(); + log(`dropped an expired notify endpoint ${id}`); + } else { + log(`notify(${reason}) push failed for ${id}: ${e.statusCode || e.message}`); + } + } +} + function resubscribe() { + resubscribeNotify(); const pks = watchedPubkeys(); try { sub?.close(); } catch {} sub = null; - if (!pks.length) { log('nothing registered; not subscribed'); return; } + if (!pks.length) { log('nothing registered for nwc; not subscribed'); return; } lastResub = Date.now(); sub = pool.subscribeMany( RELAYS, @@ -228,6 +305,20 @@ const server = Bun.serve({ return json({ ok: true }); } + // Server-to-server: the names registrar reports a settled receive for a + // pubkey. Token-gated; pushes 'payment received' to that user's devices. + if (url.pathname === '/notify' && req.method === 'POST') { + const body = await req.json().catch(() => null); + if (!CFG.notifyToken || body?.token !== CFG.notifyToken) return json({ error: 'forbidden' }, 403); + const pk = body?.pubkey; + if (!/^[0-9a-f]{64}$/.test(pk || '')) return json({ error: 'pubkey required' }, 400); + let n = 0; + for (const [id, r] of Object.entries(notifyRegs)) { + if ((r.ptags || []).includes(pk)) { pushNotify(id, r, 'payment', { amountSat: Number(body.amountSat) || undefined }).catch(() => {}); n++; } + } + return json({ ok: true, devices: n }); + } + // Was a request we pushed already answered by someone? Lets a worker // avoid double-paying (or shouting an error over) another device's reply. if (url.pathname === '/answered') { @@ -240,7 +331,22 @@ const server = Bun.serve({ if (url.pathname === '/register' && req.method === 'POST') { const body = await req.json().catch(() => null); const sub_ = body?.subscription; + const hasNwc = Array.isArray(body?.servicePubkeys); const pks = (body?.servicePubkeys || []).filter((p) => /^[0-9a-f]{64}$/.test(p)); + // user-facing notification watch (messages + payments) — independent of + // the NWC registration so either can refresh without clobbering the other + if (body?.notify && sub_?.endpoint) { + const hex = (a, cap) => [...new Set((a || []).filter((p) => /^[0-9a-f]{64}$/.test(p)))].slice(0, cap); + const id = Bun.hash(sub_.endpoint).toString(36); + notifyRegs[id] = { + sub: sub_, updated: Date.now(), + ptags: hex(body.notify.ptags, 8), + authors: hex(body.notify.authors, 64), + }; + persist(); + resubscribeNotify(); + if (!hasNwc) return json({ ok: true, notify: true }); + } if (!sub_?.endpoint || !pks.length) return json({ error: 'subscription and servicePubkeys required' }, 400); if (pks.length > MAX_PK) return json({ error: `at most ${MAX_PK} pubkeys` }, 400); // one id per endpoint so re-registering replaces rather than duplicates @@ -271,6 +377,7 @@ const server = Bun.serve({ regs[pk] = regs[pk].filter((r) => r.id !== id); if (!regs[pk].length) delete regs[pk]; } + delete notifyRegs[id]; persist(); resubscribe(); return json({ ok: true }); diff --git a/src/app.js b/src/app.js index 795ccc3..a7feb75 100644 --- a/src/app.js +++ b/src/app.js @@ -2141,7 +2141,15 @@ function receiveTab() { // (receives Ark and Lightning), Savings shows a fresh on-chain address. const fresh = wallet.freshReceive(); const nameMode = featureAll('receiveModes').find((m) => m.id === 'name'); - if (accountSel() === 'spending' && nameMode) return nameMode.render(null); + if (accountSel() === 'spending') { + // Spending needs the network (registrar + ark); offline, offer the + // on-chain path right here instead of an endless spinner. + if (wallet.offline) + return h('div', { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'notice info' }, t('receiveOfflineSpending')), + h('button', { class: 'btn-primary btn-block', onClick: () => setAccountSel('savings') }, t('receiveUseSavings'))); + if (nameMode) return nameMode.render(null); + } const addr = fresh.address; return h( 'div', diff --git a/src/features/messages.js b/src/features/messages.js index e96cadb..6ff94a4 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -43,6 +43,7 @@ const COMMUNITY = { const EPOCH = 0; const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; +const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state @@ -374,6 +375,7 @@ export function messagesFeature(ctx) { (s.joined[cid] ||= {})[id.pubkey] = true; save(s); publishLists(); + registerPush().catch(() => {}); ensureRoom(jm); ui.msgView = 'room'; ui.msgCommunity = cid; @@ -465,6 +467,7 @@ export function messagesFeature(ctx) { } const room = ensureRoom(communityById(b.community_id)); identity().then((id) => id && ensureJoined(room, id)).catch(() => {}); + registerPush().catch(() => {}); ui.msgView = 'room'; ui.msgCommunity = b.community_id; ui.msgChannel = null; @@ -502,6 +505,55 @@ export function messagesFeature(ctx) { const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null; if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} } + // ---- push notifications ------------------------------------------------- + // The nwcpush notifier watches relays for what it can see without keys: + // kind-1059 wraps p-tagged at us (DMs, direct invites), zap receipts + // (payments), and wraps authored by our communities' channel keys (chat). + // It pushes a typed nudge; the service worker shows a generic notification + // unless a window is visible. Payment pushes also arrive server-to-server + // from the names registrar when a lightning-address receive settles. + + const b64ToBytes = (b64) => { + const pad = '='.repeat((4 - (b64.length % 4)) % 4); + const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(raw, (c) => c.charCodeAt(0)); + }; + + function pushWatch() { + const authors = []; + for (const jm of communities()) { + const root = hexToBytes(jm.community_root); + for (const c of (jm.channels || []).slice(0, 8)) authors.push(channelKey(root, c.id, EPOCH).pk); + } + return { ptags: myPubkeys(), authors }; + } + + async function registerPush({ interactive = false } = {}) { + try { + if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false; + if (Notification.permission !== 'granted') { + if (!interactive) return false; + if ((await Notification.requestPermission()) !== 'granted') return false; + } + const reg = await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json(); + sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(publicKey) }); + } + const r = await fetch(`${NOTIFIER}/register`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subscription: sub.toJSON(), notify: pushWatch() }), + }); + if (!r.ok) return false; + const s = st(); + if (!s.push) { s.push = true; save(s); } + return true; + } catch { + return false; + } + } + // ---- device sync: Community List (13302) + Invite List (13303) ---------- // Self-encrypted replaceables (CORD-02 §8 / CORD-05 §4) so memberships and // minted link keys follow the user to any device or client. Published under @@ -986,6 +1038,20 @@ export function messagesFeature(ctx) { if (pendingLink) kids.push(linkInviteCard()); for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv)); + // offer push once — it covers messages AND payments + if (typeof Notification !== 'undefined' && Notification.permission === 'default' && !st().pushDismissed) + kids.push(h('div', { class: 'notice info col', style: 'gap:8px' }, + h('div', {}, t('msgPushOffer')), + h('div', { class: 'row gap6' }, + h('button', { + class: 'btn-primary btn-sm', + onClick: async () => { const ok = await registerPush({ interactive: true }); toast(ok ? t('msgPushOn') : t('msgPushFailed')); render(); }, + }, t('msgPushEnable')), + h('button', { + class: 'btn-ghost btn-sm', + onClick: () => { const s = st(); s.pushDismissed = true; save(s); render(); }, + }, t('msgDismiss'))))); + // ---- DMs kids.push(h('div', { class: 'row between', style: 'align-items:baseline' }, h('h3', { style: 'margin:0' }, t('msgDmsTitle')), @@ -1303,6 +1369,7 @@ export function messagesFeature(ctx) { } startDMs(); syncLists().catch(() => {}); + registerPush().catch(() => {}); // silent refresh when permission already granted }, stop() { for (const u of allUnsubs) { try { u(); } catch {} } diff --git a/src/i18n.js b/src/i18n.js index 550a231..2afe232 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -169,6 +169,10 @@ const STR = { msgOpen: 'Open', msgNpubPlaceholder: 'npub…', msgSearchPlaceholder: 'Name or npub…', + msgPushOffer: 'Get notified when you receive payments and messages, even with coinos closed.', + msgPushEnable: 'Enable notifications', + msgPushOn: 'Notifications on', + msgPushFailed: 'Could not enable notifications', msgChannelPlaceholder: 'New channel name', msgOwnerOnly: 'Only the community owner can do that', profEdit: 'Edit profile', @@ -207,6 +211,8 @@ const STR = { settingsNetwork: 'Network', advancedSettings: 'Advanced', recoveryDesc: 'The twelve words that back up this wallet. Anyone who sees them can steal your funds.', + receiveOfflineSpending: "You're offline — your payment address needs the network. You can still receive to Savings with an on-chain address.", + receiveUseSavings: 'Switch to Savings', arkOffboardAmtPlaceholder: 'Amount', arkSplitting: 'Carving out that exact amount — this can take a minute…', arkSplitTimeout: 'The split is taking longer than expected — your balance is intact; try again shortly.', diff --git a/src/sw-nwc.js b/src/sw-nwc.js index b6cfc01..0fdc4d7 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -11,6 +11,27 @@ const NOTIFIER = 'https://nwcpush.coinos.io'; self.addEventListener('push', (e) => { let data = {}; try { data = e.data ? e.data.json() : {}; } catch (_) {} + // User-facing notifications (payments, DMs, community chat). An open + // window already shows everything live — stay quiet then. + if (data.type === 'notify') { + e.waitUntil((async () => { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (clients.some((c) => c.visibilityState === 'visible')) return; + const T = { + payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats — open coinos to see it.` : 'Open coinos to see it.'], + dm: ['New message', 'You have a new private message.'], + chat: ['New chat activity', 'There are new messages in your communities.'], + }; + const [title, body] = T[data.reason] || T.chat; + await self.registration.showNotification(title, { + body, + icon: 'icon-192.png', badge: 'icon-192.png', + tag: 'notify-' + (data.reason || 'chat'), renotify: data.reason === 'payment', + data: { url: './' }, + }); + })()); + return; + } if (data.type !== 'nwc') return; e.waitUntil((async () => { // An open window handles requests itself with full wallet state — nudge -- cgit v1.2.3