eleven / ocho Public

sw: notifications speak the viewer's names — sender by uid, @mentions rewritten (#650)

The page pre-resolves a uid → display-name map and the published-label →
my-name rename map into IndexedDB alongside the thread keys (the SW holds
no key that could open the nicknames blob itself); the SW resolves the
sender and rewrites @mentions with a classic-worker copy of ui.js
translateMentions (unit-tested there). Thread titles were already
nickname-aware via threadName.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 91599c99ab77379d6ac5295fc5ee6ad0f4609a67 parent 03b49d9
3 files changed, +38 −4
  • changelog/2026-07-11-notifications-use-your-names.md +4 −0
  • web/static/shell.js +10 −0
  • web/static/sw.js +24 −4
diff --git a/changelog/2026-07-11-notifications-use-your-names.md b/changelog/2026-07-11-notifications-use-your-names.md
new file mode 100644
index 0000000..a8d6de9
--- /dev/null
+++ b/changelog/2026-07-11-notifications-use-your-names.md
@@ -0,0 +1,4 @@
+🤖 Notifications now use your names for people. If you call someone "Dermo",
+a message or reaction from them says Dermo in the notification — and mentions
+inside the message read with your names too. Like everything else, this
+happens on your device; the server never sees a name.
diff --git a/web/static/shell.js b/web/static/shell.js
index bfd9816..bc37f44 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1585,6 +1585,16 @@ function syncThreadKeysToSW() {
// The SW also needs to know who "me" is: reaction pushes are shown only to
// the reacted-to message's author (see sw.js), decided after decryption.
if (myUserId) idbSet("myUid", myUserId).catch(() => {});
+ // Names for the SW's notification text (#577): a sender resolves by uid to
+ // the name I show for them, and @mentions in the body rewrite to my names.
+ // Pre-resolved here (the SW holds no keys for the nicknames blob) — kept
+ // fresh by refreshNamesUI, which calls this on every name change.
+ const byUid = {};
+ for (const e of roster) {
+ const n = displayNameFor(nicknames[e.user_id], e.label);
+ if (n) byUid[e.user_id] = n;
+ }
+ idbSet("memberNames", { byUid, renames: nicknameRenames() }).catch(() => {});
}
// The frontend build this page loaded with (server-injected into the HTML).
diff --git a/web/static/sw.js b/web/static/sw.js
index 4738b8d..2bd5770 100644
--- a/web/static/sw.js
+++ b/web/static/sw.js
@@ -32,6 +32,22 @@ function idbGet(key) {
});
}
+// Per-viewer names (#577) in notification text. `renames` maps published
+// label → the name this member calls them. A classic-worker copy of ui.js
+// translateMentions (unit-tested there; this file can't import modules) —
+// same boundary rules as the feed's renderer: longest label wins, no trailing
+// word char, and a preceding word/@/. char means mid-word or an email.
+function translateMentions(text, renames) {
+ const labels = Object.keys(renames || {});
+ if (!text || !labels.length || !text.includes("@")) return text;
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp("@(" + labels.sort((a, b) => b.length - a.length).map(esc).join("|") + ")(?![\\w])", "g");
+ return text.replace(re, (m, label, idx) => {
+ const prev = text[idx - 1];
+ return prev !== undefined && /[\w@.]/.test(prev) ? m : "@" + renames[label];
+ });
+}
+
async function decryptBlob(blob, keyB64) {
const key = await crypto.subtle.importKey(
"raw", b64urlToBytes(keyB64), "AES-GCM", false, ["decrypt"]
@@ -98,6 +114,10 @@ async function handlePush(event) {
try {
// Per-thread keys (id -> {key, name, kind}) the page syncs to IndexedDB.
const threads = (await idbGet("threadKeys")) || {};
+ // Names as this member shows them (#577): uid → display name, plus the
+ // published-label → my-name map for rewriting @mentions in body text.
+ const names = (await idbGet("memberNames")) || {};
+ const nameFor = (uid, fallback) => (uid && names.byUid && names.byUid[uid]) || fallback;
const t = data.thread && threads[data.thread];
if (data.blob && t && t.key) {
const msg = await decryptBlob(data.blob, t.key);
@@ -110,8 +130,8 @@ async function handlePush(event) {
// author uid notify no one.
const me = await idbGet("myUid");
if (!msg.on || !msg.targetUid || !me || msg.targetUid !== me) return;
- title = t.name || msg.name || "New message";
- body = `${msg.name || "Someone"} reacted ${msg.emoji || ""} to your message`;
+ title = t.name || nameFor(msg.uid, msg.name) || "New message";
+ body = `${nameFor(msg.uid, msg.name) || "Someone"} reacted ${msg.emoji || ""} to your message`;
} else if (msg.notice && msg.notice.title) {
// A mini-app event carrying its own (encrypted) notification — e.g. a
// reminder the server just delivered. The app wrote the words.
@@ -122,8 +142,8 @@ async function handlePush(event) {
// silent rather than showing an empty "New message".
return;
} else {
- const sender = msg.name || "";
- let text = msg.text || "";
+ const sender = nameFor(msg.uid, msg.name) || "";
+ let text = translateMentions(msg.text || "", names.renames);
if (!text && Array.isArray(msg.attachments) && msg.attachments.length) {
const a = msg.attachments[0];
text = a.type && a.type.startsWith("image/") ? "📷 Photo" : "📎 " + (a.name || "File");