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 --- src/features/messages.js | 67 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) (limited to 'src/features') 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 {} } -- cgit v1.2.3