eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Views — a bot (or app) shows list-shaped data in the chat where you asked.
2// A view is SCHEMA + DATA, never code: the stored spec expands into a real
3// app-schema list (viewSchema) that the shared schema engine renders, so a
4// view can display data and link out — nothing else. Design: docs/views.md.
5//
6// Views ride the thread's own encrypted log (view.create/update/remove),
7// folded here like any app state and covered by the ordinary snapshots. The
8// fold trusts nothing: payloads aren't sender-signed, so every event is
9// re-clamped independently of whatever the sidecar validated — strings
10// bounded, non-https URLs dropped, item and byte caps enforced, oldest views
11// evicted past the per-thread cap.
12import { defineApp, appStateFor, openCommandPanel } from "../apps.js";
13import { schemaRender, schemaStyles } from "../schema-render.js";
14
15// Caps — the fold's half of the two-sided bound (the sidecar's validator in
16// internal/bot/view.go is the other; keep them in step).
17const MAX_VIEWS = 20;
18const MAX_ITEMS = 50;
19const MAX_TITLE = 80;
20const MAX_GLYPH = 4;
21const MAX_ITEM_TITLE = 300;
22const MAX_ITEM_SUB = 300;
23const MAX_ITEM_NOTE = 500;
24const MAX_ITEM_URL = 600;
25const MAX_SPEC_BYTES = 32 * 1024;
26
27const str = (v, max) => (typeof v === "string" ? v.trim().slice(0, max) : "");
28const idOK = (v) => typeof v === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(v);
29
30// httpsURL keeps a link only when it is really a web link — anything else
31// (javascript:, data:, plain http) folds to "" and the row simply isn't
32// tappable. The renderer re-checks at click time; belt and braces.
33function httpsURL(v) {
34 const s = str(v, MAX_ITEM_URL);
35 return /^https:\/\/[^\s]+$/.test(s) ? s : "";
36}
37
38// sanitizeView clamps one create/update event into a stored view, or null
39// when it is hopeless (no id, no title, no usable items). Item ids are
40// assigned positionally when absent so the schema's keyed list stays stable.
41function sanitizeView(e) {
42 if (!e || !idOK(e.id)) return null;
43 const title = str(e.title, MAX_TITLE);
44 if (!title) return null;
45 if (!Array.isArray(e.items) || !e.items.length) return null;
46 const items = [];
47 for (const raw of e.items) {
48 if (items.length === MAX_ITEMS) break;
49 if (!raw || typeof raw !== "object") continue;
50 const t = str(raw.title, MAX_ITEM_TITLE);
51 if (!t) continue;
52 items.push({
53 id: idOK(raw.id) ? raw.id : "i" + items.length,
54 title: t,
55 subtitle: str(raw.subtitle, MAX_ITEM_SUB),
56 note: str(raw.note, MAX_ITEM_NOTE),
57 url: httpsURL(raw.url),
58 accent: raw.accent === true,
59 });
60 }
61 if (!items.length) return null;
62 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 };
63 try {
64 if (JSON.stringify(view).length > MAX_SPEC_BYTES) return null;
65 } catch {
66 return null;
67 }
68 return view;
69}
70
71// upsert replaces a view in place (view.update refreshing data keeps its
72// position) or appends, evicting the oldest past the cap — a chat that keeps
73// receiving views stays bounded without ever refusing the newest.
74function upsert(views, view) {
75 const i = views.findIndex((v) => v.id === view.id);
76 const next = views.slice();
77 if (i >= 0) next[i] = { ...view, ts: view.ts || next[i].ts };
78 else next.push(view);
79 next.sort((a, b) => (a.ts || 0) - (b.ts || 0));
80 return next.slice(Math.max(0, next.length - MAX_VIEWS));
81}
82
83// viewSchema expands a stored view into a real app-schema (docs/app-schema.md)
84// for the shared engine: one keyed collection, one section, read-only rows
85// whose tap opens the item's link. Pure, so web and native can expand the
86// same stored spec identically when native adds views.
87export function viewSchema(view) {
88 return {
89 schema: 1,
90 name: "view",
91 title: view.title,
92 glyph: view.glyph || "▤",
93 collection: "items",
94 state: {
95 items: {
96 key: "id",
97 fields: { id: "string", title: "string", subtitle: "string", note: "string", url: "string", accent: "bool" },
98 },
99 },
100 events: {},
101 views: {
102 main: {
103 kind: "list",
104 summary: [{ if: { empty: "$items" }, text: "Nothing here" }],
105 search: { fields: ["title", "subtitle", "note"] },
106 sections: [{ id: "all", source: "active", order: { by: "_index", dir: "asc" } }],
107 row: {
108 title: { text: "{item.title}" },
109 subtitle: [{ if: { truthy: "$item.subtitle" }, text: "{item.subtitle}", accent: { truthy: "$item.accent" } }],
110 note: [{ if: { truthy: "$item.note" }, text: "{item.note}" }],
111 tap: { url: "$item.url" },
112 },
113 },
114 },
115 };
116}
117
118export const viewsApp = defineApp({
119 name: "views",
120 title: "Views",
121 glyph: "▤",
122 places: [], // a registry + a panel, not a chat surface — never listed
123 initialState: () => ({ views: [] }),
124 events: {
125 // Create and update share the sanitizer: an update is a full replacement
126 // (the common bot move is "refresh the data"), so there is no partial
127 // merge to reason about and stale fields can't linger.
128 "view.create": (s, e) => {
129 const v = sanitizeView(e);
130 return v ? poked({ views: upsert(s.views || [], v) }) : s;
131 },
132 "view.update": (s, e) => {
133 const v = sanitizeView(e);
134 if (!v || !(s.views || []).some((x) => x.id === v.id)) return s; // update never creates
135 return poked({ views: upsert(s.views, v) });
136 },
137 "view.remove": (s, e) => poked({ views: (s.views || []).filter((v) => v.id !== e.id) }),
138 },
139 // Views arrive alongside a bot reply that already narrates them (and carries
140 // the Open view chip) — a second action line would just double the bubble.
141 commands: {
142 "/views": {
143 desc: "See this chat's views",
144 run: (args, ctx) => {
145 const views = viewsFor(ctx.threadId);
146 if (!views.length) return ctx.reply({ text: "No views in this chat yet — ask a bot to show you something." });
147 const opened = ctx.panel({
148 title: "Views",
149 position: "side",
150 render: (el, panel) => renderViewIndex(el, panel, ctx.threadId, views),
151 });
152 if (!opened) ctx.reply({ text: "Open a chat to see its views." });
153 },
154 },
155 },
156 styles: schemaStyles + viewsStyles(),
157});
158
159// poked passes folded state through while nudging the shell's ▤ Views button
160// (a view arriving mid-conversation should surface it without a reload).
161// Headless folds (Node tests) skip the nudge.
162function poked(state) {
163 try {
164 if (typeof document !== "undefined") document.dispatchEvent(new CustomEvent("lchat:views-changed"));
165 } catch {}
166 return state;
167}
168
169function viewsFor(threadId) {
170 const s = appStateFor(threadId, "views");
171 return s && Array.isArray(s.views) ? s.views : [];
172}
173
174// renderViewOne mounts one view through the schema engine. The ctx is inert:
175// a view's synthesized schema declares no composer, no actions and no ticker,
176// so nothing can emit — but hand it no-ops rather than trusting that.
177function renderViewOne(el, view, threadId) {
178 const inert = { threadId, emit: () => {}, schedule: () => {}, unschedule: () => {}, id: () => "" };
179 schemaRender(viewSchema(view))({ items: view.items }, el, inert);
180 if (view.by) {
181 const who = document.createElement("p");
182 who.className = "views-by";
183 who.textContent = `Shared by ${view.by}`;
184 el.appendChild(who);
185 }
186}
187
188// renderViewIndex is /views: every live view as a tappable row, newest first.
189function renderViewIndex(el, panel, threadId, views) {
190 const ul = document.createElement("ul");
191 ul.className = "views-index";
192 for (const v of views.slice().reverse()) {
193 const li = document.createElement("li");
194 const b = document.createElement("button");
195 b.type = "button";
196 b.className = "views-index-open";
197 b.textContent = `${v.glyph || "▤"} ${v.title}`;
198 b.onclick = () => openThreadView(threadId, v.id);
199 li.appendChild(b);
200 ul.appendChild(li);
201 }
202 el.appendChild(ul);
203}
204
205// openThreadView is the open_view chip's runner (and /views' row tap): find
206// the folded view and pin it above the feed. Returns "" on success or the
207// in-place message for the chip to show — never throws.
208export function openThreadView(threadId, viewId) {
209 const view = viewsFor(threadId).find((v) => v.id === viewId);
210 if (!view) return "That view isn't here yet — try again in a moment";
211 const opened = openCommandPanel(threadId, {
212 title: view.title,
213 position: "side",
214 render: (el) => renderViewOne(el, view, threadId),
215 });
216 return opened ? "" : "Open this chat to see the view";
217}
218
219function viewsStyles() {
220 return `
221 .views-by { margin: 4px 12px 8px; font-size: 12px; color: var(--muted, #8a8f98); }
222 .views-index { list-style: none; margin: 0; padding: 4px 0; }
223 .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; }
224 .views-index-open:hover, .views-index-open:focus-visible { background: var(--hover, rgba(127,127,127,.12)); }
225 `;
226}
227