| 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). |
| 8 | export const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| 9 | export const APPEARANCES = ["light", "dark", "system"]; |
| 10 | export const DEFAULT_THEME = "classic"; |
| 11 | export const DEFAULT_APPEARANCE = "system"; |
| 12 | |
| 13 | // Coerce a stored (possibly stale/garbage) value to a valid theme/appearance. |
| 14 | export function normalizeTheme(v) { |
| 15 | return THEMES.includes(v) ? v : DEFAULT_THEME; |
| 16 | } |
| 17 | export 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. |
| 23 | export 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. |
| 38 | export const TEXT_SIZES = [10, 11, 12, 13, 14, 15, 16]; |
| 39 | export const DEFAULT_TEXT_SIZE = 15; |
| 40 | |
| 41 | // Coerce a stored (possibly stale/garbage) value to a valid size. |
| 42 | export 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. |
| 54 | export 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)'). |
| 65 | export 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. |
| 82 | const TOKEN_CHAR = /[a-z0-9_+-]/i; |
| 83 | export 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). |
| 111 | export 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). |
| 124 | export 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. |
| 139 | export 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. |
| 151 | export 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. |
| 165 | export 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). |
| 174 | export 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. |
| 187 | export 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). |
| 200 | export 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. |
| 208 | export 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. |
| 220 | export 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. |
| 239 | export 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). |
| 253 | const 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 "". |
| 269 | export 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. |
| 276 | export 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. |
| 282 | export 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. |
| 289 | export 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. |
| 302 | export 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. |
| 400 | export 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. |
| 415 | export 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. |
| 425 | export 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 | |