// 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;
}