From 7a7b2c61fe8394844fa306debb84917454ade7b0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 14 Aug 2026 01:19:44 -0700 Subject: Settings grows a Notifications page — per-category switches the notifier honours server-side — and the status bar gets a real coinos badge instead of a black blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv --- src/app.js | 2 ++ src/features/ark.js | 6 ++++-- src/features/messages.js | 47 +++++++++++++++++++++++++++++++++++++++++++++-- src/features/nwc.js | 16 ++++++++++++++-- src/i18n.js | 10 ++++++++++ src/nwc-respond.js | 5 +++-- src/sw-nwc.js | 6 +++--- 7 files changed, 81 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 5d0264d..e6e8e9b 100644 --- a/src/app.js +++ b/src/app.js @@ -1662,6 +1662,7 @@ function settingsTab() { case 'payments': return page(featureAll('settingsCards').reverse()); case 'network': return page([networkCard(), explorerCard()]); case 'nostr': return nostrSettingsView(); + case 'notifications': return page(featureAll('notifySettingsCards')); case 'advanced': return advancedSettingsView(); } const nav = (id, label) => @@ -1676,6 +1677,7 @@ function settingsTab() { nav('payments', t('settingsPayments')), nav('network', t('settingsNetwork')), nav('nostr', t('nostrSettings')), + nav('notifications', t('settingsNotifications')), nav('advanced', t('advancedSettings')), h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'receive'; render(); } }, t('back')) ); diff --git a/src/features/ark.js b/src/features/ark.js index 4693336..8913dc3 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -481,12 +481,13 @@ export function arkFeature(ctx) { // connections/offer belong to the nwc feature; when it isn't the caller // (an auto-withdraw setting changed, say) carry forward what's mirrored // rather than blanking someone's wallet connections. - async function writeBgMirror(connections, offer) { + async function writeBgMirror(connections, offer, prefs) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; const prev = await loadBg(wallet._cacheKey()); if (connections === undefined) connections = (prev && prev.connections) || []; if (offer === undefined) offer = (prev && prev.offer) || null; + if (prefs === undefined) prefs = (prev && prev.prefs) || undefined; const rec = buildBg({ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }, mgrState: ark.state, @@ -496,6 +497,7 @@ export function arkFeature(ctx) { autowithdraw: await awMirror(prev), }); if (prev) rec.spends = prev.spends; // the worker's log survives rewrites + if (prefs) rec.prefs = prefs; // notification choices the worker honours await saveBg(wallet._cacheKey(), rec); } @@ -2056,7 +2058,7 @@ export function arkFeature(ctx) { arkBgReady() { return !!(ark && ark.info && ark.state); }, // nwc.js calls this (with its connections) whenever the mirror should // refresh: on enable, on connection changes, after payments, on a timer. - async arkBgWrite(connections, offer) { return writeBgMirror(connections, offer); }, + async arkBgWrite(connections, offer, prefs) { return writeBgMirror(connections, offer, prefs); }, async arkBgSpendableSat() { const rec = await loadBg(wallet._cacheKey()); return ((rec && rec.mgr && rec.mgr.vtxos) || []) diff --git a/src/features/messages.js b/src/features/messages.js index a27a6b7..c6d8a62 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -603,14 +603,19 @@ export function messagesFeature(ctx) { // off by default: a busy room would otherwise buzz a phone all day for // conversations that aren't addressed to anyone in particular. function pushWatch() { - const on = st().notify; + const s = st(); + const on = s.notify; const authors = []; for (const jm of communities()) { if (!on[jm.community_id]) continue; 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 }; + // per-category opt-outs travel with the registration so the notifier + // never sends what the user turned off (a suppressed-but-delivered push + // would earn Chrome's generic "updated in background" nag instead) + const reasons = { payment: s.reasons?.payment !== false, dm: s.reasons?.dm !== false }; + return { ptags: myPubkeys(), authors, reasons }; } const roomNotify = (cid) => !!st().notify[cid]; @@ -631,6 +636,43 @@ export function messagesFeature(ctx) { } } + // Settings → Notifications: the master switch for this device plus the + // per-category choices the registration carries to the notifier. + function notifyCard() { + const supported = typeof Notification !== 'undefined' + && typeof navigator !== 'undefined' && 'serviceWorker' in navigator + && typeof window !== 'undefined' && 'PushManager' in window; + const perm = supported ? Notification.permission : 'unsupported'; + const s = st(); + const on = (k) => s.reasons?.[k] !== false; + const flip = async (k) => { + const s2 = st(); + s2.reasons = { ...(s2.reasons || {}), [k]: !on(k) }; + save(s2); render(); + const ok = await registerPush({ interactive: true }); + if (!ok) toast(t('msgPushFailed')); + }; + const rowT = (label, k) => h('div', { class: 'row between' }, + h('span', { class: 'small' }, label), + h('button', { class: 'btn-sm', onClick: () => flip(k) }, on(k) ? t('nwcOn') : t('nwcOff'))); + return h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('notifTitle')), + !supported + ? h('div', { class: 'small faint' }, t('nwcNoPush')) + : perm === 'denied' + ? h('div', { class: 'notice err' }, t('nwcNotifBlocked')) + : perm !== 'granted' || !s.push + ? h('button', { class: 'btn-primary btn-block', onClick: async () => { + const ok = await registerPush({ interactive: true }); + toast(ok ? t('notifEnabled') : t('msgPushFailed')); + render(); + } }, t('notifEnable')) + : h('div', { class: 'small faint' }, t('notifOnDevice')), + rowT(t('notifPayRecv'), 'payment'), + rowT(t('notifDm'), 'dm'), + h('div', { class: 'small faint' }, t('notifChatHint'))); + } + // Hand the service worker what it needs to tell a friend's DM from a // stranger's: our key (only if we actually hold one), who we follow, and the // names we already know. See dm-inbox.js for the threat model — a remote @@ -1816,6 +1858,7 @@ export function messagesFeature(ctx) { }, // Conversations waiting on us, for the header's message button. unreadMessages() { return unreadCount(); }, + notifySettingsCards() { return [notifyCard()]; }, screenView() { if (ui.screen !== 'wallet') return null; if (ui.profilePk) return profileScreen(); diff --git a/src/features/nwc.js b/src/features/nwc.js index d6bfdea..90ccb84 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -216,13 +216,14 @@ export function nwcFeature(ctx) { // worker — surface it the same way. A visible tab already shows it live. async function notifySent(c, amountSat, feeSat) { try { + if (load().paySentNotify === false) return; if (typeof document === 'undefined' || !document.hidden) return; if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; const reg = await navigator.serviceWorker.ready; await reg.showNotification(t('nwcPaidTitle'), { body: t('nwcPaidBody', { amount: amountSat.toLocaleString(), name: c.name || 'app' }) + (feeSat ? ` (+${feeSat} fee)` : ''), - icon: 'icon-192.png', badge: 'icon-192.png', + icon: 'icon-192.png', badge: 'badge-96.png', tag: 'nwc-sent', renotify: true, data: { url: './' }, }); } catch {} @@ -699,7 +700,8 @@ export function nwcFeature(ctx) { bgTimer = setTimeout(async () => { try { if (!hook('arkBgReady')) return; - await hook('arkBgWrite', conns(), load().offer || null); + await hook('arkBgWrite', conns(), load().offer || null, + { paySent: load().paySentNotify !== false }); } catch (e) { console.warn('nwc: could not write background state', e.message); } }, 1500); } @@ -850,5 +852,15 @@ export function nwcFeature(ctx) { init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); ensureBackground(false); }, stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } }, nostrSettingsCards() { return [nwcCard()]; }, + notifySettingsCards() { + const on = load().paySentNotify !== false; + return [h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row between' }, + h('span', { class: 'small' }, t('notifPaySent')), + h('button', { class: 'btn-sm', onClick: () => { + const s = load(); s.paySentNotify = !on; save(s); writeBg(); render(); + } }, on ? t('nwcOn') : t('nwcOff'))), + h('div', { class: 'small faint' }, t('notifPaySentHint')))]; + }, }; } diff --git a/src/i18n.js b/src/i18n.js index a211eb5..26bf1fb 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -665,6 +665,16 @@ const STR = { nwcBackgroundOff: 'Background answering turned off', nwcPaidTitle: 'Payment sent', nwcPaidBody: '-{amount} sats via {name}', + settingsNotifications: 'Notifications', + notifTitle: 'Notifications', + notifEnable: 'Enable notifications on this device', + notifEnabled: 'Notifications enabled', + notifOnDevice: 'Notifications are on for this device.', + notifPayRecv: 'Payments received', + notifDm: 'Direct messages', + notifChatHint: 'Community chat is opt-in per room — turn it on from a room’s menu.', + notifPaySent: 'Payments sent by connected apps', + notifPaySentHint: 'A heads-up when a connected app — a nostr client zapping, for example — spends from this wallet while it’s closed.', nwcNotifHint: 'You’ll be asked to allow notifications — the connection uses them to answer when Coinos is closed.', nwcNotifNeeded: 'Without notifications the connection couldn’t answer when Coinos is closed, so it wasn’t added. Allow notifications and try again.', nwcNotifBlocked: 'Notifications are blocked for this site. Allow them in your browser settings, then add the connection again.', diff --git a/src/nwc-respond.js b/src/nwc-respond.js index bd75360..232ae3e 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -280,8 +280,9 @@ export async function respondFromBg(data, { result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 }, }); log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`); - // Money left while the app was closed — that always deserves a heads-up, - // whether it was a zap from a nostr app or any other connected spender. + // Money left while the app was closed — that deserves a heads-up, unless + // the user turned "payments sent" notifications off in Settings. + if (rec.prefs && rec.prefs.paySent === false) return true; const paidSat = a.amountSat || dec.amountSat; const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : ''; return { diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 108c393..2566a5c 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -47,7 +47,7 @@ self.addEventListener('push', (e) => { const [title, body] = T[data.reason] || T.chat; await self.registration.showNotification(title, { body, - icon: 'icon-192.png', badge: 'icon-192.png', + icon: 'icon-192.png', badge: 'badge-96.png', tag: 'notify-' + (data.reason || 'chat'), renotify: data.reason === 'payment', data: { url: './' }, }); @@ -79,7 +79,7 @@ self.addEventListener('push', (e) => { if (handled && handled.notify) { await self.registration.showNotification(handled.notify.title, { body: handled.notify.body, - icon: 'icon-192.png', badge: 'icon-192.png', + icon: 'icon-192.png', badge: 'badge-96.png', tag: handled.notify.tag || 'nwc-incoming', renotify: true, data: { url: './' }, }); @@ -93,7 +93,7 @@ self.addEventListener('push', (e) => { body: incoming ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.' : 'An app is asking your wallet to pay. Open Coinos to approve.', - icon: 'icon-192.png', badge: 'icon-192.png', + icon: 'icon-192.png', badge: 'badge-96.png', tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, }); -- cgit v1.2.3