| 1 | // Roster display helpers — the shared vocabulary for showing members: label |
| 2 | // resolution (my nickname vs the published name), search matching, presence |
| 3 | // lines, the agent tag, and the picker row/section builders. Used by the |
| 4 | // thread list, the typeahead, the pickers, and the group-action sheets. |
| 5 | // Extracted verbatim from shell.js. |
| 6 | import { displayNameFor, presenceLabel } from "/static/ui.js"; |
| 7 | import { roster, nicknames } from "/static/shellstate.js"; |
| 8 | |
| 9 | export function rosterLabel(userId) { |
| 10 | const e = roster.find((x) => x.user_id === userId); |
| 11 | return displayNameFor(nicknames[userId], e && e.label) || "Someone"; |
| 12 | } |
| 13 | |
| 14 | // The published label only — for anything that leaves this device inside a |
| 15 | // shared payload (membership announce texts, invite pre-fills): my private |
| 16 | // names for people must never ride where other members can read them. |
| 17 | export function publishedLabel(userId) { |
| 18 | const e = roster.find((x) => x.user_id === userId); |
| 19 | return e && e.label ? e.label : "Someone"; |
| 20 | } |
| 21 | |
| 22 | // Does a roster entry match a (lowercased) search query? Both names count: |
| 23 | // what pickers display is my name for them, so searching the name you can see |
| 24 | // must find them — and searching their published name must too. |
| 25 | export function rosterMatches(e, q) { |
| 26 | if ((e.label || "").toLowerCase().includes(q)) return true; |
| 27 | const nick = nicknames[e.user_id]; |
| 28 | return !!nick && !!nick.name && nick.name.toLowerCase().includes(q); |
| 29 | } |
| 30 | |
| 31 | // Presence ("online" | "away" | "offline" | "") rides on roster entries: seeded |
| 32 | // by /api/roster, kept live by "presence" WS frames. "" = unknown (no dot). |
| 33 | export function rosterPresence(userId) { |
| 34 | const e = roster.find((x) => x.user_id === userId); |
| 35 | return (e && e.presence) || ""; |
| 36 | } |
| 37 | |
| 38 | // Fill `el` with a status line — a coloured dot (green/yellow/red) + the |
| 39 | // Online/Away/Offline text, on an inline-flex wrapper so the dot centres |
| 40 | // against the text. Empties the element when the state is unknown. |
| 41 | export function setPresenceLine(el, presence) { |
| 42 | el.textContent = ""; |
| 43 | const label = presenceLabel(presence); |
| 44 | if (!label) return; |
| 45 | const line = document.createElement("span"); |
| 46 | line.className = "presence-line"; |
| 47 | const dot = document.createElement("span"); |
| 48 | dot.className = "p-dot " + presence; |
| 49 | dot.setAttribute("aria-hidden", "true"); |
| 50 | line.appendChild(dot); |
| 51 | line.appendChild(document.createTextNode(label)); |
| 52 | el.appendChild(line); |
| 53 | } |
| 54 | |
| 55 | // A small "BOT" tag to mark an agent in the roster picker, so it's clear before |
| 56 | // you add it that it isn't a person. Self-contained styling (no CSS dependency). |
| 57 | // Accent by default (a discovery cue in pickers/lists); pass muted=true inline |
| 58 | // on messages, where it repeats on every reply and bright accent reads as noise. |
| 59 | export function agentTag(muted, kind) { |
| 60 | const t = document.createElement("span"); |
| 61 | const ai = kind === "ai"; |
| 62 | t.textContent = ai ? "AI" : "BOT"; |
| 63 | t.title = ai |
| 64 | ? "An AI helper — what you send it is sent to an AI provider to answer" |
| 65 | : "An automated helper (bot), not a person — it runs on this server"; |
| 66 | // The chip is centered by its flex parent (each name line is a flex row with |
| 67 | // align-items:center — see the call sites), so it needs no baseline nudge; |
| 68 | // flex:none keeps it from shrinking, line-height:1 keeps the box tight. |
| 69 | t.style.cssText = |
| 70 | "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;" + |
| 71 | (muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;"); |
| 72 | return t; |
| 73 | } |
| 74 | |
| 75 | // A picker row's name: the label, plus for agents the BOT tag and the admin's |
| 76 | // one-line description (the Helpers catalog blurb — the "what am I adding?" |
| 77 | // hint), or for a person their own "about me" line (decrypted from the roster). |
| 78 | export function pickerName(e) { |
| 79 | const nm = document.createElement("span"); |
| 80 | // Name + badge on a centered flex line; the description (if any) sits below. |
| 81 | const line = document.createElement("span"); |
| 82 | line.style.cssText = "display:flex;align-items:center;"; |
| 83 | const txt = document.createElement("span"); |
| 84 | txt.textContent = displayNameFor(nicknames[e.user_id], e.label); |
| 85 | line.appendChild(txt); |
| 86 | if (e.is_agent) line.appendChild(agentTag(false, e.kind)); |
| 87 | nm.appendChild(line); |
| 88 | const blurb = e.is_agent ? e.description : e.about; |
| 89 | if (blurb) { |
| 90 | const d = document.createElement("span"); |
| 91 | d.className = "helper-desc"; |
| 92 | d.textContent = blurb; |
| 93 | nm.appendChild(d); |
| 94 | } |
| 95 | return nm; |
| 96 | } |
| 97 | |
| 98 | // Section heading inside a pick-list (People / Helpers). |
| 99 | export function pickerSection(title) { |
| 100 | const s = document.createElement("div"); |
| 101 | s.className = "pick-section"; |
| 102 | s.textContent = title; |
| 103 | return s; |
| 104 | } |
| 105 | |