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>
2 files changed,
+105
−97
web/static/rosterui.js+104 −0web/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 |