diff options
| author | Adam Soltys <[email protected]> | 2026-09-14 17:42:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-14 17:42:04 -0700 |
| commit | 5a0f70fcedd7a02a159de1819082981a6fd6244f (patch) | |
| tree | 41afd35f5e109e55a0767007fc20bab291fd39c4 | |
| parent | 7a1a628366d7ef8888b49217ae04cc9bcb97799e (diff) | |
| download | coinosv3-5a0f70fcedd7a02a159de1819082981a6fd6244f.tar.gz coinosv3-5a0f70fcedd7a02a159de1819082981a6fd6244f.zip | |
A reply or a mention on nostr can buzz your phone.
The push notifier now watches kind 1 for the user's pubkey beside DMs and
zap receipts, and pushes who, what, and an excerpt. The worker shows
"New reply to your post" or "You were mentioned" with the text, and a tap
lands in the Notifications list — as ?open=notifs when a window has to
be opened, or as a worker message when one already is. It has its own
switch, "Replies and mentions on nostr", in Settings → Notifications,
on by default like payments and DMs.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
| -rw-r--r-- | build.js | 8 | ||||
| -rw-r--r-- | nwcpush/server.js | 21 | ||||
| -rw-r--r-- | src/features/messages.js | 24 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/sw-nwc.js | 16 |
5 files changed, 60 insertions, 10 deletions
@@ -219,11 +219,15 @@ const SW_WAKE_ONLY = `self.addEventListener('push', (e) => { }); self.addEventListener('notificationclick', (e) => { e.notification.close(); + const d = (e.notification && e.notification.data) || {}; e.waitUntil((async () => { const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const open = all.find((c) => 'focus' in c); - if (open) return open.focus(); - if (self.clients.openWindow) return self.clients.openWindow('./'); + if (open) { + if (d.view) { try { open.postMessage({ type: 'open', view: d.view }); } catch (_) {} } + return open.focus(); + } + if (self.clients.openWindow) return self.clients.openWindow(d.url || './'); })()); });`; diff --git a/nwcpush/server.js b/nwcpush/server.js index 85fc7b5..d4eb6a5 100644 --- a/nwcpush/server.js +++ b/nwcpush/server.js @@ -138,7 +138,8 @@ function resubscribeNotify() { 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) }); + // ...as do replies and mentions (kind 1 naming the user) + if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1], '#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 @@ -151,7 +152,7 @@ function resubscribeNotify() { // DMs are throttled barely at all now: the device drops the ones it decides // are noise (a stranger, or our own sent-copy), and a server-side cooldown // would let a discarded push swallow the friend's message that followed it. -const COOLDOWN = { payment: 10_000, dm: 1_000, chat: 90_000 }; +const COOLDOWN = { payment: 10_000, dm: 1_000, chat: 90_000, mention: 5_000 }; const lastPush = new Map(); // endpointId:reason -> ts const seenNotifyEvents = new Map(); // event id -> ts function onNotifyEvent(ev) { @@ -162,6 +163,20 @@ function onNotifyEvent(ev) { for (const [k, t] of seenNotifyEvents) if (t < cut) seenNotifyEvents.delete(k); } const p = ev.tags?.find((t) => t[0] === 'p')?.[1]; + // A public reply or mention: the note is readable here, so the device + // gets what it needs to say who and what — an excerpt, and whether it + // answers a post (e tag) or just names the person. + if (ev.kind === 1) { + const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]); + const reply = (ev.tags || []).some((t) => t[0] === 'e' && t[1]); + const extra = { from: ev.pubkey, reply, text: String(ev.content || '').replace(/\s+/g, ' ').slice(0, 140) }; + for (const [id, r] of Object.entries(notifyRegs)) { + const mine = r.ptags || []; + if (mine.includes(ev.pubkey)) continue; // their own post, not news + if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'mention', extra).catch(() => {}); + } + return; + } const reason = ev.kind === 9735 || ev.kind === 9737 ? 'payment' : p && notifyPtags().includes(p) ? 'dm' : 'chat'; // A wrapped DM hides its sender from us by design, so we can't decide here @@ -423,7 +438,7 @@ const server = Bun.serve({ 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']) { + for (const k of ['payment', 'dm', 'chat', 'mention']) { if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k]; } notifyRegs[id] = { diff --git a/src/features/messages.js b/src/features/messages.js index e39a35e..71daef8 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -44,6 +44,18 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st // and need no billboard. const CLIENT_TAG = ['client', 'coinos']; +// A view the URL asks to open (a notification tap): read once, then taken +// off the address so a reload doesn't repeat it. +const OPEN_VIEW = (() => { + try { + const v = new URLSearchParams(location.search).get('open'); + if (!v) return null; + const u = new URL(location.href); u.searchParams.delete('open'); + history.replaceState(null, '', u.pathname + (u.search || '')); + return v; + } catch { return null; } +})(); + export function messagesFeature(ctx) { const { h, ui, render, wallet, toast, hook } = ctx; @@ -1295,7 +1307,7 @@ export function messagesFeature(ctx) { // 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 }; + const reasons = { payment: s.reasons?.payment !== false, dm: s.reasons?.dm !== false, mention: s.reasons?.mention !== false }; // Stamp the network: the same seed makes the same nostr pubkey on mainnet // AND staging, so without this the notifier fans a mainnet payment push // out to the staging PWA's subscription too — and tapping it opened @@ -1356,6 +1368,7 @@ export function messagesFeature(ctx) { : h('div', { class: 'small faint' }, t('notifOnDevice')), rowT(t('notifPayRecv'), 'payment'), rowT(t('notifDm'), 'dm'), + rowT(t('notifMention'), 'mention'), h('div', { class: 'small faint' }, t('notifChatHint'))); } @@ -5361,6 +5374,7 @@ export function messagesFeature(ctx) { let notifSeenAtOpen = 0; // what counted as new when the list was opened function openNotifs() { notifSeenAtOpen = notifSeen(); + ui.chatOpen = true; ui.msgView = 'notifs'; notifNow(); refreshNotifs(true).catch(() => {}); @@ -5572,6 +5586,14 @@ export function messagesFeature(ctx) { setTimeout(() => { try { const me = ctx.shownPubkey && ctx.shownPubkey(); if (me) prefetchProfilePage(me); } catch {} }, 2500); + // A tapped "new reply" notification lands here: as ?open=notifs when + // it had to open a window, or as a worker message when one was open. + if (OPEN_VIEW === 'notifs') setTimeout(openNotifs, 0); + try { + navigator.serviceWorker?.addEventListener('message', (ev) => { + if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs(); + }); + } catch {} if (urlInvite && !pendingLink) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); diff --git a/src/i18n.js b/src/i18n.js index 290b514..06ce526 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -942,6 +942,7 @@ const STR = { notifOnDevice: 'Notifications are on for this device.', notifPayRecv: 'Payments received', notifDm: 'Direct messages', + notifMention: 'Replies and mentions on nostr', 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.', diff --git a/src/sw-nwc.js b/src/sw-nwc.js index a166368..3919bcb 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -44,13 +44,16 @@ self.addEventListener('push', (e) => { payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats — open coinos to see it.` : 'Open coinos to see it.'], dm: dmName ? [dmName, 'sent you a message.'] : ['New message', 'You have a new private message.'], chat: ['New chat activity', 'There are new messages in your communities.'], + mention: [data.reply ? 'New reply to your post' : 'You were mentioned', data.text || 'Open coinos to see it.'], }; const [title, body] = T[data.reason] || T.chat; + // a reply lands you in the Notifications list, where it is + const target = data.reason === 'mention' ? { url: './?open=notifs', view: 'notifs' } : { url: './' }; await self.registration.showNotification(title, { body, icon: 'icon-192.png', badge: 'badge-96.png', - tag: 'notify-' + (data.reason || 'chat'), renotify: data.reason === 'payment', - data: { url: './' }, + tag: 'notify-' + (data.reason || 'chat'), renotify: data.reason === 'payment' || data.reason === 'mention', + data: target, }); })()); return; @@ -102,10 +105,15 @@ self.addEventListener('push', (e) => { self.addEventListener('notificationclick', (e) => { e.notification.close(); + const d = (e.notification && e.notification.data) || {}; e.waitUntil((async () => { const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const open = all.find((c) => 'focus' in c); - if (open) return open.focus(); - if (self.clients.openWindow) return self.clients.openWindow('./'); + if (open) { + // an open window goes where the notification points, without reloading + if (d.view) { try { open.postMessage({ type: 'open', view: d.view }); } catch (_) {} } + return open.focus(); + } + if (self.clients.openWindow) return self.clients.openWindow(d.url || './'); })()); }); |
