| 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); |