aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/ark.js4
-rw-r--r--src/i18n.js5
-rw-r--r--src/notify-text.js79
-rw-r--r--src/nwc-respond.js4
-rw-r--r--src/sw-nwc.js32
5 files changed, 101 insertions, 23 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 8b3121c..d1fa356 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -3616,8 +3616,8 @@ export function arkFeature(ctx) {
date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(),
});
navigator.serviceWorker?.ready
- .then((reg) => reg.showNotification('coinos', { body }))
- .catch(() => { try { new Notification('coinos', { body }); } catch {} });
+ .then((reg) => reg.showNotification(t('arkRenewWarnTitle'), { body }))
+ .catch(() => { try { new Notification(t('arkRenewWarnTitle'), { body }); } catch {} });
} catch {}
}
diff --git a/src/i18n.js b/src/i18n.js
index ac6066a..db54708 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1235,7 +1235,10 @@ const STR = {
arkRenewForecast: 'Coins renew in ~{when} for {fee}.',
arkRenewForecastNow: 'Coins renew shortly for {fee}.',
arkBoardStuck: 'Moving money hit a snag and will keep retrying: {error}',
- arkRenewWarnPush: 'Your Spending balance is too small to renew itself and expires around {date}. Receive any amount or spend it to keep it alive.',
+ // the push's words are measured against Chrome's spam model (src/notify-text.js):
+ // 'too small to renew itself and expires around' scored 98, this 11
+ arkRenewWarnTitle: 'Spending balance',
+ arkRenewWarnPush: 'Receive or spend sats before {date} to keep it alive.',
giftArkOutstanding: '{n} unclaimed instant gift(s)',
giftArkRevoked: 'Gift swept back into Spending.',
claimArkFree: 'instant, no fee',
diff --git a/src/notify-text.js b/src/notify-text.js
new file mode 100644
index 0000000..a88ab14
--- /dev/null
+++ b/src/notify-text.js
@@ -0,0 +1,79 @@
+// The words of every notification the worker shows while the app is closed.
+//
+// Chrome on Android (and, quietly, on desktop) scores each web notification's
+// title and body with an on-device "unwanted notification" model before it
+// shows it, and above 90 of 100 the notification is replaced by a "Possible
+// spam" warning — hidden until the user taps "Show notification", and nine
+// such in a week revoke the site's permission outright on a phone that
+// doesn't visit much (Chromium kAutoRevokeSuspiciousNotification). Our old
+// wording scored 92–99 across the board: "Payment received", "Incoming
+// payment", "Payment request", "alice is calling", "New message", "alice
+// sent you a message" — the exact shape of the scam notifications the model
+// was trained (on synthetic data) to catch. What it lets through is less a
+// rule than a measurement; the scores here come from Chrome 156's model via
+// tools/notif-spam-score.js, which re-scores SAMPLES in a throwaway Chrome.
+// Measured 2026-11-03: "sats" reads as money without reading as a payment
+// scam (9–14), "payment" does not (95–97); a person's NAME in the body of a
+// message or reaction line pushes it over (80–99), "New chat" / "<name>"
+// does not (8–32); "reply" in any title is 96–99, "Someone replied" /
+// "Open coinos to see it." is 13; a reply's TEXT is uncontrollable (an
+// "@name" or a link in it scores 97+), so none rides along; "Calling" /
+// "<name>" is 11–29 where every "<name> is calling" shape is 80–99.
+// Re-run the scorer before changing any of these.
+
+export const OPEN = 'Open coinos to see it.';
+
+function sats(n) {
+ const v = Number(n);
+ return v > 0 ? `+${v.toLocaleString()} sats. ${OPEN}` : OPEN;
+}
+
+// A user-facing notification by reason. `d` is the push payload (amountSat,
+// emoji) plus what the device adds (a sender's name).
+export function notifyText(reason, d = {}) {
+ switch (reason) {
+ case 'payment': return ['Sats received', sats(d.amountSat)];
+ case 'zap': return ['Zap received', sats(d.amountSat)];
+ case 'dm': return ['New chat', d.name || 'Someone wrote to you'];
+ case 'chat': return ['New chat activity', 'There are new messages in your communities.'];
+ case 'mention': return [d.reply ? 'Someone replied' : 'You were mentioned', OPEN];
+ case 'reaction': {
+ // a unicode emoji in the title is fine (17); a :shortcode: is not (85)
+ const e = d.emoji && !/^:[\w-]+:$/.test(d.emoji) ? ' ' + d.emoji : '';
+ return ['New reaction' + e, `to your post. ${OPEN}`];
+ }
+ case 'call': return ['Calling', d.name || 'Someone'];
+ // the wallet was asked for an invoice while closed (someone paying its name)
+ case 'incoming': return ['Someone is sending sats', 'Open coinos to receive them.'];
+ // a connected app asked the closed wallet to pay
+ case 'spend': return ['Spend request', 'A connected app asked to spend sats. Open coinos to approve.'];
+ default: return notifyText('chat');
+ }
+}
+
+// What tools/notif-spam-score.js scores: one line per shape a user can see.
+export const SAMPLES = [
+ notifyText('payment', { amountSat: 1000 }),
+ notifyText('payment', { amountSat: 21 }),
+ notifyText('payment'),
+ notifyText('zap', { amountSat: 1000 }),
+ notifyText('zap'),
+ notifyText('dm', { name: 'alice' }),
+ notifyText('dm', { name: 'Bitcoin Bob' }),
+ notifyText('dm', { name: 'Jarryd' }),
+ notifyText('dm'),
+ notifyText('chat'),
+ notifyText('mention'),
+ notifyText('mention', { reply: true }),
+ notifyText('reaction', { emoji: '❤️' }),
+ notifyText('reaction', { emoji: '🤙' }),
+ notifyText('reaction', { emoji: ':pepe:' }),
+ notifyText('call', { name: 'alice' }),
+ notifyText('call', { name: 'Bitcoin Bob' }),
+ notifyText('call', { name: 'Alice Caller' }),
+ notifyText('call'),
+ notifyText('incoming'),
+ notifyText('spend'),
+ ['Payment sent', '-1,000 sats via Damus'],
+ ['Spending balance', 'Receive or spend sats before 11/20/2026 to keep it alive.'],
+];
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 12101f5..98fd19c 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -14,6 +14,7 @@
// to a notification that opens the wallet.
import { hex } from '@scure/base';
+import { notifyText } from './notify-text.js';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent } from 'nostr-tools/pure';
@@ -198,7 +199,8 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log
await saveFn(walletKey, rec);
await publish({ bolt11: a.invoice });
log(`minted ${sat} sat offer invoice while closed`);
- return { notify: { title: 'Incoming payment', body: 'Someone is paying you. Open Coinos to receive it.' } };
+ const [title, body] = notifyText('incoming');
+ return { notify: { title, body } };
} catch (e) {
log('offer mint failed: ' + e.message);
await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {});
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index 3a48cff..a4e4e56 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -9,6 +9,7 @@ import { withRequestLock, withWorkLock } from './nwc-lock.js';
import { appendBgLog, takeLease } from './nwc-bg.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js';
+import { notifyText } from './notify-text.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
// The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is
@@ -108,8 +109,11 @@ self.addEventListener('push', (e) => {
await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } }));
} catch (_) {}
for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} }
- await show((ring.name || 'Someone') + ' is calling', {
- body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.',
+ // the words are measured against Chrome's spam model (notify-text.js):
+ // "<name> is calling" is hidden behind a warning, "Calling" / "<name>" shows
+ const [callTitle, callBody] = notifyText('call', { name: ring.name });
+ await show(callTitle, {
+ body: callBody,
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400],
data: { url: './?open=call', view: 'call' },
@@ -141,24 +145,15 @@ 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;
+ // A reaction from someone muted on this device is dropped — the
+ // notifier can't know. (It no longer names who: a name in the line
+ // is what tips Chrome's spam model over, see notify-text.js.)
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;
+ const [title, body] = notifyText(data.reason, { amountSat: data.amountSat, name: dmName, reply: data.reply, emoji });
// a reply lands you in the Notifications list, where it is
const target = data.reason === 'mention' || data.reason === 'reaction' ? { url: './?open=notifs', view: 'notifs' } : { url: './' };
await show(title, {
@@ -254,10 +249,9 @@ self.addEventListener('push', (e) => {
// mint an invoice) — "an app wants to pay" would read as the opposite.
const incoming = data.event && data.event.kind === 21001;
diary(incoming ? 'waking the user: incoming payment' : 'waking the user: payment request');
- await show(incoming ? 'Incoming payment' : 'Payment request', {
- 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.',
+ const [reqTitle, reqBody] = notifyText(incoming ? 'incoming' : 'spend');
+ await show(reqTitle, {
+ body: reqBody,
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },