eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// Small, framework-free UI helpers, kept here so they can be unit-tested in Node.
2
3// ---- themes -------------------------------------------------------------
4// Colour themes + appearance live entirely client-side (a localStorage pref,
5// never the server). These pure helpers are the single source of truth for the
6// valid values + defaults; shell.js applies them and the pre-paint script in the
7// page <head> mirrors this list (keep them in sync).
8export const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"];
9export const APPEARANCES = ["light", "dark", "system"];
10export const DEFAULT_THEME = "classic";
11export const DEFAULT_APPEARANCE = "system";
12
13// Coerce a stored (possibly stale/garbage) value to a valid theme/appearance.
14export function normalizeTheme(v) {
15 return THEMES.includes(v) ? v : DEFAULT_THEME;
16}
17export function normalizeAppearance(v) {
18 return APPEARANCES.includes(v) ? v : DEFAULT_APPEARANCE;
19}
20
21// The CSS `color-scheme` value for an appearance choice. "system" yields
22// "light dark" so light-dark() in the stylesheet follows the OS setting.
23export function colorScheme(appearance) {
24 if (appearance === "light") return "light";
25 if (appearance === "dark") return "dark";
26 return "light dark";
27}
28
29// ---- text size ------------------------------------------------------------
30// The desktop message text size, in px — the stops of the "Text size" slider
31// (theme sheet), same idea as Mac iMessage's seven-notch slider. The default
32// sits ONE FROM THE TOP: message text has a low ceiling (16 is the only
33// workable bigger size — beyond that bubbles read as shouting), while the
34// room to taste lies below. Touch devices aren't governed by this at all
35// (they follow the system text size via -apple-system-body in style.css).
36// A purely local preference, like the theme. Applied as --msg-font-size on
37// <html>; the native Mac app drives the same CSS variable from its own pref.
38export const TEXT_SIZES = [10, 11, 12, 13, 14, 15, 16];
39export const DEFAULT_TEXT_SIZE = 15;
40
41// Coerce a stored (possibly stale/garbage) value to a valid size.
42export function normalizeTextSize(v) {
43 const n = Number(v);
44 return TEXT_SIZES.includes(n) ? n : DEFAULT_TEXT_SIZE;
45}
46
47// appSchemeFrom picks the custom scheme an app handoff fires. The shipped
48// app registers the plain scheme; dev alts suffix it
49// (elevenmessenger.alt-<slug> — ios/project.yml). A page cannot DETECT
50// installed apps (deliberately — anti-fingerprinting), so a developer aims
51// handoffs at an alt by storing the alt's scheme (callers read
52// localStorage.lchat_app_scheme); anything outside our prefix falls back to
53// the shipped app. Pure — callers own the storage read.
54export function appSchemeFrom(raw) {
55 return typeof raw === "string" && /^elevenmessenger(\.alt-[a-z0-9-]{1,32})?$/.test(raw)
56 ? raw
57 : "elevenmessenger";
58}
59
60// shouldSend decides whether a keydown in the composer should send the message.
61//
62// Desktop (precise pointer): Enter sends, Shift+Enter inserts a newline.
63// Mobile/touch (coarse pointer): Enter always inserts a newline; you send with
64// the on-screen button instead. `coarse` is matchMedia('(pointer: coarse)').
65export function shouldSend(e, coarse) {
66 return e.key === "Enter" && !e.shiftKey && !coarse;
67}
68
69// ---- composer typeahead -------------------------------------------------
70// Detect an in-progress ":emoji" or "@mention" token immediately before the
71// caret, for the composer autocomplete. Returns { kind: "emoji"|"mention",
72// query, start } (start = index of the trigger char, so the caller can replace
73// text.slice(start, caret)), or null when the caret isn't in such a token.
74//
75// The trigger must sit at a boundary — start of text or just after whitespace —
76// so "10:30", "http://…", and "email@host" don't pop the menu. Emoji needs at
77// least one character after ":" (a lone ":" is common punctuation); "@" opens on
78// its own so you can browse everyone. A leading "/" opens the command menu — a
79// command is the whole first token, so "/" only triggers at the very start of
80// the input (and closes once you type past the command name into its args).
81// Pure, so it's unit-tested in Node.
82const TOKEN_CHAR = /[a-z0-9_+-]/i;
83export function composerToken(text, caret) {
84 text = text == null ? "" : String(text);
85 if (caret == null || caret > text.length) caret = text.length;
86 if (text[0] === "/") {
87 const sp = text.indexOf(" ");
88 const nameEnd = sp === -1 ? text.length : sp; // caret past the name → args, no menu
89 return caret <= nameEnd ? { kind: "command", query: text.slice(1, caret), start: 0 } : null;
90 }
91 let i = caret;
92 while (i > 0 && TOKEN_CHAR.test(text[i - 1])) i--;
93 const trigger = text[i - 1];
94 if (trigger !== "@" && trigger !== ":") return null;
95 const before = text[i - 2]; // char before the trigger (undefined at start)
96 if (before !== undefined && !/\s/.test(before)) return null;
97 const query = text.slice(i, caret);
98 if (trigger === ":") {
99 if (!query) return null; // a bare ":" is punctuation, not an emoji search
100 return { kind: "emoji", query, start: i - 1 };
101 }
102 return { kind: "mention", query, start: i - 1 };
103}
104
105// ---- message attribution --------------------------------------------------
106// Whether a decrypted message payload was sent by this user (any of their
107// devices). Newer payloads carry the sender's user id (`uid` — inside the
108// ciphertext, so the server never sees it); older ones only the screen name,
109// so fall back to comparing that (same rule the bubble renderer has always
110// used for mine-vs-theirs).
111export function ownMessage(payload, myUserId, myName) {
112 if (!payload) return false;
113 if (payload.uid && myUserId) return payload.uid === myUserId;
114 return !!myName && payload.name === myName;
115}
116
117// Whether two feed messages came from the same sender — the sender-run rule
118// (consecutive same-sender bubbles share one name header). Prefer the uid
119// (inside the ciphertext, stable across renames and present even when the
120// sender hadn't picked a name yet); the payload's name snapshot is only the
121// tiebreak for uid-less legacy messages. Two nameless legacy messages never
122// group: "" === "" says nothing about who sent them.
123// Twin: ChatFeedModel.sameSender (native renders the same runs).
124export function sameSender(a, b) {
125 if (!a || !b) return false;
126 if (a.uid && b.uid) return a.uid === b.uid;
127 return !!a.name && a.name === b.name;
128}
129
130// ---- group header ---------------------------------------------------------
131// The member-count subtitle for a group header, counting people and bots
132// separately ("2 members, 3 bots") so a room's bot presence is visible at a
133// glance. Bots are omitted when there are none; an all-bot group (people = 0,
134// bots > 0) shows just the bot count. Pure, so it's unit-tested in Node.
135// Subtitle count, split by helper kind so it mirrors the badges: people are
136// "member(s)", deterministic helpers "bot(s)" (BOT badge), LLM-backed ones
137// "AI agent(s)" (AI badge). Zero categories are dropped; "0 members" shows only
138// when a group has no helpers at all.
139export function memberCountLabel(people, bots, agents = 0) {
140 const parts = [];
141 if (people || (!bots && !agents)) parts.push(`${people} member${people === 1 ? "" : "s"}`);
142 if (bots) parts.push(`${bots} bot${bots === 1 ? "" : "s"}`);
143 if (agents) parts.push(`${agents} AI agent${agents === 1 ? "" : "s"}`);
144 return parts.join(", ");
145}
146
147// appReloadMessage picks the reload-banner copy for an apps_changed frame's
148// `kind` ("created" / "updated" / "removed", or missing on an older server).
149// A blanket "New app available" was confusing for every other kind of change
150// (a deletion, a member-app tweak) — this names what actually happened.
151export function appReloadMessage(kind) {
152 if (kind === "created") return "A new app is available.";
153 if (kind === "updated") return "App updated.";
154 return "Apps changed.";
155}
156
157// ---- presence --------------------------------------------------------------
158// Which chats the filter menu's "Show online only" keeps. `others` is roster
159// info for every member except the viewer: [{ agent, presence }]. People
160// decide when a chat has any: at least one person online right now (bots'
161// always-on sockets would otherwise match every mixed room forever, and Away
162// doesn't count — the filter answers "who can I reach right now"). A chat
163// with only bots falls back to the bots' own status, i.e. is the sidecar up.
164// A Notes/app thread has no others → filtered out.
165export function onlineFilterKeeps(others) {
166 const people = others.filter((o) => o && !o.agent);
167 if (people.length) return people.some((o) => o.presence === "online");
168 return others.some((o) => o && o.agent && o.presence === "online");
169}
170
171// Which filter-menu category a chat belongs to: "apps" (mini-app threads),
172// "bots" (every other member is an agent — a bot DM, an all-bot room), else
173// "people" (anything with another person in it, plus your own Notes).
174export function chatCategory(isApp, others) {
175 if (isApp) return "apps";
176 if (others.length && others.every((o) => o && o.agent)) return "bots";
177 return "people";
178}
179
180// The status line under a DM partner's name (and beside members in the member
181// sheet). All three states read out — each wears a coloured dot in the UI
182// (green/yellow/red). "" = unknown (a keyless invitee, an old server): no line.
183// Presentation is web + Mac only (there's room for a dot + word); the iOS app
184// instead shows online as a green ring on the DM header avatar and nothing for
185// away/offline. The LOGIC here stays the single source, mirrored by
186// ios/LChatCore Presence.swift.
187export function presenceLabel(presence) {
188 return presence === "online" ? "Online" : presence === "away" ? "Away" : presence === "offline" ? "Offline" : "";
189}
190
191// ---- per-viewer names (#577) ----------------------------------------------
192// A member may keep their own name for someone ("Dermo"), independent of the
193// name that person publishes ("Dermot"). A nickname entry is { name, seen }:
194// `name` is what the viewer calls them, `seen` is the published label the
195// viewer last acknowledged. Pure so both rules are unit-tested in Node and
196// mirrorable by ios/LChatCore when native lands.
197
198// The name to show: the viewer's own name for someone wins over the name they
199// publish. "" when neither exists (callers pick their own fallback).
200export function displayNameFor(nick, label) {
201 return (nick && nick.name) || label || "";
202}
203
204// When the published name has moved on from what the viewer last acknowledged,
205// they get a choice (keep my name / use theirs) — returns { from, to }, or null
206// when there's nothing to ask: no nickname, no published name yet, the change
207// is already acknowledged, or they renamed to exactly what I call them.
208export function renameNotice(nick, label) {
209 if (!nick || !nick.name || !label) return null;
210 if (label === nick.seen || label === nick.name) return null;
211 return { from: nick.name, to: label };
212}
213
214// The chat feed translates @mention chips to the viewer's names at render
215// time; plain-text surfaces that can't parse rich text (the sidebar preview)
216// get the same courtesy as a string rewrite. `renames` maps published label →
217// the viewer's name. Boundary rules mirror the feed's mention renderer
218// (chat.js): longest label wins, no trailing word char, and a preceding
219// word/@/. char means mid-word or an email — leave it alone.
220export function translateMentions(text, renames) {
221 const labels = Object.keys(renames || {});
222 if (!text || !labels.length || !text.includes("@")) return text;
223 const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
224 const re = new RegExp("@(" + labels.sort((a, b) => b.length - a.length).map(esc).join("|") + ")(?![\\w])", "g");
225 return text.replace(re, (m, label, idx) => {
226 const prev = text[idx - 1];
227 return prev !== undefined && /[\w@.]/.test(prev) ? m : "@" + renames[label];
228 });
229}
230
231// ---- chat list ordering --------------------------------------------------
232// Pinned-first ordering of the thread list: pinned chats float to the top, and
233// WITHIN each band chats are ordered most-recent-first by `lastOf(t)` (a
234// timestamp). This is the single source of truth for list order — every chat
235// with newer activity rises, mirroring the server's ORDER BY, so the list stays
236// correct without any imperative bump. `lastOf` defaults to a no-op (sort by 0),
237// which — because the sort is stable — leaves the input order untouched. Pure,
238// so the ordering rule is unit-tested in Node.
239export function pinnedFirst(list, isPinned, lastOf = () => 0) {
240 const byRecent = (a, b) => (lastOf(b) || 0) - (lastOf(a) || 0);
241 return [...list.filter((t) => isPinned(t)).sort(byRecent), ...list.filter((t) => !isPinned(t)).sort(byRecent)];
242}
243
244// ---- attachment policy --------------------------------------------------
245// ANY file may be shared (2026-07-09 — the old extension allowlist bit real
246// files, e.g. extensionless text; the server only ever stores an encrypted
247// blob and the name/MIME ride inside the payload, so a type gate bought
248// nothing). This map now only picks icons and download MIMEs for the types we
249// recognise; anything else gets 📎 + octet-stream. It's extension-driven (not
250// MIME) because browsers report inconsistent — often empty — types for
251// documents like .md and .csv. Images still take a separate path (downscaled
252// + re-encoded to JPEG before upload).
253const FILE_TYPES = {
254 pdf: { mime: "application/pdf", icon: "📕" },
255 txt: { mime: "text/plain", icon: "📄" },
256 md: { mime: "text/markdown", icon: "📄" },
257 markdown: { mime: "text/markdown", icon: "📄" },
258 csv: { mime: "text/csv", icon: "📊" },
259 json: { mime: "application/json", icon: "📄" },
260 doc: { mime: "application/msword", icon: "📘" },
261 docx: { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", icon: "📘" },
262 xls: { mime: "application/vnd.ms-excel", icon: "📊" },
263 xlsx: { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", icon: "📊" },
264 ppt: { mime: "application/vnd.ms-powerpoint", icon: "📙" },
265 pptx: { mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", icon: "📙" },
266};
267
268// The lowercased extension of a filename (without the dot), or "".
269export function fileExt(name) {
270 const m = /\.([a-z0-9]+)$/i.exec(name || "");
271 return m ? m[1].toLowerCase() : "";
272}
273
274// Any file may be attached — kept (always true) so older call sites keep
275// working; the icon/MIME maps below are the only per-type behaviour left.
276export function fileAllowed() {
277 return true;
278}
279
280// Best-effort MIME for an attachment: the browser's reported type if it gave
281// one, else mapped from the extension so .md/.csv still download/open sensibly.
282export function fileMime(name, type) {
283 if (type) return type;
284 const t = FILE_TYPES[fileExt(name)];
285 return t ? t.mime : "application/octet-stream";
286}
287
288// A document emoji for an attachment, by extension. 📎 for anything unmapped.
289export function fileIcon(name) {
290 const t = FILE_TYPES[fileExt(name)];
291 return t ? t.icon : "📎";
292}
293
294// ---- action chips ---------------------------------------------------------
295// Helpers (bots) may attach proposed actions to a message payload — the client
296// renders them as one-tap chips under the bubble. Chips are CONSENT-BASED: a
297// chip only runs when the member taps it, and this whitelist is the only thing
298// the client will render — unknown types and invalid params are dropped.
299// Payloads aren't sender-signed (yet), so any member could craft one: every
300// action here must therefore be harmless and reversible (switch a theme, open
301// a panel) — never destructive, never data-revealing.
302export const CHIP_TYPES = {
303 set_theme: { param: "theme", valid: (v) => THEMES.includes(v) },
304 set_appearance: { param: "appearance", valid: (v) => APPEARANCES.includes(v) },
305 open_theme_picker: {},
306 open_profile: {},
307 open_tutorial: {}, // starts Eleven's guided tour (e.g. the welcome DM's "Take the tour")
308 // Jump to a thread the member is in (e.g. "Open Morning Headlines" on a
309 // bot-builder confirmation). Navigation only: opening a thread you're not a
310 // member of simply fails, so a crafted chip can't do more than a tap on the
311 // sidebar could.
312 open_thread: { param: "thread", valid: (v) => /^[A-Za-z0-9_-]{1,64}$/.test(v) },
313 // Open a VIEW a bot created in this chat (docs/views.md) — "here are the 5
314 // issues" + a chip that shows them. Navigation-only like open_thread: the
315 // tap just pins a panel rendering state this device already folded (and
316 // re-clamped) from the thread's own log, so a crafted chip can at worst
317 // open a view the member could open anyway with /views.
318 open_view: { param: "view", valid: (v) => /^[A-Za-z0-9_-]{1,64}$/.test(v) },
319 // Open the memory scratchpad for the thread the chip sits in — how a
320 // remember/forget write announces itself (the sidecar attaches this, never
321 // the model). Navigation-free and read-only at the tap: it opens a panel
322 // over state already synced to this device.
323 open_memory: {},
324 // Connected-service op (the integrations registry, docs/integrations.md): a
325 // bot proposes running one manifest operation and the tap is the member's
326 // consent — for writes, the chip's label carries the manifest's confirm line
327 // ("Register blip.com? This buys the domain and costs real money."), so the
328 // label may run longer than a normal chip's. This sanitize only bounds the
329 // shape; the bot and the pinned executor re-validate everything against the
330 // manifest, and the grant minted on tap is scoped to `access`, so a tampered
331 // chip can't escalate a read into a write.
332 service_op: {
333 longLabel: true, // a consent sentence must render whole, never cut mid-warning
334 sanitize: (a, chip) => {
335 if (typeof a.service !== "string" || a.service.length > 64 || !/^[a-z0-9-]+$/.test(a.service)) return false;
336 if (typeof a.op !== "string" || a.op.length > 64 || !/^[a-z0-9]+(\.[a-z0-9]+)*$/.test(a.op)) return false;
337 if (a.access !== "read" && a.access !== "write") return false;
338 const args = a.args === undefined || a.args === null ? {} : a.args;
339 if (typeof args !== "object" || Array.isArray(args)) return false;
340 const keys = Object.keys(args);
341 if (keys.length > 16 || keys.some((k) => typeof args[k] !== "string")) return false;
342 try {
343 if (JSON.stringify(args).length > 4096) return false;
344 } catch {
345 return false;
346 }
347 chip.service = a.service;
348 chip.op = a.op;
349 chip.access = a.access;
350 chip.args = args;
351 return true;
352 },
353 },
354 // Run a slash command on THIS device — how a bot files into your personal
355 // apps ("remind me at 4pm" → a chip running `/remind pick up the kids at
356 // 4pm`; the tap is the permission, and times parse in YOUR timezone because
357 // the command runs where you are). Held to the harmless-and-reversible bar
358 // three ways: the runner only accepts APP commands (never bot/floor
359 // commands), "@here" is refused (no group side effects from a tap), and the
360 // label is ALWAYS the command itself (noLabel) — a chip can never claim to
361 // do one thing and run another.
362 run_command: {
363 longLabel: true,
364 noLabel: true, // the command IS the label; a free label could lie about it
365 sanitize: (a, chip) => {
366 if (typeof a.command !== "string") return false;
367 const cmd = a.command.trim();
368 if (!cmd.startsWith("/") || cmd.length < 2 || cmd.length > 200) return false;
369 if (/[\x00-\x1f\x7f]/.test(cmd)) return false; // one line, no control chars
370 if (/(^|\s)@here(\s|$)/i.test(cmd)) return false; // never a group install/file from a tap
371 chip.command = cmd;
372 return true;
373 },
374 },
375 // A read-only research session across connected services (docs/
376 // integrations.md § research sessions): the tap seals a short-lived READ
377 // grant per named service to the bot, which answers the goal in-thread.
378 // Reads only by construction — the runner mints nothing but read grants and
379 // the sidecar's loop exposes nothing but read ops — so a tampered chip can
380 // at worst read what the member already allowed that bot to read.
381 service_research: {
382 longLabel: true,
383 sanitize: (a, chip) => {
384 if (!Array.isArray(a.services) || a.services.length < 1 || a.services.length > 4) return false;
385 if (a.services.some((s) => typeof s !== "string" || s.length > 64 || !/^[a-z0-9-]+$/.test(s))) return false;
386 if (typeof a.goal !== "string" || !a.goal.trim() || a.goal.length > 300) return false;
387 chip.services = [...new Set(a.services)];
388 chip.goal = a.goal.trim();
389 return true;
390 },
391 },
392};
393
394// capAllows: does a connection's access cap permit an op of this access level?
395// The cap is chosen by the member when they connect a service ("read" = read
396// only, "write" = read & write) and rides inside their sealed credential; the
397// client refuses to mint a grant beyond it. An absent cap is a legacy
398// connection (stored before caps existed) and keeps its old behaviour — both
399// levels allowed. Pure, so the rule is one testable place.
400export function capAllows(cap, access) {
401 if (access !== "write") return true; // reads are always within any cap
402 return cap !== "read"; // writes need a read+write (or legacy, cap-less) connection
403}
404
405// appOpenPlan: what App Studio's "Open" should do for an app with these
406// places (docs/member-apps.md). An app that can't live in a group chat opens
407// straight into where it does live — a personal thread when "you" is among
408// its places, else its space-wide home. One that ONLY lives in group chats
409// has nothing to open until it's added to one, so the button asks which
410// group; one that supports group chats AND another place asks how to open it.
411// Pure, so the routing rule is one testable place.
412// → { ask: false | "group" | "how", direct: "you" | "space" | null }
413// `direct` is the non-group place a direct open (or the how-chooser's
414// non-group option) targets; null only for group-only apps.
415export function appOpenPlan(places) {
416 const p = Array.isArray(places) && places.length ? places : ["you"];
417 const direct = p.includes("you") ? "you" : p.includes("space") ? "space" : null;
418 if (!p.includes("chat")) return { ask: false, direct };
419 return { ask: direct ? "how" : "group", direct };
420}
421
422// sanitizeActions coerces an untrusted payload `actions` array to renderable
423// chips: whitelist the type, validate the param (plus the type's own sanitize
424// hook, when it has one), cap the count and label size.
425export function sanitizeActions(actions) {
426 if (!Array.isArray(actions)) return [];
427 const out = [];
428 for (const a of actions) {
429 if (out.length === 4) break; // a message is not a menu
430 if (!a || typeof a !== "object") continue;
431 const spec = CHIP_TYPES[a.type];
432 if (!spec) continue;
433 const chip = { type: a.type };
434 if (spec.param) {
435 const v = a[spec.param];
436 if (typeof v !== "string" || !spec.valid(v)) continue;
437 chip[spec.param] = v;
438 }
439 if (spec.sanitize && !spec.sanitize(a, chip)) continue;
440 // noLabel types render from their own params (run_command shows the
441 // command itself) — a free label there could lie about what the tap does.
442 if (!spec.noLabel && typeof a.label === "string" && a.label.trim()) chip.label = a.label.trim().slice(0, spec.longLabel ? 160 : 40);
443 out.push(chip);
444 }
445 return out;
446}
447