eleven / messenger Public

web: extract the roster display helpers into rosterui.js (#961)

The eight member-display helpers (label/nickname resolution, search
matching, presence lines, agent tag, picker rows/sections), verbatim.
The called-identifier audit caught presenceLabel needing its own ui.js
import — its first real catch. Precursor to the group-actions and
new-chat-picker extractions, which both lean on these.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5937555bd96fd3df9cf40975f436c019b6d38434 parent 4ce9108
2 files changed, +105 −97
  • web/static/rosterui.js +104 −0
  • web/static/shell.js +1 −97
diff --git a/web/static/rosterui.js b/web/static/rosterui.js
new file mode 100644
index 0000000..6ee3c3f
--- /dev/null
+++ b/web/static/rosterui.js
@@ -0,0 +1,104 @@
+// Roster display helpers — the shared vocabulary for showing members: label
+// resolution (my nickname vs the published name), search matching, presence
+// lines, the agent tag, and the picker row/section builders. Used by the
+// thread list, the typeahead, the pickers, and the group-action sheets.
+// Extracted verbatim from shell.js.
+import { displayNameFor, presenceLabel } from "/static/ui.js";
+import { roster, nicknames } from "/static/shellstate.js";
+
+export 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.
+export 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.
+export 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).
+export function rosterPresence(userId) {
+ const e = roster.find((x) => x.user_id === userId);
+ return (e && e.presence) || "";
+}
+
+// Fill `el` with a status line — a coloured dot (green/yellow/red) + the
+// Online/Away/Offline text, on an inline-flex wrapper so the dot centres
+// against the text. Empties the element when the state is unknown.
+export function setPresenceLine(el, presence) {
+ el.textContent = "";
+ const label = presenceLabel(presence);
+ if (!label) return;
+ const line = document.createElement("span");
+ line.className = "presence-line";
+ const dot = document.createElement("span");
+ dot.className = "p-dot " + presence;
+ dot.setAttribute("aria-hidden", "true");
+ line.appendChild(dot);
+ line.appendChild(document.createTextNode(label));
+ el.appendChild(line);
+}
+
+// A small "BOT" tag to mark an agent in the roster picker, so it's clear before
+// you add it that it isn't a person. Self-contained styling (no CSS dependency).
+// Accent by default (a discovery cue in pickers/lists); pass muted=true inline
+// on messages, where it repeats on every reply and bright accent reads as noise.
+export function agentTag(muted, kind) {
+ const t = document.createElement("span");
+ const ai = kind === "ai";
+ t.textContent = ai ? "AI" : "BOT";
+ t.title = ai
+ ? "An AI helper — what you send it is sent to an AI provider to answer"
+ : "An automated helper (bot), not a person — it runs on this server";
+ // The chip is centered by its flex parent (each name line is a flex row with
+ // align-items:center — see the call sites), so it needs no baseline nudge;
+ // flex:none keeps it from shrinking, line-height:1 keeps the box tight.
+ t.style.cssText =
+ "flex:none;margin-left:6px;font-size:0.7em;line-height:1;font-weight:600;letter-spacing:0.03em;padding:2px 5px;border-radius:6px;vertical-align:middle;" +
+ (muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;");
+ return t;
+}
+
+// A picker row's name: the label, plus for agents the BOT tag and the admin's
+// one-line description (the Helpers catalog blurb — the "what am I adding?"
+// hint), or for a person their own "about me" line (decrypted from the roster).
+export function pickerName(e) {
+ const nm = document.createElement("span");
+ // Name + badge on a centered flex line; the description (if any) sits below.
+ const line = document.createElement("span");
+ line.style.cssText = "display:flex;align-items:center;";
+ const txt = document.createElement("span");
+ 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);
+ const blurb = e.is_agent ? e.description : e.about;
+ if (blurb) {
+ const d = document.createElement("span");
+ d.className = "helper-desc";
+ d.textContent = blurb;
+ nm.appendChild(d);
+ }
+ return nm;
+}
+
+// Section heading inside a pick-list (People / Helpers).
+export function pickerSection(title) {
+ const s = document.createElement("div");
+ s.className = "pick-section";
+ s.textContent = title;
+ return s;
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 07fd5a6..3ad1eb4 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -134,6 +134,7 @@ import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/
import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md)
import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage } from "/static/apps/chat.js";
import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
+import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md)
import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586)
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
@@ -1780,103 +1781,6 @@ async function resolveRename(uid, keep) {
}
-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) {
- const e = roster.find((x) => x.user_id === userId);
- return (e && e.presence) || "";
-}
-
-// Fill `el` with a status line — a coloured dot (green/yellow/red) + the
-// Online/Away/Offline text, on an inline-flex wrapper so the dot centres
-// against the text. Empties the element when the state is unknown.
-function setPresenceLine(el, presence) {
- el.textContent = "";
- const label = presenceLabel(presence);
- if (!label) return;
- const line = document.createElement("span");
- line.className = "presence-line";
- const dot = document.createElement("span");
- dot.className = "p-dot " + presence;
- dot.setAttribute("aria-hidden", "true");
- line.appendChild(dot);
- line.appendChild(document.createTextNode(label));
- el.appendChild(line);
-}
-
-// A small "BOT" tag to mark an agent in the roster picker, so it's clear before
-// you add it that it isn't a person. Self-contained styling (no CSS dependency).
-// Accent by default (a discovery cue in pickers/lists); pass muted=true inline
-// on messages, where it repeats on every reply and bright accent reads as noise.
-function agentTag(muted, kind) {
- const t = document.createElement("span");
- const ai = kind === "ai";
- t.textContent = ai ? "AI" : "BOT";
- t.title = ai
- ? "An AI helper — what you send it is sent to an AI provider to answer"
- : "An automated helper (bot), not a person — it runs on this server";
- // The chip is centered by its flex parent (each name line is a flex row with
- // align-items:center — see the call sites), so it needs no baseline nudge;
- // flex:none keeps it from shrinking, line-height:1 keeps the box tight.
- t.style.cssText =
- "flex:none;margin-left:6px;font-size:0.7em;line-height:1;font-weight:600;letter-spacing:0.03em;padding:2px 5px;border-radius:6px;vertical-align:middle;" +
- (muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;");
- return t;
-}
-
-// A picker row's name: the label, plus for agents the BOT tag and the admin's
-// one-line description (the Helpers catalog blurb — the "what am I adding?"
-// hint), or for a person their own "about me" line (decrypted from the roster).
-function pickerName(e) {
- const nm = document.createElement("span");
- // Name + badge on a centered flex line; the description (if any) sits below.
- const line = document.createElement("span");
- line.style.cssText = "display:flex;align-items:center;";
- const txt = document.createElement("span");
- 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);
- const blurb = e.is_agent ? e.description : e.about;
- if (blurb) {
- const d = document.createElement("span");
- d.className = "helper-desc";
- d.textContent = blurb;
- nm.appendChild(d);
- }
- return nm;
-}
-
-// Section heading inside a pick-list (People / Helpers).
-function pickerSection(title) {
- const s = document.createElement("div");
- s.className = "pick-section";
- s.textContent = title;
- return s;
-}
-
// The instance key is a single AES key shared by all members, used only for
// all-members content (profile photos). It's wrapped to each member's user
// pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if