aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-14 01:19:44 -0700
committerAdam Soltys <[email protected]>2026-08-14 01:19:44 -0700
commit7a7b2c61fe8394844fa306debb84917454ade7b0 (patch)
tree4d2505c52dc239b5379e43400ac7bf822773e7aa
parent5b23470f5d9ec7ee7cc33c2b4b936ddd24fa17cc (diff)
downloadcoinosv3-7a7b2c61fe8394844fa306debb84917454ade7b0.tar.gz
coinosv3-7a7b2c61fe8394844fa306debb84917454ade7b0.zip
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
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
-rw-r--r--build.js4
-rw-r--r--nwcpush/server.js7
-rw-r--r--src/app.js2
-rw-r--r--src/features/ark.js6
-rw-r--r--src/features/messages.js47
-rw-r--r--src/features/nwc.js16
-rw-r--r--src/i18n.js10
-rw-r--r--src/nwc-respond.js5
-rw-r--r--src/sw-nwc.js6
-rw-r--r--static/badge-96.pngbin0 -> 3540 bytes
10 files changed, 90 insertions, 13 deletions
diff --git a/build.js b/build.js
index d3120b1..4088682 100644
--- a/build.js
+++ b/build.js
@@ -14,7 +14,7 @@ import { mkdir, readdir } from 'node:fs/promises';
const FAVICON = 'data:image/svg+xml,%3Csvg%20width%3D%2272%22%20height%3D%2272%22%20viewBox%3D%220%200%2072%2072%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%20%3Cpath%20d%3D%22M69.8824%2036C69.8824%2054.7127%2054.7127%2069.8824%2036%2069.8824C17.2873%2069.8824%202.11765%2054.7127%202.11765%2036C2.11765%2017.2873%2017.2873%202.11765%2036%202.11765C54.7127%202.11765%2069.8824%2017.2873%2069.8824%2036Z%22%20fill%3D%22white%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%204.23529C18.4568%204.23529%204.23529%2018.4568%204.23529%2036C4.23529%2053.5432%2018.4568%2067.7647%2036%2067.7647C53.5432%2067.7647%2067.7647%2053.5432%2067.7647%2036C67.7647%2018.4568%2053.5432%204.23529%2036%204.23529ZM0%2036C0%2016.1177%2016.1177%200%2036%200C55.8823%200%2072%2016.1177%2072%2036C72%2055.8823%2055.8823%2072%2036%2072C16.1177%2072%200%2055.8823%200%2036Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%2058.5882C48.4751%2058.5882%2058.5882%2048.4751%2058.5882%2036C58.5882%2023.5248%2048.4751%2013.4117%2036%2013.4117C23.5249%2013.4117%2013.4118%2023.5248%2013.4118%2036C13.4118%2048.4751%2023.5249%2058.5882%2036%2058.5882ZM36%2054C45.9411%2054%2054%2045.9411%2054%2036C54%2026.0589%2045.9411%2018%2036%2018C26.0589%2018%2018%2026.0589%2018%2036C18%2045.9411%2026.0589%2054%2036%2054Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36.0001%2022.8988C36.0001%2022.8988%2036%2022.8988%2036%2022.8988C28.7644%2022.8988%2022.8988%2028.7644%2022.8988%2036C22.8988%2043.2356%2028.7644%2049.1012%2036%2049.1012C36%2049.1012%2036.0001%2049.1012%2036.0001%2049.1012V22.8988Z%22%20fill%3D%22black%22/%3E%20%3C/svg%3E';
// Static assets copied verbatim into dist/ (icons referenced by the manifest).
-const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg'];
+const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png'];
const MANIFEST = {
id: '/',
@@ -166,7 +166,7 @@ const SW_WAKE_ONLY = `self.addEventListener('push', (e) => {
}
await self.registration.showNotification('Payment request', {
body: '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: './' },
});
diff --git a/nwcpush/server.js b/nwcpush/server.js
index 6300732..7efebb1 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -150,6 +150,7 @@ function onNotifyEvent(ev) {
}
async function pushNotify(id, r, reason, extra) {
+ if (r.reasons && r.reasons[reason] === false) return; // opted out of this category
const key = id + ':' + reason;
const last = lastPush.get(key) || 0;
if (Date.now() - last < (COOLDOWN[reason] || 10_000)) return;
@@ -356,10 +357,16 @@ const server = Bun.serve({
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);
+ // per-category opt-outs; absent means everything on (older clients)
+ const reasons = {};
+ for (const k of ['payment', 'dm', 'chat']) {
+ if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k];
+ }
notifyRegs[id] = {
sub: sub_, updated: Date.now(),
ptags: hex(body.notify.ptags, 8),
authors: hex(body.notify.authors, 64),
+ reasons,
};
persist();
resubscribeNotify();
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: './' },
});
diff --git a/static/badge-96.png b/static/badge-96.png
new file mode 100644
index 0000000..d568b8d
--- /dev/null
+++ b/static/badge-96.png
Binary files differ