aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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
Diffstat (limited to 'src')
-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
4 files changed, 103 insertions, 1 deletions
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