// Views — a bot (or app) shows list-shaped data in the chat where you asked. // A view is SCHEMA + DATA, never code: the stored spec expands into a real // app-schema list (viewSchema) that the shared schema engine renders, so a // view can display data and link out — nothing else. Design: docs/views.md. // // Views ride the thread's own encrypted log (view.create/update/remove), // folded here like any app state and covered by the ordinary snapshots. The // fold trusts nothing: payloads aren't sender-signed, so every event is // re-clamped independently of whatever the sidecar validated — strings // bounded, non-https URLs dropped, item and byte caps enforced, oldest views // evicted past the per-thread cap. import { defineApp, appStateFor, openCommandPanel } from "../apps.js"; import { schemaRender, schemaStyles } from "../schema-render.js"; // Caps — the fold's half of the two-sided bound (the sidecar's validator in // internal/bot/view.go is the other; keep them in step). const MAX_VIEWS = 20; const MAX_ITEMS = 50; const MAX_TITLE = 80; const MAX_GLYPH = 4; const MAX_ITEM_TITLE = 300; const MAX_ITEM_SUB = 300; const MAX_ITEM_NOTE = 500; const MAX_ITEM_URL = 600; const MAX_SPEC_BYTES = 32 * 1024; const str = (v, max) => (typeof v === "string" ? v.trim().slice(0, max) : ""); const idOK = (v) => typeof v === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(v); // httpsURL keeps a link only when it is really a web link — anything else // (javascript:, data:, plain http) folds to "" and the row simply isn't // tappable. The renderer re-checks at click time; belt and braces. function httpsURL(v) { const s = str(v, MAX_ITEM_URL); return /^https:\/\/[^\s]+$/.test(s) ? s : ""; } // sanitizeView clamps one create/update event into a stored view, or null // when it is hopeless (no id, no title, no usable items). Item ids are // assigned positionally when absent so the schema's keyed list stays stable. function sanitizeView(e) { if (!e || !idOK(e.id)) return null; const title = str(e.title, MAX_TITLE); if (!title) return null; if (!Array.isArray(e.items) || !e.items.length) return null; const items = []; for (const raw of e.items) { if (items.length === MAX_ITEMS) break; if (!raw || typeof raw !== "object") continue; const t = str(raw.title, MAX_ITEM_TITLE); if (!t) continue; items.push({ id: idOK(raw.id) ? raw.id : "i" + items.length, title: t, subtitle: str(raw.subtitle, MAX_ITEM_SUB), note: str(raw.note, MAX_ITEM_NOTE), url: httpsURL(raw.url), accent: raw.accent === true, }); } if (!items.length) return null; const view = { id: e.id, title, glyph: str(e.glyph, MAX_GLYPH), items, by: str(e.name, 80), ts: typeof e.ts === "number" ? e.ts : 0 }; try { if (JSON.stringify(view).length > MAX_SPEC_BYTES) return null; } catch { return null; } return view; } // upsert replaces a view in place (view.update refreshing data keeps its // position) or appends, evicting the oldest past the cap — a chat that keeps // receiving views stays bounded without ever refusing the newest. function upsert(views, view) { const i = views.findIndex((v) => v.id === view.id); const next = views.slice(); if (i >= 0) next[i] = { ...view, ts: view.ts || next[i].ts }; else next.push(view); next.sort((a, b) => (a.ts || 0) - (b.ts || 0)); return next.slice(Math.max(0, next.length - MAX_VIEWS)); } // viewSchema expands a stored view into a real app-schema (docs/app-schema.md) // for the shared engine: one keyed collection, one section, read-only rows // whose tap opens the item's link. Pure, so web and native can expand the // same stored spec identically when native adds views. export function viewSchema(view) { return { schema: 1, name: "view", title: view.title, glyph: view.glyph || "▤", collection: "items", state: { items: { key: "id", fields: { id: "string", title: "string", subtitle: "string", note: "string", url: "string", accent: "bool" }, }, }, events: {}, views: { main: { kind: "list", summary: [{ if: { empty: "$items" }, text: "Nothing here" }], search: { fields: ["title", "subtitle", "note"] }, sections: [{ id: "all", source: "active", order: { by: "_index", dir: "asc" } }], row: { title: { text: "{item.title}" }, subtitle: [{ if: { truthy: "$item.subtitle" }, text: "{item.subtitle}", accent: { truthy: "$item.accent" } }], note: [{ if: { truthy: "$item.note" }, text: "{item.note}" }], tap: { url: "$item.url" }, }, }, }, }; } export const viewsApp = defineApp({ name: "views", title: "Views", glyph: "▤", places: [], // a registry + a panel, not a chat surface — never listed initialState: () => ({ views: [] }), events: { // Create and update share the sanitizer: an update is a full replacement // (the common bot move is "refresh the data"), so there is no partial // merge to reason about and stale fields can't linger. "view.create": (s, e) => { const v = sanitizeView(e); return v ? poked({ views: upsert(s.views || [], v) }) : s; }, "view.update": (s, e) => { const v = sanitizeView(e); if (!v || !(s.views || []).some((x) => x.id === v.id)) return s; // update never creates return poked({ views: upsert(s.views, v) }); }, "view.remove": (s, e) => poked({ views: (s.views || []).filter((v) => v.id !== e.id) }), }, // Views arrive alongside a bot reply that already narrates them (and carries // the Open view chip) — a second action line would just double the bubble. commands: { "/views": { desc: "See this chat's views", run: (args, ctx) => { const views = viewsFor(ctx.threadId); if (!views.length) return ctx.reply({ text: "No views in this chat yet — ask a bot to show you something." }); const opened = ctx.panel({ title: "Views", position: "side", render: (el, panel) => renderViewIndex(el, panel, ctx.threadId, views), }); if (!opened) ctx.reply({ text: "Open a chat to see its views." }); }, }, }, styles: schemaStyles + viewsStyles(), }); // poked passes folded state through while nudging the shell's ▤ Views button // (a view arriving mid-conversation should surface it without a reload). // Headless folds (Node tests) skip the nudge. function poked(state) { try { if (typeof document !== "undefined") document.dispatchEvent(new CustomEvent("lchat:views-changed")); } catch {} return state; } function viewsFor(threadId) { const s = appStateFor(threadId, "views"); return s && Array.isArray(s.views) ? s.views : []; } // renderViewOne mounts one view through the schema engine. The ctx is inert: // a view's synthesized schema declares no composer, no actions and no ticker, // so nothing can emit — but hand it no-ops rather than trusting that. function renderViewOne(el, view, threadId) { const inert = { threadId, emit: () => {}, schedule: () => {}, unschedule: () => {}, id: () => "" }; schemaRender(viewSchema(view))({ items: view.items }, el, inert); if (view.by) { const who = document.createElement("p"); who.className = "views-by"; who.textContent = `Shared by ${view.by}`; el.appendChild(who); } } // renderViewIndex is /views: every live view as a tappable row, newest first. function renderViewIndex(el, panel, threadId, views) { const ul = document.createElement("ul"); ul.className = "views-index"; for (const v of views.slice().reverse()) { const li = document.createElement("li"); const b = document.createElement("button"); b.type = "button"; b.className = "views-index-open"; b.textContent = `${v.glyph || "▤"} ${v.title}`; b.onclick = () => openThreadView(threadId, v.id); li.appendChild(b); ul.appendChild(li); } el.appendChild(ul); } // openThreadView is the open_view chip's runner (and /views' row tap): find // the folded view and pin it above the feed. Returns "" on success or the // in-place message for the chip to show — never throws. export function openThreadView(threadId, viewId) { const view = viewsFor(threadId).find((v) => v.id === viewId); if (!view) return "That view isn't here yet — try again in a moment"; const opened = openCommandPanel(threadId, { title: view.title, position: "side", render: (el) => renderViewOne(el, view, threadId), }); return opened ? "" : "Open this chat to see the view"; } function viewsStyles() { return ` .views-by { margin: 4px 12px 8px; font-size: 12px; color: var(--muted, #8a8f98); } .views-index { list-style: none; margin: 0; padding: 4px 0; } .views-index-open { display: block; width: 100%; text-align: left; padding: 10px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; border-radius: 8px; min-height: 44px; } .views-index-open:hover, .views-index-open:focus-visible { background: var(--hover, rgba(127,127,127,.12)); } `; }