aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js8
-rw-r--r--nwcpush/server.js21
-rw-r--r--src/features/messages.js24
-rw-r--r--src/i18n.js1
-rw-r--r--src/sw-nwc.js16
5 files changed, 60 insertions, 10 deletions
diff --git a/build.js b/build.js
index ae9aee9..0f2373e 100644
--- a/build.js
+++ b/build.js
@@ -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 || './');
})());
});