diff options
| -rw-r--r-- | nwcpush/server.js | 30 | ||||
| -rw-r--r-- | src/features/messages.js | 5 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/locales/ar.json | 2 | ||||
| -rw-r--r-- | src/locales/bn.json | 2 | ||||
| -rw-r--r-- | src/locales/de.json | 2 | ||||
| -rw-r--r-- | src/locales/es.json | 2 | ||||
| -rw-r--r-- | src/locales/fr.json | 2 | ||||
| -rw-r--r-- | src/locales/hi.json | 2 | ||||
| -rw-r--r-- | src/locales/ja.json | 2 | ||||
| -rw-r--r-- | src/locales/ko.json | 2 | ||||
| -rw-r--r-- | src/locales/pt.json | 2 | ||||
| -rw-r--r-- | src/locales/ru.json | 2 | ||||
| -rw-r--r-- | src/locales/tr.json | 2 | ||||
| -rw-r--r-- | src/locales/zh.json | 2 | ||||
| -rw-r--r-- | src/sw-nwc.js | 17 | ||||
| -rw-r--r-- | tools/notif-categories-test.js | 39 |
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); } |
