eleven / messenger Public

Web: per-viewer names — keep the name you gave someone when they rename (#577, part 2) (#605)

* web: per-viewer names — keep the name you gave someone when they rename (#577)

The web layer over #596's viewer-private nicknames blob. What I call people
lives in one map (uid → { name, seen }), sealed to my OWN public key —
other members can't open it, my devices share it. Resolution order
everywhere a name renders: my override → published label → payload
snapshot (memberName falls through in the renderer, #592).

- rosterLabel/memberName/pickerName/profile card resolve my name first
- Inviting someone seeds my name for them, so their signup rename never
  silently replaces the name I invited them under
- Rename detection (pure, unit-tested renameNotice in ui.js): an inline
  keep/adopt row under the DM header, and the same choice on the profile
  card (the group-chat path); acknowledging stamps { seen }
- Profile card gains "Call them something else" to rename anyone for
  yourself (typing their published name back clears the override)
- Mentions deliberately stay on published names — a mention travels as
  text every member must resolve

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: review fixes — never leak nicknames outbound, read-modify-write saves, picker search, a11y

- announceJoin/Remove + resendInvite use publishedLabel, never my private
  name for someone (it must not ride inside shared payloads)
- Saves are read-modify-write over a queued-ops journal, serialized on a
  chain, with self-retry: a stale tab or a failed boot load can no longer
  wipe nicknames stored from another device, and a failed save is applied
  locally + keeps syncing (inline 'still syncing' note on the card)
- Picker + mention-typeahead search matches the nickname you can see as
  well as the published name (inserted @mention stays published)
- Escape in the nickname editor cancels the edit, not the whole sheet;
  focus hands back to the edit button / composer instead of dropping to
  body; banner + card notice are polite live regions
- Boot no longer blocks reveal on a stalled /api/nicknames (3s time-box,
  repaint when it lands); changelog no longer claims a member-list path
  that doesn't exist yet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: mention chips show the viewer's name for the person (#577)

Read-side only: matching, the tap menu, and the wire text all stay on
published names (a mention must resolve for every member), but the chip
this viewer sees renders through mentionDisplay — type @dermo, the
typeahead inserts the published @Diarmuid, and your own feed shows @Dermo
again. Native mention renderers need the same hook when parity lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: rename UI wears the app's decision idiom — action chips, weighted names, eleven-input editor

Design pass: the keep/adopt choice is the product's one-tap-decision shape
(action-chip, same as a helper's chips) on both the DM banner and the
profile card — one decision, one look. The banner message renders in
primary ink with the two NAMES at weight 600 (the emphasis encodes which
words matter). The card's editor becomes an eleven-input with the
published name as placeholder; inline cssText moves to .nick-area rules.
Translated mention chips carry the published name in a hover title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: .nick-area .row needs its own display:flex (.row is only styled under .card)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: sidebar preview speaks the viewer's names too — @mentions rewritten, sender prefix by uid

translateMentions (pure, ui.js, tested) rewrites published @names in the
plain-text snippet with the same boundary rules as the feed's mention
renderer, and the group 'Name:' prefix resolves by uid like bubble
headers (#592) — nickname → current label → payload snapshot. Rides into
the native notify preview for free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: previews re-derive when names arrive — boot computed them before labels/nicknames decrypted

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 099e52af0387aa81a8238d51774f81604899b668 parent b865a5d
6 files changed, +508 −19
  • changelog/2026-07-10-call-people-what-you-like.md +9 −0
  • test/ui.test.mjs +36 −0
  • web/static/apps/chat.js +6 −1
  • web/static/shell.js +370 −18
  • web/static/style.css +47 −0
  • web/static/ui.js +40 −0
diff --git a/changelog/2026-07-10-call-people-what-you-like.md b/changelog/2026-07-10-call-people-what-you-like.md
new file mode 100644
index 0000000..04d92e3
--- /dev/null
+++ b/changelog/2026-07-10-call-people-what-you-like.md
@@ -0,0 +1,9 @@
+🤖 You choose what you call people. If you invited someone as "Dermo" and they
+sign up as "Dermot", you keep calling them Dermo — and when someone changes
+their name, the choice appears right in your chat with them: switch to their
+new name, or keep yours. You can also rename anyone for yourself from their
+profile card — tap the title of your chat with them. Mentions follow along:
+type @ and the name you use, and the mention finds them and shows on your
+screen as your name for them (everyone else sees the name they chose). Your
+names for people are private to you: they're locked so only your own devices
+can read them — other members and the server never see them.
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index 22e00e9..3599690 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -17,6 +17,9 @@ import {
onlineFilterKeeps,
chatCategory,
presenceLabel,
+ displayNameFor,
+ renameNotice,
+ translateMentions,
DEFAULT_THEME,
DEFAULT_APPEARANCE,
sanitizeActions,
@@ -345,5 +348,38 @@ await test("presenceLabel: all three states read out; unknown reads blank", () =
assert.equal(presenceLabel(undefined), "");
});
+await test("displayNameFor: my name for someone wins over their published one", () => {
+ assert.equal(displayNameFor({ name: "Dermo", seen: "Dermo" }, "Dermot"), "Dermo");
+ assert.equal(displayNameFor(null, "Dermot"), "Dermot");
+ assert.equal(displayNameFor(undefined, ""), "");
+ assert.equal(displayNameFor({ name: "", seen: "" }, "Dermot"), "Dermot"); // empty nickname = no nickname
+});
+
+await test("renameNotice: fires once per unacknowledged rename, never without a nickname", () => {
+ // The scenario from #577: invited as Dermo, renames to Dermot → I get the choice.
+ assert.deepEqual(renameNotice({ name: "Dermo", seen: "Dermo" }, "Dermot"), { from: "Dermo", to: "Dermot" });
+ // Acknowledged (kept my name): seen catches up, no more prompting.
+ assert.equal(renameNotice({ name: "Dermo", seen: "Dermot" }, "Dermot"), null);
+ // …until they rename AGAIN.
+ assert.deepEqual(renameNotice({ name: "Dermo", seen: "Dermot" }, "Diarmuid"), { from: "Dermo", to: "Diarmuid" });
+ // They renamed to exactly what I call them — nothing to ask.
+ assert.equal(renameNotice({ name: "Dermo", seen: "x" }, "Dermo"), null);
+ // No nickname / no published name yet → never a notice.
+ assert.equal(renameNotice(null, "Dermot"), null);
+ assert.equal(renameNotice({ name: "Dermo", seen: "Dermo" }, ""), null);
+});
+
+await test("translateMentions: rewrites published @names to the viewer's, same boundaries as the renderer", () => {
+ const renames = { Robert: "Bobby", "Anna Lee": "Mum" };
+ assert.equal(translateMentions("@Robert good to see you", renames), "@Bobby good to see you");
+ assert.equal(translateMentions("ping @Anna Lee now", renames), "ping @Mum now"); // multi-word label
+ assert.equal(translateMentions("@Roberta is someone else", renames), "@Roberta is someone else"); // no trailing word char
+ assert.equal(translateMentions("mail me@Robert.com", renames), "mail me@Robert.com"); // email stays
+ assert.equal(translateMentions("hi @Robert and @Robert", renames), "hi @Bobby and @Bobby"); // every occurrence
+ assert.equal(translateMentions("no mentions here", renames), "no mentions here");
+ assert.equal(translateMentions("@Robert", {}), "@Robert"); // no renames → untouched
+ assert.equal(translateMentions("", renames), "");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 55f74e6..d198f5b 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -1662,8 +1662,13 @@ function renderRichText(el, text) {
if (mt.index > last) target.appendChild(document.createTextNode(t.slice(last, mt.index)));
const span = document.createElement("span");
span.className = "mention";
- span.textContent = mt[0];
const label = mt[0].slice(1);
+ // The chip shows the viewer's own name for the person when the host has
+ // one (per-viewer names); the match above stays on the published name.
+ // When the two differ, the published name waits under a hover title.
+ const shown = (host && host.mentionDisplay && host.mentionDisplay(label)) || label;
+ span.textContent = "@" + shown;
+ if (shown !== label) span.title = `Goes by ${label}`;
// Only OTHER members get the tappable link/menu; a self-mention (or an
// unresolved name) stays a plain bold chip, matching the classic feed.
if (host && host.mention && (!host.mentionable || host.mentionable(label))) {
diff --git a/web/static/shell.js b/web/static/shell.js
index ae16ccf..14349c5 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -42,6 +42,9 @@ import {
onlineFilterKeeps,
chatCategory,
presenceLabel,
+ displayNameFor,
+ renameNotice,
+ translateMentions,
} from "/static/ui.js";
import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
@@ -573,6 +576,9 @@ const appThreadView = {}; // thread id -> "chat" | "interface"
// Interface pane shows. Sticks per thread for the session, like appThreadView.
const groupAppView = {}; // thread id -> app name
const threadPreview = {}; // thread id -> last-message snippet
+// The decrypted payload each snippet came from, so previews can re-derive when
+// names arrive/change (boot computes them before labels + nicknames decrypt).
+const previewPayload = {};
// Pending-invite links, keyed by the (pending DM) thread id. Only known in the
// session that minted them — the secret never reaches the server, so it can't be
// recovered after a reload (the pending chat offers "re-send" to mint a fresh one).
@@ -710,7 +716,15 @@ async function boot() {
new Promise((resolve) => setTimeout(() => resolve(null), 4000)),
]);
await loadRoster();
- await Promise.all([setupInstanceKey(), loadThreads()]);
+ // Nicknames need only our own keypair (not the instance key), so they load
+ // alongside — before thread titles are derived below. Time-boxed: a stalled
+ // /api/nicknames must not hold the reveal hostage — on timeout the app
+ // opens with published names, and repaints when the fetch finally lands
+ // (the thread-list check skips the repaint when it landed pre-paint).
+ const nickLoad = loadNicknames().then(() => {
+ if (document.getElementById("thread-list")) refreshNamesUI();
+ });
+ await Promise.all([setupInstanceKey(), loadThreads(), Promise.race([nickLoad, new Promise((res) => setTimeout(res, 3000))])]);
// The two loads race: when the thread list landed first, any open group
// (key under the instance key) was skipped — fold it in now the key's here.
if (instanceKey && threads.some((t) => t.open_key && !threadKeys[t.id])) await loadThreads();
@@ -1104,11 +1118,144 @@ async function publishMyDescription(text) {
} catch {}
}
+// ---- per-viewer names (#577) ---------------------------------------------
+// What *I* call people, independent of the name they publish: uid → { name,
+// seen } (see ui.js displayNameFor/renameNotice for the two rules). The whole
+// map is one blob sealed to MY OWN public key (sealForPub) — deliberately
+// stricter than the instance-key blobs: my names for people are mine, not the
+// group's, so other members can't open it. Devices share the keypair, so it
+// syncs across my devices for free. The native embed never holds userPriv, so
+// there this stays empty and published names render (native parity follows).
+let nicknames = {};
+// Ops this device has made that the server copy may not have yet: uid → the
+// entry to write, or null for "cleared". Saves re-read the server blob and
+// replay only these on top — so a stale tab never clobbers names set on
+// another device, and a boot whose load failed can't turn the next save into
+// a wipe of everything stored. Ops stay queued (and retry) until a save lands.
+const nicknameOps = new Map();
+let nickRetryTimer = 0;
+
+// The server's current map, or null when it can't be known right now (fetch
+// failed, blob won't open, no key). null means "don't write" — never guess.
+async function fetchServerNicknames() {
+ if (!session.userPriv) return null;
+ try {
+ const r = await fetch(`/api/nicknames?token=${encodeURIComponent(session.token)}`);
+ if (!r.ok) return null;
+ const body = await r.json();
+ if (!body.nicknames_blob) return {};
+ const obj = await openSealed(body.nicknames_blob, session.userPriv);
+ return (obj && obj.names) || {};
+ } catch {
+ return null;
+ }
+}
+
+async function loadNicknames() {
+ const server = await fetchServerNicknames();
+ if (server) nicknames = server;
+}
+
+// Read-modify-write: re-read the server copy and replay only this device's
+// pending ops on top, then seal + store. Returns false — leaving the ops
+// queued and a retry scheduled — when the read or the write fails. Saves are
+// serialized on a chain: two quick renames must not interleave their
+// fetch/POST pairs (the second's op would be dropped by the first's cleanup).
+let nickSaveChain = Promise.resolve(true);
+function saveNicknames() {
+ nickSaveChain = nickSaveChain.then(saveNicknamesNow, saveNicknamesNow);
+ return nickSaveChain;
+}
+
+async function saveNicknamesNow() {
+ if (!session.userPriv || !session.userPub) return true;
+ if (!nicknameOps.size) return true;
+ const included = [...nicknameOps.entries()];
+ const base = await fetchServerNicknames();
+ if (!base) return queueNickRetry();
+ const merged = { ...base };
+ for (const [uid, entry] of included) {
+ if (entry) merged[uid] = entry;
+ else delete merged[uid];
+ }
+ try {
+ const blob = await sealForPub(session.userPub, { v: 1, names: merged });
+ const r = await fetch("/api/nicknames", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, nicknames_blob: blob }),
+ });
+ if (!r.ok) return queueNickRetry();
+ nicknames = merged; // adopt anything another device stored meanwhile too
+ // Clear only what this save wrote — an op re-set mid-save stays queued.
+ for (const [uid, entry] of included) if (nicknameOps.get(uid) === entry) nicknameOps.delete(uid);
+ return true;
+ } catch {
+ return queueNickRetry();
+ }
+}
+
+// A failed save must not be a silent loss: keep trying while ops are queued.
+// Always returns false so saveNicknames' failure paths read as one-liners.
+function queueNickRetry() {
+ clearTimeout(nickRetryTimer);
+ nickRetryTimer = setTimeout(async () => {
+ if (nicknameOps.size && (await saveNicknames())) await refreshNamesUI();
+ }, 5000);
+ return false;
+}
+
+// Set (or with "" clear) my name for someone, stamping `seen` with their
+// current published label so a later rename of theirs prompts me exactly once.
+// Returns whether the save has landed (false = applied locally, still syncing).
+async function setNickname(uid, name) {
+ const e = roster.find((x) => x.user_id === uid);
+ const entry = name ? { name, seen: (e && e.label) || "" } : null;
+ if (entry) nicknames[uid] = entry;
+ else delete nicknames[uid];
+ nicknameOps.set(uid, entry);
+ const ok = await saveNicknames();
+ await refreshNamesUI();
+ return ok;
+}
+
+// Acknowledge a rename: keep my name but stop prompting (keep=true), or drop
+// my name and follow theirs from now on (keep=false).
+async function resolveRename(uid, keep) {
+ const e = roster.find((x) => x.user_id === uid);
+ const nick = nicknames[uid];
+ if (!nick) return true;
+ const entry = keep ? { name: nick.name, seen: (e && e.label) || nick.seen } : null;
+ if (entry) nicknames[uid] = entry;
+ else delete nicknames[uid];
+ nicknameOps.set(uid, entry);
+ const ok = await saveNicknames();
+ await refreshNamesUI();
+ return ok;
+}
+
function rosterLabel(userId) {
+ const e = roster.find((x) => x.user_id === userId);
+ return displayNameFor(nicknames[userId], e && e.label) || "Someone";
+}
+
+// The published label only — for anything that leaves this device inside a
+// shared payload (membership announce texts, invite pre-fills): my private
+// names for people must never ride where other members can read them.
+function publishedLabel(userId) {
const e = roster.find((x) => x.user_id === userId);
return e && e.label ? e.label : "Someone";
}
+// Does a roster entry match a (lowercased) search query? Both names count:
+// what pickers display is my name for them, so searching the name you can see
+// must find them — and searching their published name must too.
+function rosterMatches(e, q) {
+ if ((e.label || "").toLowerCase().includes(q)) return true;
+ const nick = nicknames[e.user_id];
+ return !!nick && !!nick.name && nick.name.toLowerCase().includes(q);
+}
+
// Presence ("online" | "away" | "offline" | "") rides on roster entries: seeded
// by /api/roster, kept live by "presence" WS frames. "" = unknown (no dot).
function rosterPresence(userId) {
@@ -1162,7 +1309,7 @@ function pickerName(e) {
const line = document.createElement("span");
line.style.cssText = "display:flex;align-items:center;";
const txt = document.createElement("span");
- txt.textContent = e.label;
+ txt.textContent = displayNameFor(nicknames[e.user_id], e.label);
line.appendChild(txt);
if (e.is_agent) line.appendChild(agentTag(false, e.kind));
nm.appendChild(line);
@@ -1384,6 +1531,7 @@ async function loadThreads() {
} else if (payload.type !== "reaction") {
// A trailing reaction event stays silent (like the live path) —
// previewText would render it as "Name: " with nothing after.
+ previewPayload[t.id] = payload;
threadPreview[t.id] = previewText(t, payload);
}
// Seed the unread dot from the synced watermark, applying the same
@@ -1621,6 +1769,9 @@ window.lchatPaletteRun = (kind, id) => {
// A one-line snippet for the chat list: "Name: text" in groups, just text in DMs.
function previewText(t, data) {
let body = (data.text || "").replace(/\s+/g, " ").trim();
+ // The feed shows @mentions as the viewer's own names for people (#577); the
+ // plain-text preview says the same thing (also rides into native notify).
+ body = translateMentions(body, nicknameRenames());
const atts = data.attachments || [];
if (!body && atts.length) {
const n = atts.length;
@@ -1631,8 +1782,22 @@ function previewText(t, data) {
}
}
// An action line ("Alice waves") already names the actor — skip the group
- // prefix so it doesn't double up ("Alice: Alice waves").
- return t.kind === "group" && data.name && !data.action ? `${data.name}: ${body}` : body;
+ // prefix so it doesn't double up ("Alice: Alice waves"). The prefix resolves
+ // by uid like the bubble headers (#592) — my name for them, their current
+ // name, then the payload snapshot.
+ const who = (data.uid && displayNameFor(nicknames[data.uid], (roster.find((x) => x.user_id === data.uid) || {}).label)) || data.name;
+ return t.kind === "group" && who && !data.action ? `${who}: ${body}` : body;
+}
+
+// Published label → my name for them, for every member I've renamed — the map
+// translateMentions rewrites plain text with.
+function nicknameRenames() {
+ const map = {};
+ for (const e of roster) {
+ const nick = nicknames[e.user_id];
+ if (nick && nick.name && e.label && nick.name !== e.label) map[e.label] = nick.name;
+ }
+ return map;
}
// Native (Eleven) local notification: inside the Mac wrapper, hand a decrypted
@@ -1690,17 +1855,23 @@ async function displayName(t) {
// them from the current roster labels.
async function recomputeThreadNames() {
await Promise.all(threads.map(async (t) => (threadName[t.id] = await displayName(t))));
+ // Snippets bake names in too (sender prefix, @mentions) — re-derive them
+ // from their retained payloads with the labels/nicknames now in hand.
+ for (const t of threads) if (previewPayload[t.id]) threadPreview[t.id] = previewText(t, previewPayload[t.id]);
}
// Recompute titles and repaint everywhere a name shows — the chat list, the open
// thread header, and the service worker's notification titles. For use after the
// app shell exists (post-boot: the key arriving, or a live label event).
async function refreshNamesUI() {
- await recomputeThreadNames();
+ await recomputeThreadNames(); // also re-derives the baked-name snippets
renderThreadList();
if (activeId) {
const el = document.getElementById("thread-title");
if (el) el.textContent = threadName[activeId] || "Chat";
+ // A label change may mean a DM partner renamed themself past my nickname
+ // for them (#577) — offer the keep/adopt choice (or clear a settled one).
+ updateRenameBanner(threads.find((x) => x.id === activeId));
}
syncThreadKeysToSW();
}
@@ -2528,6 +2699,7 @@ async function openThread(id) {
// A pending DM (invitee not joined yet) shows a waiting-to-join banner with the
// share link; a normal chat clears any stale banner.
updatePendingBanner(threads.find((x) => x.id === id));
+ updateRenameBanner(threads.find((x) => x.id === id));
document.getElementById("tt").classList.add("show-thread");
renderThreadList();
@@ -3214,14 +3386,17 @@ setChatAppHost({
},
loadOlder: (threadId, beforeSid) => chatAppLoadOlder(threadId, beforeSid),
// A member's current (client-decrypted) roster name by uid, "" when unknown —
- // the bubble-header lookup. Deliberately NOT rosterLabel: no "Someone"
- // fallback, so the renderer can fall through to the payload's name snapshot.
+ // the bubble-header lookup, my own name for them winning (#577). Deliberately
+ // no "Someone" fallback, so the renderer can fall through to the payload's
+ // name snapshot.
memberName: (uid) => {
const e = roster.find((x) => x.user_id === uid);
- return (e && e.label) || "";
+ return displayNameFor(nicknames[uid], e && e.label);
},
// Mentions: the roster's (client-decrypted) display names, and the original
- // tap-a-mention member menu (Message them …).
+ // tap-a-mention member menu (Message them …). Deliberately the PUBLISHED
+ // names, not my nicknames — a mention travels as text every member must
+ // resolve, so "@Dermo" would highlight for no one else.
mentionNames: () => roster.map((e) => e.label).filter(Boolean),
mention: (label, anchor) => {
const entry = roster.find((e) => e.label === label);
@@ -3233,6 +3408,13 @@ setChatAppHost({
const entry = roster.find((e) => e.label === label);
return !!entry && entry.user_id !== myUserId;
},
+ // The chip text for a matched @mention: my name for them when I have one
+ // (#577). Read-side only — matching, the menu, and the wire text all stay
+ // on published names; only what THIS viewer sees on the chip changes.
+ mentionDisplay: (label) => {
+ const entry = roster.find((e) => e.label === label);
+ return (entry && displayNameFor(nicknames[entry.user_id], entry.label)) || label;
+ },
// Presentation facts the app needs to render the CLASSIC markup exactly:
// sender names show only in groups; helper senders get the AI/BOT badge; the
// hover icons are the same static in-code SVGs the original feed uses.
@@ -4065,7 +4247,8 @@ function sendAndConfirm(threadId, blob) {
async function announceJoin(threadId, uids) {
const key = threadKeys[threadId];
if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return;
- const payload = { name: session.name || "New member", text: `${session.name || "New member"} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true };
+ // publishedLabel, NOT rosterLabel: this text is read by every member.
+ const payload = { name: session.name || "New member", text: `${session.name || "New member"} added ${nameList(uids.map(publishedLabel))}`, join: uids.slice(), ts: Date.now(), action: true };
if (myUserId) payload.uid = myUserId;
const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
@@ -4093,7 +4276,8 @@ function announceLeave(threadId) {
async function announceRemove(threadId, uids) {
const key = threadKeys[threadId];
if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return;
- const payload = { name: session.name || "New member", text: `${session.name || "New member"} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true };
+ // publishedLabel, NOT rosterLabel: this text is read by every member.
+ const payload = { name: session.name || "New member", text: `${session.name || "New member"} removed ${nameList(uids.map(publishedLabel))}`, removed: uids.slice(), ts: Date.now(), action: true };
if (myUserId) payload.uid = myUserId;
const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
@@ -5607,6 +5791,7 @@ function connectWS() {
if (t) {
t.last_at = msg.created_at || Math.floor(Date.now() / 1000);
if (payload) {
+ previewPayload[tid] = payload;
const preview = previewText(t, payload);
threadPreview[tid] = preview;
maybeNativeNotify(tid, payload, preview);
@@ -6083,7 +6268,7 @@ function viewProfile(uid) {
if (!e) return;
const backdrop = modal();
const card = backdrop.querySelector(".sheet");
- const av = avatarEl(e.label || "?", () => avatarFor(uid));
+ const av = avatarEl(displayNameFor(nicknames[uid], e.label) || "?", () => avatarFor(uid));
av.classList.add("avatar-lg");
const avWrap = document.createElement("div");
avWrap.style.cssText = "text-align:center;margin:6px 0 12px";
@@ -6092,10 +6277,96 @@ function viewProfile(uid) {
const name = document.createElement("h2");
name.style.cssText = "display:flex;align-items:center;justify-content:center";
const nm = document.createElement("span");
- nm.textContent = e.label || "Someone";
+ nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
name.appendChild(nm);
if (e.is_agent) name.appendChild(agentTag(false, e.kind));
card.appendChild(name);
+
+ // Per-viewer names (#577), people only: when I call them something of my
+ // own, their published name shows beneath; a fresh rename of theirs offers
+ // the keep/adopt choice; and "Call them something else" edits my name for
+ // them (clearing it follows their published name again).
+ if (!e.is_agent && uid !== myUserId && e.public_key) {
+ const nickArea = document.createElement("div");
+ nickArea.className = "nick-area";
+ // The card's name/notice lines change under AT users' feet as they choose —
+ // a polite live region announces the outcome without stealing focus.
+ nickArea.setAttribute("role", "status");
+ card.appendChild(nickArea);
+ // synced=false appends an inline "still syncing" line — a failed save is
+ // applied locally and retries, but must never read as a clean success.
+ const paintNick = (focusEdit, synced = true) => {
+ nickArea.textContent = "";
+ nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
+ const nick = nicknames[uid];
+ const notice = renameNotice(nick, e.label);
+ if (notice) {
+ const row = document.createElement("p");
+ row.className = "rename-note";
+ row.appendChild(renameLine(notice));
+ const choose = async (keepMine) => paintNick(true, await resolveRename(uid, keepMine));
+ for (const b of renameChoices(notice, choose)) row.appendChild(b);
+ nickArea.appendChild(row);
+ } else if (nick && nick.name && e.label && e.label !== nick.name) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.style.margin = "0";
+ p.textContent = `Goes by ${e.label}`;
+ nickArea.appendChild(p);
+ }
+ const edit = document.createElement("button");
+ edit.className = "linkish";
+ edit.textContent = nick && nick.name ? "Change what you call them" : "Call them something else";
+ edit.onclick = () => {
+ nickArea.textContent = "";
+ const input = document.createElement("input");
+ input.type = "text";
+ input.maxLength = 80;
+ input.value = displayNameFor(nick, e.label);
+ input.className = "eleven-input nick-input";
+ input.placeholder = e.label || "";
+ input.setAttribute("aria-label", "Your name for them");
+ const row = document.createElement("div");
+ row.className = "row";
+ const save = document.createElement("button");
+ save.textContent = "Save";
+ save.onclick = async () => {
+ const v = input.value.trim();
+ // Typing their published name back = no override to keep.
+ paintNick(true, await setNickname(uid, v === e.label ? "" : v));
+ };
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => paintNick(true);
+ input.onkeydown = (ev) => {
+ if (ev.key === "Enter") (ev.preventDefault(), save.onclick());
+ // stopPropagation: Escape here cancels the edit, not the whole sheet
+ // (modal() closes on a document-level Escape otherwise).
+ if (ev.key === "Escape") (ev.stopPropagation(), paintNick(true));
+ };
+ row.appendChild(save);
+ row.appendChild(cancel);
+ nickArea.appendChild(input);
+ nickArea.appendChild(row);
+ input.focus();
+ input.select();
+ };
+ nickArea.appendChild(edit);
+ if (!synced) {
+ const warn = document.createElement("p");
+ warn.className = "sub";
+ warn.style.margin = "2px 0 0";
+ warn.textContent = "Couldn't reach the server just now — saved here, still syncing.";
+ nickArea.appendChild(warn);
+ }
+ // Save/Cancel/Escape destroy the control that held focus; hand it to the
+ // edit button so keyboard focus never drops out of the sheet's Tab trap.
+ if (focusEdit) edit.focus();
+ };
+ paintNick();
+ }
+
const blurb = e.is_agent ? e.description : e.about;
if (blurb) {
const p = document.createElement("p");
@@ -6106,6 +6377,78 @@ function viewProfile(uid) {
}
}
+// A DM partner renamed themself while I hold my own name for them (#577): a
+// slim inline row between the header and the feed — occupying layout space,
+// never floating — offering the choice once. Clears itself when there's
+// nothing to ask (no nickname, acknowledged, or not a DM; the person's profile
+// card — today opened from your DM's header — shows the same choice).
+function updateRenameBanner(t) {
+ const view = document.getElementById("thread-view");
+ if (!view) return;
+ let bar = document.getElementById("rename-banner");
+ let notice = null;
+ let other = null;
+ if (t && t.kind === "dm") {
+ other = (t.members || []).find((uid) => uid !== myUserId);
+ const e = roster.find((x) => x.user_id === other);
+ notice = renameNotice(nicknames[other], e && e.label);
+ }
+ if (!notice) {
+ if (bar) bar.remove();
+ return;
+ }
+ if (!bar) {
+ bar = document.createElement("div");
+ bar.id = "rename-banner";
+ bar.className = "rename-banner";
+ // A polite live region: the row appears mid-conversation, so AT users are
+ // told the choice exists without focus being stolen from the composer.
+ bar.setAttribute("role", "status");
+ view.insertBefore(bar, document.getElementById("app-surface"));
+ }
+ bar.textContent = "";
+ bar.appendChild(renameLine(notice));
+ // Choosing removes the bar (and the focused button with it) — hand focus to
+ // the composer so keyboard users aren't dropped onto <body>.
+ const choose = async (keepMine) => {
+ await resolveRename(other, keepMine);
+ const box = view.querySelector(".chatapp-root .composer textarea") || document.getElementById("text");
+ if (box) box.focus();
+ };
+ for (const b of renameChoices(notice, choose)) bar.appendChild(b);
+}
+
+// "<from> now goes by <to>" with the two NAMES carrying the weight — the
+// emphasis encodes which words matter; the sentence around them stays plain.
+function renameLine(notice) {
+ const msg = document.createElement("span");
+ msg.className = "rename-msg";
+ const from = document.createElement("span");
+ from.className = "rename-name";
+ from.textContent = notice.from;
+ const to = document.createElement("span");
+ to.className = "rename-name";
+ to.textContent = notice.to;
+ msg.appendChild(from);
+ msg.appendChild(document.createTextNode(" now goes by "));
+ msg.appendChild(to);
+ return msg;
+}
+
+// The keep/adopt pair, in the app's one-tap-decision shape (action chips).
+// choose(keepMine) resolves; both surfaces (banner + profile card) share this.
+function renameChoices(notice, choose) {
+ const use = document.createElement("button");
+ use.className = "action-chip";
+ use.textContent = `Use ${notice.to}`;
+ use.onclick = () => choose(false);
+ const keep = document.createElement("button");
+ keep.className = "action-chip";
+ keep.textContent = `Keep ${notice.from}`;
+ keep.onclick = () => choose(true);
+ return [use, keep];
+}
+
// The group info incl. the space description — plaintext branding-adjacent
// metadata like the admin name/email, member-gated on /api/group.
async function fetchSpaceInfo() {
@@ -6288,7 +6631,7 @@ function addPeople() {
const renderList = () => {
const q = filter.value.trim().toLowerCase();
list.innerHTML = "";
- const matches = candidates.filter((x) => x.label.toLowerCase().includes(q));
+ const matches = candidates.filter((x) => rosterMatches(x, q));
const addRow = (e) => {
const row = document.createElement("label");
row.className = "list-row";
@@ -6732,8 +7075,8 @@ const disc = document.createElement("button");
// member list can't include an app). People get a heading only when another
// group sits alongside them (a lone people list needs no label); helpers and
// apps always get theirs, since each is deliberately "not a person".
- const people = others.filter((e) => !e.is_agent && e.label.toLowerCase().includes(q));
- const helpers = others.filter((e) => e.is_agent && e.label.toLowerCase().includes(q));
+ const people = others.filter((e) => !e.is_agent && rosterMatches(e, q));
+ const helpers = others.filter((e) => e.is_agent && rosterMatches(e, q));
const apps = mode === "people" ? matchingApps(q) : [];
if (!people.length && !helpers.length && !apps.length) {
const p = document.createElement("p");
@@ -7051,6 +7394,13 @@ async function inviteSomeone(name) {
if (r.user_id && !roster.some((e) => e.user_id === r.user_id)) {
roster.push({ user_id: r.user_id, label: name, label_blob: labelBlob, public_key: "", avatar: "" });
}
+ // The name I invited them under becomes MY name for them (#577): if they pick
+ // a different one at signup I keep this, and their rename prompts me once.
+ if (r.user_id) {
+ nicknames[r.user_id] = { name, seen: name };
+ nicknameOps.set(r.user_id, { name, seen: name });
+ await saveNicknames(); // a failed save self-retries; never blocks the invite
+ }
return r.thread_id;
}
@@ -7062,7 +7412,7 @@ async function resendInvite(threadId) {
if (!b64) throw new Error("no key");
const t = threads.find((x) => x.id === threadId);
const other = (t.members || []).find((uid) => uid !== myUserId);
- const name = rosterLabel(other);
+ const name = publishedLabel(other); // rides in the link they open — never my private name for them
const T = randomSecret();
const { id, wrapKey, claimHash } = await deriveInvite(T);
const wrapped = await encrypt(wrapKey, { threadKey: b64 });
@@ -7820,7 +8170,9 @@ function typeaheadItems(tok) {
.map((a) => ({ name: a.title || a.name, glyph: a.glyph || "▦", insert: "@" + (a.title || a.name) + " " }));
return [
...roster
- .filter((e) => e.user_id !== myUserId && e.label && e.label.toLowerCase().includes(q))
+ // rosterMatches: typing the name YOU call someone finds them too — but
+ // what inserts is always the published @name every member can resolve.
+ .filter((e) => e.user_id !== myUserId && e.label && rosterMatches(e, q))
.map((e) => ({ name: e.label, userId: e.user_id, insert: "@" + e.label + " " })),
...appMatches,
].slice(0, 8);
diff --git a/web/static/style.css b/web/static/style.css
index 4f2ee4c..6dc1918 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1809,6 +1809,53 @@ input.switch:disabled { opacity: 0.5; cursor: default; }
.pending-card .row { justify-content: center; gap: 10px; flex-wrap: wrap; }
.linkish.danger { color: var(--danger); }
+/* Per-viewer names (#577): a DM partner renamed themself while you hold your
+ own name for them. A slim inline row between header and feed — it occupies
+ layout space (never floats over content) and clears once you choose. The
+ choices wear the action-chip idiom (the app's one-tap-decision shape, same
+ as a helper's chips), so "the app is asking you something" reads instantly;
+ the two NAMES carry the emphasis, the frame stays quiet. */
+.rename-banner {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 8px 12px;
+ font-size: 14px;
+ color: var(--ink);
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+}
+.rename-banner .rename-msg { margin-right: 2px; }
+.rename-name { font-weight: 600; }
+.rename-banner .action-chip:focus-visible,
+.rename-note .action-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+/* The same choice on a profile card (the group-chat path to it) — identical
+ chips, so both surfaces of the one decision look like the one decision. */
+.rename-note {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin: 2px 0 8px;
+ font-size: 14px;
+ color: var(--ink);
+}
+/* The profile card's "what I call them" block: the Goes-by fact, the rename
+ entry point, and the inline editor. */
+.nick-area { text-align: center; margin: 2px 0 4px; }
+.nick-area .nick-input {
+ padding: 8px 12px;
+ width: 240px;
+ max-width: 100%;
+ text-align: center;
+ font-size: 15px;
+}
+.nick-area .row { display: flex; justify-content: center; gap: 10px; margin-top: 8px; }
+
/* ---- multi-thread layout (docs/threads-and-dms.md) ---- */
.tt { display: flex; height: 100%; width: 100%; background: var(--panel); }
/* floating connection-status pill (only shown during a sustained outage) */
diff --git a/web/static/ui.js b/web/static/ui.js
index e6ce518..c7a58b3 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -165,6 +165,46 @@ export function presenceLabel(presence) {
return presence === "online" ? "Online" : presence === "away" ? "Away" : presence === "offline" ? "Offline" : "";
}
+// ---- per-viewer names (#577) ----------------------------------------------
+// A member may keep their own name for someone ("Dermo"), independent of the
+// name that person publishes ("Dermot"). A nickname entry is { name, seen }:
+// `name` is what the viewer calls them, `seen` is the published label the
+// viewer last acknowledged. Pure so both rules are unit-tested in Node and
+// mirrorable by ios/LChatCore when native lands.
+
+// The name to show: the viewer's own name for someone wins over the name they
+// publish. "" when neither exists (callers pick their own fallback).
+export function displayNameFor(nick, label) {
+ return (nick && nick.name) || label || "";
+}
+
+// When the published name has moved on from what the viewer last acknowledged,
+// they get a choice (keep my name / use theirs) — returns { from, to }, or null
+// when there's nothing to ask: no nickname, no published name yet, the change
+// is already acknowledged, or they renamed to exactly what I call them.
+export function renameNotice(nick, label) {
+ if (!nick || !nick.name || !label) return null;
+ if (label === nick.seen || label === nick.name) return null;
+ return { from: nick.name, to: label };
+}
+
+// The chat feed translates @mention chips to the viewer's names at render
+// time; plain-text surfaces that can't parse rich text (the sidebar preview)
+// get the same courtesy as a string rewrite. `renames` maps published label →
+// the viewer's name. Boundary rules mirror the feed's mention renderer
+// (chat.js): longest label wins, no trailing word char, and a preceding
+// word/@/. char means mid-word or an email — leave it alone.
+export 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];
+ });
+}
+
// ---- chat list ordering --------------------------------------------------
// Pinned-first ordering of the thread list: pinned chats float to the top, and
// WITHIN each band chats are ordered most-recent-first by `lastOf(t)` (a