// Small, framework-free UI helpers, kept here so they can be unit-tested in Node. // ---- themes ------------------------------------------------------------- // Colour themes + appearance live entirely client-side (a localStorage pref, // never the server). These pure helpers are the single source of truth for the // valid values + defaults; shell.js applies them and the pre-paint script in the // page mirrors this list (keep them in sync). export const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; export const APPEARANCES = ["light", "dark", "system"]; export const DEFAULT_THEME = "classic"; export const DEFAULT_APPEARANCE = "system"; // Coerce a stored (possibly stale/garbage) value to a valid theme/appearance. export function normalizeTheme(v) { return THEMES.includes(v) ? v : DEFAULT_THEME; } export function normalizeAppearance(v) { return APPEARANCES.includes(v) ? v : DEFAULT_APPEARANCE; } // The CSS `color-scheme` value for an appearance choice. "system" yields // "light dark" so light-dark() in the stylesheet follows the OS setting. export function colorScheme(appearance) { if (appearance === "light") return "light"; if (appearance === "dark") return "dark"; return "light dark"; } // ---- text size ------------------------------------------------------------ // The desktop message text size, in px — the stops of the "Text size" slider // (theme sheet), same idea as Mac iMessage's seven-notch slider. The default // sits ONE FROM THE TOP: message text has a low ceiling (16 is the only // workable bigger size — beyond that bubbles read as shouting), while the // room to taste lies below. Touch devices aren't governed by this at all // (they follow the system text size via -apple-system-body in style.css). // A purely local preference, like the theme. Applied as --msg-font-size on // ; the native Mac app drives the same CSS variable from its own pref. export const TEXT_SIZES = [10, 11, 12, 13, 14, 15, 16]; export const DEFAULT_TEXT_SIZE = 15; // Coerce a stored (possibly stale/garbage) value to a valid size. export function normalizeTextSize(v) { const n = Number(v); return TEXT_SIZES.includes(n) ? n : DEFAULT_TEXT_SIZE; } // appSchemeFrom picks the custom scheme an app handoff fires. The shipped // app registers the plain scheme; dev alts suffix it // (elevenmessenger.alt- — ios/project.yml). A page cannot DETECT // installed apps (deliberately — anti-fingerprinting), so a developer aims // handoffs at an alt by storing the alt's scheme (callers read // localStorage.lchat_app_scheme); anything outside our prefix falls back to // the shipped app. Pure — callers own the storage read. export function appSchemeFrom(raw) { return typeof raw === "string" && /^elevenmessenger(\.alt-[a-z0-9-]{1,32})?$/.test(raw) ? raw : "elevenmessenger"; } // shouldSend decides whether a keydown in the composer should send the message. // // Desktop (precise pointer): Enter sends, Shift+Enter inserts a newline. // Mobile/touch (coarse pointer): Enter always inserts a newline; you send with // the on-screen button instead. `coarse` is matchMedia('(pointer: coarse)'). export function shouldSend(e, coarse) { return e.key === "Enter" && !e.shiftKey && !coarse; } // ---- composer typeahead ------------------------------------------------- // Detect an in-progress ":emoji" or "@mention" token immediately before the // caret, for the composer autocomplete. Returns { kind: "emoji"|"mention", // query, start } (start = index of the trigger char, so the caller can replace // text.slice(start, caret)), or null when the caret isn't in such a token. // // The trigger must sit at a boundary — start of text or just after whitespace — // so "10:30", "http://…", and "email@host" don't pop the menu. Emoji needs at // least one character after ":" (a lone ":" is common punctuation); "@" opens on // its own so you can browse everyone. A leading "/" opens the command menu — a // command is the whole first token, so "/" only triggers at the very start of // the input (and closes once you type past the command name into its args). // Pure, so it's unit-tested in Node. const TOKEN_CHAR = /[a-z0-9_+-]/i; export function composerToken(text, caret) { text = text == null ? "" : String(text); if (caret == null || caret > text.length) caret = text.length; if (text[0] === "/") { const sp = text.indexOf(" "); const nameEnd = sp === -1 ? text.length : sp; // caret past the name → args, no menu return caret <= nameEnd ? { kind: "command", query: text.slice(1, caret), start: 0 } : null; } let i = caret; while (i > 0 && TOKEN_CHAR.test(text[i - 1])) i--; const trigger = text[i - 1]; if (trigger !== "@" && trigger !== ":") return null; const before = text[i - 2]; // char before the trigger (undefined at start) if (before !== undefined && !/\s/.test(before)) return null; const query = text.slice(i, caret); if (trigger === ":") { if (!query) return null; // a bare ":" is punctuation, not an emoji search return { kind: "emoji", query, start: i - 1 }; } return { kind: "mention", query, start: i - 1 }; } // ---- message attribution -------------------------------------------------- // Whether a decrypted message payload was sent by this user (any of their // devices). Newer payloads carry the sender's user id (`uid` — inside the // ciphertext, so the server never sees it); older ones only the screen name, // so fall back to comparing that (same rule the bubble renderer has always // used for mine-vs-theirs). export function ownMessage(payload, myUserId, myName) { if (!payload) return false; if (payload.uid && myUserId) return payload.uid === myUserId; return !!myName && payload.name === myName; } // Whether two feed messages came from the same sender — the sender-run rule // (consecutive same-sender bubbles share one name header). Prefer the uid // (inside the ciphertext, stable across renames and present even when the // sender hadn't picked a name yet); the payload's name snapshot is only the // tiebreak for uid-less legacy messages. Two nameless legacy messages never // group: "" === "" says nothing about who sent them. // Twin: ChatFeedModel.sameSender (native renders the same runs). export function sameSender(a, b) { if (!a || !b) return false; if (a.uid && b.uid) return a.uid === b.uid; return !!a.name && a.name === b.name; } // ---- group header --------------------------------------------------------- // The member-count subtitle for a group header, counting people and bots // separately ("2 members, 3 bots") so a room's bot presence is visible at a // glance. Bots are omitted when there are none; an all-bot group (people = 0, // bots > 0) shows just the bot count. Pure, so it's unit-tested in Node. // Subtitle count, split by helper kind so it mirrors the badges: people are // "member(s)", deterministic helpers "bot(s)" (BOT badge), LLM-backed ones // "AI agent(s)" (AI badge). Zero categories are dropped; "0 members" shows only // when a group has no helpers at all. export function memberCountLabel(people, bots, agents = 0) { const parts = []; if (people || (!bots && !agents)) parts.push(`${people} member${people === 1 ? "" : "s"}`); if (bots) parts.push(`${bots} bot${bots === 1 ? "" : "s"}`); if (agents) parts.push(`${agents} AI agent${agents === 1 ? "" : "s"}`); return parts.join(", "); } // appReloadMessage picks the reload-banner copy for an apps_changed frame's // `kind` ("created" / "updated" / "removed", or missing on an older server). // A blanket "New app available" was confusing for every other kind of change // (a deletion, a member-app tweak) — this names what actually happened. export function appReloadMessage(kind) { if (kind === "created") return "A new app is available."; if (kind === "updated") return "App updated."; return "Apps changed."; } // ---- presence -------------------------------------------------------------- // Which chats the filter menu's "Show online only" keeps. `others` is roster // info for every member except the viewer: [{ agent, presence }]. People // decide when a chat has any: at least one person online right now (bots' // always-on sockets would otherwise match every mixed room forever, and Away // doesn't count — the filter answers "who can I reach right now"). A chat // with only bots falls back to the bots' own status, i.e. is the sidecar up. // A Notes/app thread has no others → filtered out. export function onlineFilterKeeps(others) { const people = others.filter((o) => o && !o.agent); if (people.length) return people.some((o) => o.presence === "online"); return others.some((o) => o && o.agent && o.presence === "online"); } // Which filter-menu category a chat belongs to: "apps" (mini-app threads), // "bots" (every other member is an agent — a bot DM, an all-bot room), else // "people" (anything with another person in it, plus your own Notes). export function chatCategory(isApp, others) { if (isApp) return "apps"; if (others.length && others.every((o) => o && o.agent)) return "bots"; return "people"; } // The status line under a DM partner's name (and beside members in the member // sheet). All three states read out — each wears a coloured dot in the UI // (green/yellow/red). "" = unknown (a keyless invitee, an old server): no line. // Presentation is web + Mac only (there's room for a dot + word); the iOS app // instead shows online as a green ring on the DM header avatar and nothing for // away/offline. The LOGIC here stays the single source, mirrored by // ios/LChatCore Presence.swift. 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 // timestamp). This is the single source of truth for list order — every chat // with newer activity rises, mirroring the server's ORDER BY, so the list stays // correct without any imperative bump. `lastOf` defaults to a no-op (sort by 0), // which — because the sort is stable — leaves the input order untouched. Pure, // so the ordering rule is unit-tested in Node. export function pinnedFirst(list, isPinned, lastOf = () => 0) { const byRecent = (a, b) => (lastOf(b) || 0) - (lastOf(a) || 0); return [...list.filter((t) => isPinned(t)).sort(byRecent), ...list.filter((t) => !isPinned(t)).sort(byRecent)]; } // ---- attachment policy -------------------------------------------------- // ANY file may be shared (2026-07-09 — the old extension allowlist bit real // files, e.g. extensionless text; the server only ever stores an encrypted // blob and the name/MIME ride inside the payload, so a type gate bought // nothing). This map now only picks icons and download MIMEs for the types we // recognise; anything else gets 📎 + octet-stream. It's extension-driven (not // MIME) because browsers report inconsistent — often empty — types for // documents like .md and .csv. Images still take a separate path (downscaled // + re-encoded to JPEG before upload). const FILE_TYPES = { pdf: { mime: "application/pdf", icon: "📕" }, txt: { mime: "text/plain", icon: "📄" }, md: { mime: "text/markdown", icon: "📄" }, markdown: { mime: "text/markdown", icon: "📄" }, csv: { mime: "text/csv", icon: "📊" }, json: { mime: "application/json", icon: "📄" }, doc: { mime: "application/msword", icon: "📘" }, docx: { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", icon: "📘" }, xls: { mime: "application/vnd.ms-excel", icon: "📊" }, xlsx: { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", icon: "📊" }, ppt: { mime: "application/vnd.ms-powerpoint", icon: "📙" }, pptx: { mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", icon: "📙" }, }; // The lowercased extension of a filename (without the dot), or "". export function fileExt(name) { const m = /\.([a-z0-9]+)$/i.exec(name || ""); return m ? m[1].toLowerCase() : ""; } // Any file may be attached — kept (always true) so older call sites keep // working; the icon/MIME maps below are the only per-type behaviour left. export function fileAllowed() { return true; } // Best-effort MIME for an attachment: the browser's reported type if it gave // one, else mapped from the extension so .md/.csv still download/open sensibly. export function fileMime(name, type) { if (type) return type; const t = FILE_TYPES[fileExt(name)]; return t ? t.mime : "application/octet-stream"; } // A document emoji for an attachment, by extension. 📎 for anything unmapped. export function fileIcon(name) { const t = FILE_TYPES[fileExt(name)]; return t ? t.icon : "📎"; } // ---- action chips --------------------------------------------------------- // Helpers (bots) may attach proposed actions to a message payload — the client // renders them as one-tap chips under the bubble. Chips are CONSENT-BASED: a // chip only runs when the member taps it, and this whitelist is the only thing // the client will render — unknown types and invalid params are dropped. // Payloads aren't sender-signed (yet), so any member could craft one: every // action here must therefore be harmless and reversible (switch a theme, open // a panel) — never destructive, never data-revealing. export const CHIP_TYPES = { set_theme: { param: "theme", valid: (v) => THEMES.includes(v) }, set_appearance: { param: "appearance", valid: (v) => APPEARANCES.includes(v) }, open_theme_picker: {}, open_profile: {}, open_tutorial: {}, // starts Eleven's guided tour (e.g. the welcome DM's "Take the tour") // Jump to a thread the member is in (e.g. "Open Morning Headlines" on a // bot-builder confirmation). Navigation only: opening a thread you're not a // member of simply fails, so a crafted chip can't do more than a tap on the // sidebar could. open_thread: { param: "thread", valid: (v) => /^[A-Za-z0-9_-]{1,64}$/.test(v) }, // Open a VIEW a bot created in this chat (docs/views.md) — "here are the 5 // issues" + a chip that shows them. Navigation-only like open_thread: the // tap just pins a panel rendering state this device already folded (and // re-clamped) from the thread's own log, so a crafted chip can at worst // open a view the member could open anyway with /views. open_view: { param: "view", valid: (v) => /^[A-Za-z0-9_-]{1,64}$/.test(v) }, // Open the memory scratchpad for the thread the chip sits in — how a // remember/forget write announces itself (the sidecar attaches this, never // the model). Navigation-free and read-only at the tap: it opens a panel // over state already synced to this device. open_memory: {}, // Connected-service op (the integrations registry, docs/integrations.md): a // bot proposes running one manifest operation and the tap is the member's // consent — for writes, the chip's label carries the manifest's confirm line // ("Register blip.com? This buys the domain and costs real money."), so the // label may run longer than a normal chip's. This sanitize only bounds the // shape; the bot and the pinned executor re-validate everything against the // manifest, and the grant minted on tap is scoped to `access`, so a tampered // chip can't escalate a read into a write. service_op: { longLabel: true, // a consent sentence must render whole, never cut mid-warning sanitize: (a, chip) => { if (typeof a.service !== "string" || a.service.length > 64 || !/^[a-z0-9-]+$/.test(a.service)) return false; if (typeof a.op !== "string" || a.op.length > 64 || !/^[a-z0-9]+(\.[a-z0-9]+)*$/.test(a.op)) return false; if (a.access !== "read" && a.access !== "write") return false; const args = a.args === undefined || a.args === null ? {} : a.args; if (typeof args !== "object" || Array.isArray(args)) return false; const keys = Object.keys(args); if (keys.length > 16 || keys.some((k) => typeof args[k] !== "string")) return false; try { if (JSON.stringify(args).length > 4096) return false; } catch { return false; } chip.service = a.service; chip.op = a.op; chip.access = a.access; chip.args = args; return true; }, }, // Run a slash command on THIS device — how a bot files into your personal // apps ("remind me at 4pm" → a chip running `/remind pick up the kids at // 4pm`; the tap is the permission, and times parse in YOUR timezone because // the command runs where you are). Held to the harmless-and-reversible bar // three ways: the runner only accepts APP commands (never bot/floor // commands), "@here" is refused (no group side effects from a tap), and the // label is ALWAYS the command itself (noLabel) — a chip can never claim to // do one thing and run another. run_command: { longLabel: true, noLabel: true, // the command IS the label; a free label could lie about it sanitize: (a, chip) => { if (typeof a.command !== "string") return false; const cmd = a.command.trim(); if (!cmd.startsWith("/") || cmd.length < 2 || cmd.length > 200) return false; if (/[\x00-\x1f\x7f]/.test(cmd)) return false; // one line, no control chars if (/(^|\s)@here(\s|$)/i.test(cmd)) return false; // never a group install/file from a tap chip.command = cmd; return true; }, }, // A read-only research session across connected services (docs/ // integrations.md § research sessions): the tap seals a short-lived READ // grant per named service to the bot, which answers the goal in-thread. // Reads only by construction — the runner mints nothing but read grants and // the sidecar's loop exposes nothing but read ops — so a tampered chip can // at worst read what the member already allowed that bot to read. service_research: { longLabel: true, sanitize: (a, chip) => { if (!Array.isArray(a.services) || a.services.length < 1 || a.services.length > 4) return false; if (a.services.some((s) => typeof s !== "string" || s.length > 64 || !/^[a-z0-9-]+$/.test(s))) return false; if (typeof a.goal !== "string" || !a.goal.trim() || a.goal.length > 300) return false; chip.services = [...new Set(a.services)]; chip.goal = a.goal.trim(); return true; }, }, }; // capAllows: does a connection's access cap permit an op of this access level? // The cap is chosen by the member when they connect a service ("read" = read // only, "write" = read & write) and rides inside their sealed credential; the // client refuses to mint a grant beyond it. An absent cap is a legacy // connection (stored before caps existed) and keeps its old behaviour — both // levels allowed. Pure, so the rule is one testable place. export function capAllows(cap, access) { if (access !== "write") return true; // reads are always within any cap return cap !== "read"; // writes need a read+write (or legacy, cap-less) connection } // appOpenPlan: what App Studio's "Open" should do for an app with these // places (docs/member-apps.md). An app that can't live in a group chat opens // straight into where it does live — a personal thread when "you" is among // its places, else its space-wide home. One that ONLY lives in group chats // has nothing to open until it's added to one, so the button asks which // group; one that supports group chats AND another place asks how to open it. // Pure, so the routing rule is one testable place. // → { ask: false | "group" | "how", direct: "you" | "space" | null } // `direct` is the non-group place a direct open (or the how-chooser's // non-group option) targets; null only for group-only apps. export function appOpenPlan(places) { const p = Array.isArray(places) && places.length ? places : ["you"]; const direct = p.includes("you") ? "you" : p.includes("space") ? "space" : null; if (!p.includes("chat")) return { ask: false, direct }; return { ask: direct ? "how" : "group", direct }; } // sanitizeActions coerces an untrusted payload `actions` array to renderable // chips: whitelist the type, validate the param (plus the type's own sanitize // hook, when it has one), cap the count and label size. export function sanitizeActions(actions) { if (!Array.isArray(actions)) return []; const out = []; for (const a of actions) { if (out.length === 4) break; // a message is not a menu if (!a || typeof a !== "object") continue; const spec = CHIP_TYPES[a.type]; if (!spec) continue; const chip = { type: a.type }; if (spec.param) { const v = a[spec.param]; if (typeof v !== "string" || !spec.valid(v)) continue; chip[spec.param] = v; } if (spec.sanitize && !spec.sanitize(a, chip)) continue; // noLabel types render from their own params (run_command shows the // command itself) — a free label there could lie about what the tap does. if (!spec.noLabel && typeof a.label === "string" && a.label.trim()) chip.label = a.label.trim().slice(0, spec.longLabel ? 160 : 40); out.push(chip); } return out; }