aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--nwcpush/server.js30
-rw-r--r--src/features/messages.js5
-rw-r--r--src/i18n.js2
-rw-r--r--src/locales/ar.json2
-rw-r--r--src/locales/bn.json2
-rw-r--r--src/locales/de.json2
-rw-r--r--src/locales/es.json2
-rw-r--r--src/locales/fr.json2
-rw-r--r--src/locales/hi.json2
-rw-r--r--src/locales/ja.json2
-rw-r--r--src/locales/ko.json2
-rw-r--r--src/locales/pt.json2
-rw-r--r--src/locales/ru.json2
-rw-r--r--src/locales/tr.json2
-rw-r--r--src/locales/zh.json2
-rw-r--r--src/sw-nwc.js17
-rw-r--r--tools/notif-categories-test.js39
17 files changed, 107 insertions, 10 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
index d4eb6a5..4110f91 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -138,8 +138,8 @@ function resubscribeNotify() {
if (!ptags.length && !authors.length) return;
const filters = [];
// DMs / direct invites / zap receipts land p-tagged at the user
- // ...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) });
+ // ...as do replies and mentions (kind 1 naming the user) and reactions (7)
+ if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7], '#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
@@ -152,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, mention: 5_000 };
+const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000 };
const lastPush = new Map(); // endpointId:reason -> ts
const seenNotifyEvents = new Map(); // event id -> ts
function onNotifyEvent(ev) {
@@ -177,7 +177,20 @@ function onNotifyEvent(ev) {
}
return;
}
- const reason = ev.kind === 9735 || ev.kind === 9737 ? 'payment'
+ // A like or emoji on the user's post. Opt-in by an explicit flag: an app
+ // too old to send one has no words for it and would call it chat.
+ if (ev.kind === 7) {
+ const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]);
+ const extra = { from: ev.pubkey, emoji: String(ev.content || '+').slice(0, 16) };
+ for (const [id, r] of Object.entries(notifyRegs)) {
+ const mine = r.ptags || [];
+ if (r.reasons?.reaction !== true || mine.includes(ev.pubkey)) continue;
+ if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'reaction', extra).catch(() => {});
+ }
+ return;
+ }
+ const zap = ev.kind === 9735 || ev.kind === 9737;
+ const reason = zap ? 'payment'
: p && notifyPtags().includes(p) ? 'dm' : 'chat';
// A wrapped DM hides its sender from us by design, so we can't decide here
// whether it's from a friend or a stranger — we hand the whole wrap to the
@@ -185,11 +198,16 @@ function onNotifyEvent(ev) {
// around 4KB after encryption; anything larger goes as a bare nudge and the
// device shows its generic notification.
const extra = reason === 'dm' && JSON.stringify(ev).length < 3500 ? { wrap: ev } : {};
+ // a coinos zap receipt says how much in the clear
+ const amt = ev.kind === 9737 ? parseInt(ev.tags?.find((t) => t[0] === 'amount')?.[1], 10) : 0;
+ if (amt > 0) extra.amountSat = amt;
for (const [id, r] of Object.entries(notifyRegs)) {
const hit = reason === 'chat'
? (r.authors || []).includes(ev.pubkey)
: p && (r.ptags || []).includes(p);
- if (hit) pushNotify(id, r, reason, extra).catch(() => {});
+ // a zap is its own category for apps that know one; older apps keep
+ // getting it as a payment
+ if (hit) pushNotify(id, r, zap && typeof r.reasons?.zap === 'boolean' ? 'zap' : reason, extra).catch(() => {});
}
}
@@ -438,7 +456,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', 'mention']) {
+ for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction']) {
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 89e9425..4fcff22 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2016,7 +2016,8 @@ 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, mention: s.reasons?.mention !== false };
+ const want = (k) => s.reasons?.[k] !== false;
+ const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') };
// 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
@@ -2076,8 +2077,10 @@ export function messagesFeature(ctx) {
} }, t('notifEnable'))
: h('div', { class: 'small faint' }, t('notifOnDevice')),
rowT(t('notifPayRecv'), 'payment'),
+ rowT(t('notifZap'), 'zap'),
rowT(t('notifDm'), 'dm'),
rowT(t('notifMention'), 'mention'),
+ rowT(t('notifReaction'), 'reaction'),
h('div', { class: 'small faint' }, t('notifChatHint')));
}
diff --git a/src/i18n.js b/src/i18n.js
index 02584b2..951bc8d 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1208,6 +1208,8 @@ const STR = {
notifPayRecv: 'Payments received',
notifDm: 'Direct messages',
notifMention: 'Replies and mentions on nostr',
+ notifZap: 'Zaps on your posts',
+ notifReaction: 'Reactions to your posts',
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/locales/ar.json b/src/locales/ar.json
index 2e914c3..3305722 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -780,6 +780,8 @@
"notifEnabled": "الإخطارات مكنت",
"notifOnDevice": "الإخطارات على هذا الجهاز.",
"notifPayRecv": "المدفوعات المقبوضة",
+ "notifZap": "زابات على منشوراتك",
+ "notifReaction": "التفاعلات على منشوراتك",
"notifDm": "رسائل مباشرة",
"notifChatHint": "والحديث المجتمعي هو اختيار كل غرفة - شغله من قائمة غرفة.",
"notifPaySent": "المدفوعات المرسلة بواسطة تطبيقات متصلة",
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 44b2700..60ac9ad 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -780,6 +780,8 @@
"notifEnabled": "সক্রিয়",
"notifOnDevice": "এই ডিভাইসের জন্য সূচনাবার্তা উপস্থিত রয়েছে।",
"notifPayRecv": "মূল্য প্রদান করা হয়েছে",
+ "notifZap": "আপনার পোস্টে জ্যাপ",
+ "notifReaction": "আপনার পোস্টে প্রতিক্রিয়া",
"notifDm": "সরাসরি বার্তা",
"notifChatHint": "কমিউনিটি চ্যাট রুম থেকে শুরু করে রুম পর্যন্ত চলে।",
"notifPaySent": "সংযুক্ত অ্যাপসের মাধ্যমে প্রদান করা হয়",
diff --git a/src/locales/de.json b/src/locales/de.json
index bf827b4..eb11d94 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -780,6 +780,8 @@
"notifEnabled": "Benachrichtigungen aktiviert",
"notifOnDevice": "Benachrichtigungen sind für dieses Gerät.",
"notifPayRecv": "Zahlungen",
+ "notifZap": "Zaps auf deine Beiträge",
+ "notifReaction": "Reaktionen auf deine Beiträge",
"notifDm": "Direkte Nachrichten",
"notifChatHint": "Community-Chat ist opt-in pro Zimmer drehen Sie es von einem Raum-Menü.",
"notifPaySent": "Zahlungen durch verbundene Apps",
diff --git a/src/locales/es.json b/src/locales/es.json
index 52e417b..b8e77d5 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -780,6 +780,8 @@
"notifEnabled": "Permitidas notificaciones",
"notifOnDevice": "Las notificaciones están en este dispositivo.",
"notifPayRecv": "Pagos recibidos",
+ "notifZap": "Zaps en tus publicaciones",
+ "notifReaction": "Reacciones a tus publicaciones",
"notifDm": "Mensajes directos",
"notifChatHint": "El chat comunitario es opt-in por habitación, enciende desde el menú de la habitación.",
"notifPaySent": "Pagos enviados por aplicaciones conectadas",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 471eb5b..1cd6e10 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -780,6 +780,8 @@
"notifEnabled": "Notifications activées",
"notifOnDevice": "Les notifications sont en cours pour cet appareil.",
"notifPayRecv": "Paiements reçus",
+ "notifZap": "Zaps sur vos publications",
+ "notifReaction": "Réactions à vos publications",
"notifDm": "Messages directs",
"notifChatHint": "Le chat communautaire est opt-in par chambre, allumez-le à partir d'un menu de chambre.",
"notifPaySent": "Paiements envoyés par des applications connectées",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 4fcd47d..c6d133e 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -780,6 +780,8 @@
"notifEnabled": "अधिसूचना सक्षम",
"notifOnDevice": "इस उपकरण के लिए अधिसूचनाएं हैं।",
"notifPayRecv": "प्राप्त भुगतान",
+ "notifZap": "आपकी पोस्ट पर ज़ैप",
+ "notifReaction": "आपकी पोस्ट पर प्रतिक्रियाएँ",
"notifDm": "प्रत्यक्ष संदेश",
"notifChatHint": "सामुदायिक चैट प्रति कमरे में ऑप्ट-इन है - इसे कमरे के मेनू से बदल दें।",
"notifPaySent": "कनेक्टेड ऐप्स द्वारा भेजे गए भुगतान",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 2ab8916..e71625b 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -780,6 +780,8 @@
"notifEnabled": "通知の有効化",
"notifOnDevice": "通知は、このデバイスで行われます。",
"notifPayRecv": "お支払いについて",
+ "notifZap": "投稿へのZap",
+ "notifReaction": "投稿へのリアクション",
"notifDm": "ダイレクトメッセージ",
"notifChatHint": "コミュニティチャットは、部屋のメニューから選択されます。",
"notifPaySent": "接続されたアプリで送信された支払い",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index e125415..99862ef 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -779,6 +779,8 @@
"notifEnabled": "자주 묻는 질문",
"notifOnDevice": "알림은이 기기에 있습니다.",
"notifPayRecv": "지불받은",
+ "notifZap": "내 글에 받은 잽",
+ "notifReaction": "내 글에 달린 반응",
"notifDm": "직접 메시지",
"notifChatHint": "커뮤니티 채팅은 방당 선택 - 방 메뉴에서 켜집니다.",
"notifPaySent": "연결된 앱으로 전송되는 결제",
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 9f5b2ad..b49b9d2 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -780,6 +780,8 @@
"notifEnabled": "Notificações habilitadas",
"notifOnDevice": "As notificações estão ligadas para este dispositivo.",
"notifPayRecv": "Pagamentos recebidos",
+ "notifZap": "Zaps nas suas publicações",
+ "notifReaction": "Reações às suas publicações",
"notifDm": "Mensagens diretas",
"notifChatHint": "O chat comunitário é opt-in por quarto – ligue-o do menu de um quarto.",
"notifPaySent": "Pagamentos enviados por aplicativos conectados",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 4e472fc..720de70 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -780,6 +780,8 @@
"notifEnabled": "Уведомления разрешены",
"notifOnDevice": "Уведомления для этого устройства есть.",
"notifPayRecv": "Полученные платежи",
+ "notifZap": "Запы на ваши посты",
+ "notifReaction": "Реакции на ваши посты",
"notifDm": "Прямые сообщения",
"notifChatHint": "Общественный чат включается в каждой комнате, включите его из меню комнаты.",
"notifPaySent": "Платежи, отправленные подключенными приложениями",
diff --git a/src/locales/tr.json b/src/locales/tr.json
index e1e9928..55c0c9c 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -779,6 +779,8 @@
"notifEnabled": "Bildirimler etkinleştirildi",
"notifOnDevice": "Bildirimler bu cihaz için.",
"notifPayRecv": "Ödemeler alındı",
+ "notifZap": "Gönderilerinize gelen zap'ler",
+ "notifReaction": "Gönderilerinize gelen tepkiler",
"notifDm": "Doğrudan mesajlar",
"notifChatHint": "Community chat oda başına opt-in - onu bir odadan menüsünden döndürür.",
"notifPaySent": "Bağlantılı uygulamalar tarafından gönderilen ödemeler",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 6bb4c50..3c9335c 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -780,6 +780,8 @@
"notifEnabled": "已启用通知",
"notifOnDevice": "这个装置的通知已经发出",
"notifPayRecv": "实收付款",
+ "notifZap": "帖子收到的 Zap",
+ "notifReaction": "帖子收到的回应",
"notifDm": "直接消息",
"notifChatHint": "每个房间都选择社区聊天,",
"notifPaySent": "由连接的应用程序发送付款",
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index c4a52e0..ddcdbb5 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -23,7 +23,7 @@ self.addEventListener('push', (e) => {
// Money landing is exactly when a forwarding rule wants to run, and this
// wake-up is the only moment the worker gets. Failures are recorded in
// the mirror; the notification still goes out either way.
- if (data.reason === 'payment') {
+ if (data.reason === 'payment' || data.reason === 'zap') {
try { await bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }); }
catch (err) { console.warn('[sw-aw] failed:', err && err.message); }
}
@@ -40,19 +40,30 @@ self.addEventListener('push', (e) => {
if (verdict && verdict.name) dmName = verdict.name;
} catch (err) { console.warn('[sw-dm] could not classify:', err && err.message); }
}
+ // A reaction names who, when this device knows them, and is dropped
+ // when they are muted here — the notifier can't know either.
+ let who = null;
+ if (data.reason === 'reaction' && data.from) {
+ const boxes = await allInboxes().catch(() => []);
+ if (boxes.some((b) => (b.muted || []).includes(data.from))) return;
+ who = boxes.map((b) => b.names && b.names[data.from]).find(Boolean) || null;
+ }
+ const emoji = !data.emoji || data.emoji === '+' ? '\u2764\ufe0f' : /^:[\w-]+:$/.test(data.emoji) ? data.emoji : data.emoji.slice(0, 8);
const T = {
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.'],
+ zap: ['Zap received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Someone zapped you. Open coinos to see it.'],
+ reaction: [who ? `${who} reacted ${emoji}` : `New reaction ${emoji}`, 'to your post. 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: './' };
+ const target = data.reason === 'mention' || data.reason === 'reaction' ? { 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.reason === 'mention',
+ tag: 'notify-' + (data.reason || 'chat'), renotify: ['payment', 'zap', 'mention'].includes(data.reason),
data: target,
});
})());
diff --git a/tools/notif-categories-test.js b/tools/notif-categories-test.js
new file mode 100644
index 0000000..92b0c71
--- /dev/null
+++ b/tools/notif-categories-test.js
@@ -0,0 +1,39 @@
+// Settings → Notifications lists zaps and reactions as their own categories,
+// each with its own switch. Run: bun tools/notif-categories-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844 });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ await page.goto(server.url.origin + '/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload(); await sleep(500);
+ await click('create a new wallet'); await sleep(600);
+ await click('skip verification'); await sleep(300);
+ await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'), { timeout: 20000 });
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(600);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /notification/i.test(e.textContent)).click()); await sleep(600);
+ const rows = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.card .row.between')]
+ .map((r) => [r.querySelector('span')?.textContent, r.querySelector('button')?.textContent])));
+ const before = await rows();
+ for (const k of ['Payments received', 'Zaps on your posts', 'Direct messages', 'Replies and mentions on nostr', 'Reactions to your posts'])
+ assert.equal(before[k], 'On', k + ' is listed and on by default');
+ await page.evaluate(() => [...document.querySelectorAll('.card .row.between')].find((r) => /Reactions/.test(r.textContent)).querySelector('button').click());
+ await sleep(500);
+ const after = await rows();
+ assert.equal(after['Reactions to your posts'], 'Off', 'reactions switch off on their own');
+ assert.equal(after['Zaps on your posts'], 'On');
+ assert.equal(after['Replies and mentions on nostr'], 'On');
+ assert.deepEqual(errors, []);
+ console.log('✓ zaps and reactions have their own switches', JSON.stringify(after));
+} finally { await browser.close(); server.stop(true); }