aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 22:41:39 -0700
committerAdam Soltys <[email protected]>2026-08-02 22:41:39 -0700
commitd60697c8d1cc0b862d7488be4cf8ee21cafdfd1d (patch)
tree06919a327951f9979cb4ff7cf695bb03483757fd
parent817b592bf3e6dbf3f6ef0d88a981efc6c2cc6dca (diff)
downloadcoinosv3-d60697c8d1cc0b862d7488be4cf8ee21cafdfd1d.tar.gz
coinosv3-d60697c8d1cc0b862d7488be4cf8ee21cafdfd1d.zip
Push notifications for payments and messages; offline Spending notice
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
-rw-r--r--names/server.js7
-rw-r--r--nwcpush/server.js117
-rw-r--r--src/app.js10
-rw-r--r--src/features/messages.js67
-rw-r--r--src/i18n.js6
-rw-r--r--src/sw-nwc.js21
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