eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// App Studio — the member-facing manager for member apps (docs/member-apps.md).
2//
3// Two ways an app reaches this instance: ask a bot to build one, or write the
4// code yourself and deploy it here. This surface lists the apps you've made,
5// and lets you paste a single-file app module, Check it (evaluated in the
6// sandbox to read back its manifest), and Deploy it — the SAME encryption +
7// opaque-origin sandbox every member app runs in, so your code never touches
8// keys, the page DOM, or the network.
9//
10// It's a TRUSTED compiled-in app (it holds the token + instance key to
11// encrypt/deploy), so the privileged work lives in ../sandbox.js; App Studio
12// is just the UI over it. The authoring rules are at GET /api/member-app-guide.
13import { defineApp, namespace, appByName } from "../apps.js";
14import { ICON_PUZZLE } from "../icons.js";
15import { ENABLE_MEMBER_APPS } from "../shellstate.js";
16import { appOpenPlan } from "../ui.js";
17import {
18 listDeployedApps,
19 listAppVersions,
20 deployMemberApp,
21 retireMemberApp,
22 extractManifest,
23 openMemberApp,
24 memberAppGroups,
25 openMemberAppInGroup,
26 openMemberAppForSpace,
27 memberHostEmbedded,
28 manifestIsLegacy,
29} from "../sandbox.js";
30
31// One declaration of the app's prefix: N.ev("x") → the "appstudio.x" wire type,
32// N.cls("x") → the "appstudio-x" CSS class (reached as ctx.ev / ctx.cls in render).
33const N = namespace("appstudio");
34
35// Static in-code SVG (safe for innerHTML — no user content).
36const ICON_DOWNLOAD =
37 '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
38const ICON_BOT =
39 '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="9" width="16" height="10" rx="2"/><path d="M12 5v4"/><circle cx="12" cy="4" r="1"/><line x1="9" y1="14" x2="9" y2="14"/><line x1="15" y1="14" x2="15" y2="14"/></svg>';
40
41// Download an app's source as a .js file (App Studio runs in the trusted page).
42function downloadSource(source, name) {
43 const safe = (name || "app").replace(/[^a-z0-9._-]/gi, "") || "app";
44 const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
45 const a = document.createElement("a");
46 a.href = url;
47 a.download = safe + ".js";
48 document.body.appendChild(a);
49 a.click();
50 a.remove();
51 URL.revokeObjectURL(url);
52}
53
54const STARTER = `import { defineApp, namespace } from "../apps.js";
55
56const N = namespace("hello"); // N.ev("tap") → "hello.tap", N.cls("btn") → "hello-btn"
57
58export const app = defineApp({
59 name: "hello", // unique lowercase id — rename for your app
60 title: "Hello", // shown in the Apps list
61 glyph: "👋", // one emoji
62 symbol: "puzzlepiece", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
63
64 initialState: () => ({ count: 0 }),
65
66 events: {
67 [N.ev("tap")]: (s) => ({ count: s.count + 1 }),
68 },
69
70 render(state, el, ctx) {
71 const btn = document.createElement("button");
72 btn.textContent = "Say hello";
73 btn.className = ctx.cls("btn");
74 btn.onclick = () => ctx.emit({ type: ctx.ev("tap") });
75 el.appendChild(btn);
76 if (state.count > 0) {
77 const p = document.createElement("p");
78 p.textContent = "hello 👋 (×" + state.count + ")";
79 el.appendChild(p);
80 }
81 },
82});
83`;
84
85// Module-level editor state: the framework clears our pane on each render, so
86// keep the draft + edit target here so a re-render (after deploy/remove) is
87// seamless. `editing` is the id of the app being updated, or "" for a new one.
88let draft = "";
89let editing = "";
90let editingName = "";
91let busy = false;
92// Version-history view: the app whose history is open (""), its title, and the
93// fetched history (null until loaded).
94let historyFor = "";
95let historyTitle = "";
96let historyData = null;
97
98function el(tag, cls, text) {
99 const n = document.createElement(tag);
100 if (cls) n.className = cls;
101 if (text != null) n.textContent = text;
102 return n;
103}
104
105// ---- the place-aware "Open" (appOpenPlan in ui.js) -------------------------
106// Where this app can live. The live registry has the normalized answer
107// (registerMemberApp folds the legacy boolean flags into places); fall back
108// to the stored manifest, normalized the same way, for an app that didn't
109// register this session (e.g. its name collided with another's).
110function placesOf(m) {
111 const reg = appByName(m.name);
112 if (reg && Array.isArray(reg.places) && reg.places.length) return reg.places;
113 return m.places || ["you", ...(m.groupCapable ? ["chat"] : []), ...(m.spaceCapable ? ["space"] : [])];
114}
115
116// The one open chooser — a transient strip under the tapped row (never a
117// floating overlay). Any other Open tap or its Cancel closes it; a re-render
118// wipes the pane, so render() just drops the stale handle.
119let chooser = null;
120function closeChooser() {
121 if (chooser) {
122 chooser.remove();
123 chooser = null;
124 }
125}
126function chooserFor(row, label) {
127 closeChooser();
128 const box = el("div", "chooser");
129 box.setAttribute("role", "group");
130 box.setAttribute("aria-label", label);
131 row.after(box);
132 chooser = box;
133 return box;
134}
135function chooserCancel(box) {
136 const c = el("button", "cancel", "Cancel");
137 c.onclick = closeChooser;
138 box.appendChild(c);
139}
140function chooserNote(row, text) {
141 const box = chooserFor(row, "Open");
142 box.appendChild(el("span", "chooser-note", text));
143 chooserCancel(box);
144 box.querySelector("button").focus();
145}
146
147// "Add to which group chat?" — pick a group, install the shared copy there
148// (the same groupapps.add every Apps sheet emits), and land in that chat.
149function showGroupPicker(m, row) {
150 const title = m.title || m.name;
151 const groups = memberAppGroups();
152 const box = chooserFor(row, `Add ${title} to a group chat`);
153 if (!groups.length) {
154 box.appendChild(el("span", "chooser-note", "This app lives in group chats, and you're not in any yet — start one from + New chat, then add the app there."));
155 chooserCancel(box);
156 box.querySelector("button").focus();
157 return;
158 }
159 box.appendChild(el("span", "chooser-label", "Add to which group chat?"));
160 for (const g of groups) {
161 const b = el("button", null, g.title); // group names are user content — textContent via el()
162 b.onclick = async () => {
163 b.disabled = true;
164 if (await openMemberAppInGroup(m.name, g.id)) {
165 closeChooser();
166 return;
167 }
168 b.disabled = false;
169 box.appendChild(el("span", "chooser-note err", "Couldn't add it there — try again."));
170 };
171 box.appendChild(b);
172 }
173 chooserCancel(box);
174 box.querySelector("button").focus();
175}
176
177// "Open it where?" — for an app that supports group chats AND another place.
178// Options name places, in the Apps sheets' vocabulary; the group option leads
179// to the picker above.
180function showHowChooser(m, row) {
181 const places = placesOf(m);
182 const box = chooserFor(row, `Where to open ${m.title || m.name}`);
183 box.appendChild(el("span", "chooser-label", "Open it where?"));
184 if (places.includes("you")) {
185 const you = el("button", null, "Just for you");
186 you.onclick = () => {
187 closeChooser();
188 openMemberApp(m.name);
189 };
190 box.appendChild(you);
191 }
192 if (places.includes("space")) {
193 const sp = el("button", null, "For the whole space");
194 sp.onclick = async () => {
195 sp.disabled = true;
196 if (await openMemberAppForSpace(m.name)) {
197 closeChooser();
198 return;
199 }
200 sp.disabled = false;
201 box.appendChild(el("span", "chooser-note err", "Couldn't add it for the whole space — that may be admins-only here."));
202 };
203 box.appendChild(sp);
204 }
205 const grp = el("button", null, "In a group chat…");
206 grp.onclick = () => showGroupPicker(m, row);
207 box.appendChild(grp);
208 chooserCancel(box);
209 box.querySelector("button").focus();
210}
211
212// The Open button: route by the app's places. No group place → open straight
213// into where it lives (personal thread, else its space home); group-only →
214// ask which group; group + another place → ask where.
215async function openApp(m, row) {
216 closeChooser();
217 const places = placesOf(m);
218 const plan = appOpenPlan(places);
219 // A native embed is one thread's pane: only the personal open can hand off
220 // to the native shell today, so take it whenever the app supports it
221 // (asking would offer options this pane can't do), and point group/space
222 // shapes at the native Apps sheet instead of opening inside this pane.
223 if (memberHostEmbedded()) {
224 if (places.includes("you")) return openMemberApp(m.name);
225 chooserNote(row, places.includes("chat")
226 ? "This app lives in group chats — open one and add it from that chat's Apps menu."
227 : "This app is for the whole space — add it from a chat's Apps menu.");
228 return;
229 }
230 if (plan.ask === "group") return showGroupPicker(m, row);
231 if (plan.ask === "how") return showHowChooser(m, row);
232 if (plan.direct === "space") {
233 if (!(await openMemberAppForSpace(m.name))) {
234 chooserNote(row, "Couldn't add it for the whole space — that may be admins-only here.");
235 }
236 return;
237 }
238 openMemberApp(m.name);
239}
240
241export const app = defineApp({
242 name: "appstudio",
243 title: "App Studio",
244 blurb: "Make and manage your own apps",
245 glyph: "🧩",
246 logo: ICON_PUZZLE,
247 places: ["you"],
248 placeNote: "App Studio manages the apps YOU deploy — the apps it builds are what get shared.",
249 initialState: () => ({}),
250 events: {},
251 snapshot: false, // a live view of the server, not an event-sourced app
252
253 styles: `
254.studio { padding: 16px; max-width: 720px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
255.studio h2 { margin: 0; font-size: 18px; }
256.studio h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
257.studio .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
258/* The row wraps: glyph + title hold the first line; the action cluster drops
259 to its own line and wraps among itself when the pane is too narrow to keep
260 it inline — so nothing overflows off-screen and the title is never crushed
261 letter-by-letter. On a wide pane the meta flex-grows and pushes the actions
262 to the right, keeping the familiar single-line look. */
263.studio .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 12px 0; border-bottom: 1px solid var(--border); }
264.studio .row .g { font-size: 22px; width: 28px; text-align: center; flex: 0 0 auto; align-self: flex-start; }
265/* A solid basis keeps the title readable: on a narrow pane the actions wrap
266 below rather than squeezing meta until "Streaks" stacks into "Strea/ks". */
267.studio .row .meta { flex: 1 1 200px; min-width: 0; }
268.studio .row .meta .t { font-weight: 600; overflow-wrap: break-word; }
269.studio .row .meta .by { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
270.studio .row .actions { display: flex; flex-wrap: wrap; gap: 8px; }
271.studio .bot-badge { display: inline-flex; align-items: center; color: var(--muted); }
272.studio .row button { cursor: var(--ui-cursor, pointer); background: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; }
273.studio .row button.danger { color: var(--danger); border-color: var(--danger); }
274.studio .row button.icon-btn { display: inline-flex; align-items: center; padding: 5px 9px; }
275.studio .link-btn { align-self: flex-start; background: none; border: none; color: var(--link); cursor: var(--ui-cursor, pointer); font-size: 13px; padding: 0; }
276.studio .empty { color: var(--muted); font-size: 13px; padding: 8px 0; }
277/* Shared input surface (background/border/radius/colour) comes from .eleven-input
278 in style.css; this adds the code-editor specifics + vertical breathing room. */
279.studio textarea { width: 100%; box-sizing: border-box; min-height: 220px; margin: 12px 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.5; padding: 10px; resize: vertical; }
280.studio .bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
281.studio .bar button { cursor: var(--ui-cursor, pointer); border-radius: var(--radius); padding: 7px 14px; font-size: 13px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); }
282.studio .bar button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
283.studio .bar button[disabled] { opacity: .5; cursor: default; }
284.studio .note { font-size: 13px; line-height: 1.5; margin: 10px 0 0; padding: 8px 10px; border-radius: var(--radius); background: var(--bubble-them); }
285/* The member-apps-off callout: the one notice that explains the whole page's
286 reduced state, so it gets the note surface + full-ink lead, not muted sub. */
287.studio .gate-note { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-radius: var(--radius); background: var(--bubble-them); }
288.studio .gate-note .gg { font-size: 20px; line-height: 1.3; }
289.studio .gate-note .gt { font-weight: 600; font-size: 14px; line-height: 1.4; }
290.studio .note.err { color: var(--danger); }
291.studio .note.ok { color: var(--accent); }
292.studio .spacer { flex: 1 1 auto; }
293/* The Open chooser: an inline strip under the tapped row (occupies layout
294 space, never floats). Wraps like the rows' action cluster on narrow panes. */
295.studio .chooser { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 10px 12px; margin: 8px 0; border-radius: var(--radius); background: var(--bubble-them); }
296.studio .chooser .chooser-label { font-size: 13px; font-weight: 600; }
297.studio .chooser .chooser-note { font-size: 13px; line-height: 1.5; }
298.studio .chooser .chooser-note.err { color: var(--danger); flex: 1 1 100%; }
299.studio .chooser button { cursor: var(--ui-cursor, pointer); background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; }
300.studio .chooser button[disabled] { opacity: .5; cursor: default; }
301.studio .chooser button.cancel { background: none; border-color: transparent; color: var(--muted); }
302`,
303
304 render(_state, root, ctx) {
305 root.textContent = "";
306 chooser = null; // the wipe above just removed any open chooser's DOM
307 const wrap = el("div", "studio");
308 root.appendChild(wrap);
309
310 wrap.appendChild(el("h2", null, "App Studio 🧩"));
311 // The member-apps gate (an admin setting; the server enforces it on every
312 // deploy). Off hides the whole authoring surface — no editor to type into
313 // only to be refused — while apps already deployed stay listed and usable.
314 // The why gets a callout, not the muted intro style: it explains the whole
315 // page's reduced state, so it has to be the first thing a visitor spots.
316 if (ENABLE_MEMBER_APPS) {
317 wrap.appendChild(
318 el("p", "sub", "Make your own apps: paste a single-file app module below, Check it, and Deploy. It runs sandboxed like any app — it can't reach your messages, keys, or the network. Prefer to just describe one? Ask a bot to build it for you."),
319 );
320 } else {
321 const gate = el("div", "gate-note");
322 const gg = el("span", "gg", "🔒"); // decorative — the text carries the meaning
323 gg.setAttribute("aria-hidden", "true");
324 gate.appendChild(gg);
325 const txt = el("div");
326 txt.appendChild(el("div", "gt", "Making your own apps is turned off for this space"));
327 txt.appendChild(el("p", "sub", "An admin can turn it back on in Admin → Features. Apps already deployed keep working."));
328 gate.appendChild(txt);
329 wrap.appendChild(gate);
330 }
331
332 // ---- Version history (replaces the list/editor while open) --------------
333 if (historyFor) {
334 const back = el("button", "link-btn", "← Back to apps");
335 back.onclick = () => { historyFor = ""; historyData = null; rerender(); };
336 wrap.appendChild(back);
337 const panel = el("div");
338 panel.appendChild(el("h3", null, `History — ${historyTitle}`));
339 panel.appendChild(el("p", "sub", "Every deploy is kept. Restore an earlier version and it deploys as a new one, so nothing is lost."));
340 wrap.appendChild(panel);
341 if (!historyData) {
342 panel.appendChild(el("div", "empty", "Loading…"));
343 listAppVersions(historyFor).then((res) => { historyData = res; rerender(); });
344 } else if (historyData.error) {
345 panel.appendChild(el("div", "note err", historyData.error));
346 } else if (!historyData.versions.length) {
347 panel.appendChild(el("div", "empty", "No history yet."));
348 } else {
349 for (const v of historyData.versions) {
350 const row = el("div", "row");
351 const meta = el("div", "meta");
352 meta.appendChild(el("div", "t", `Version ${v.version}${v.current ? " · current" : ""}`));
353 const sub = new Date(v.at * 1000).toLocaleString() + (v.byMe ? " · by you" : "") + (v.based_on ? ` · based on version ${v.based_on}` : "");
354 meta.appendChild(el("div", "by", sub));
355 row.appendChild(meta);
356 const actions = el("div", "actions");
357 if (v.source) {
358 const dl = el("button", "icon-btn");
359 dl.innerHTML = ICON_DOWNLOAD;
360 dl.title = "Download this version";
361 dl.onclick = () => downloadSource(v.source, `${historyTitle}-v${v.version}`);
362 actions.appendChild(dl);
363 }
364 if (ENABLE_MEMBER_APPS && !v.current && v.source) {
365 const revert = el("button", "primary", "Revert to this");
366 revert.onclick = async () => {
367 if (!confirm(`Revert to version ${v.version}? It deploys as a new version (marked "based on version ${v.version}") — nothing is lost.`)) return;
368 revert.disabled = true;
369 const r = await deployMemberApp(v.source, { id: historyFor, basedOn: v.version });
370 if (r.error) {
371 revert.disabled = false;
372 row.after(el("div", "note err", r.error));
373 return;
374 }
375 // apps_changed reloads to apply; drop back to the list meanwhile.
376 historyFor = "";
377 historyData = null;
378 rerender();
379 };
380 actions.appendChild(revert);
381 }
382 row.appendChild(actions);
383 panel.appendChild(row);
384 }
385 }
386 return;
387 }
388
389 // ---- Your apps ----------------------------------------------------------
390 const listSection = el("div");
391 listSection.appendChild(el("h3", null, "Apps on this space"));
392 const listBody = el("div");
393 listSection.appendChild(listBody);
394 wrap.appendChild(listSection);
395 listBody.appendChild(el("div", "empty", "Loading…"));
396
397 const refreshList = async () => {
398 const res = await listDeployedApps();
399 listBody.textContent = "";
400 if (res.error) {
401 listBody.appendChild(el("div", "note err", res.error));
402 return;
403 }
404 if (!res.apps.length) {
405 listBody.appendChild(
406 el("div", "empty", ENABLE_MEMBER_APPS ? "No apps yet — deploy one below, or ask a bot to build you one." : "No apps yet."),
407 );
408 return;
409 }
410 for (const a of res.apps) {
411 const m = a.manifest || {};
412 const row = el("div", "row");
413 row.appendChild(el("span", "g", m.glyph || "▦"));
414 const meta = el("div", "meta");
415 meta.appendChild(el("div", "t", m.title || m.name || a.id));
416 // Ownership and authorship are independent: you can own an app a bot wrote
417 // for you. Show "you + <bot>" (with a bot icon) for those, "by you" for
418 // hand-written, "by <name>" for someone else's.
419 const by = el("div", "by");
420 if (a.mine && a.byBot) {
421 by.appendChild(document.createTextNode("you + "));
422 const bot = el("span", "bot-badge");
423 bot.innerHTML = ICON_BOT;
424 bot.title = "AI-generated — this bot wrote it, you own it";
425 by.appendChild(bot);
426 by.appendChild(document.createTextNode(" " + (m.builtBy || "a bot")));
427 } else if (a.mine) {
428 by.appendChild(document.createTextNode("by you"));
429 } else {
430 by.appendChild(document.createTextNode("by " + (m.by || "another member")));
431 }
432 if (a.mine) {
433 const extra =
434 // Version + id let the member cross-check a bot's build report
435 // ("✨ … version 3 is live … (app id: app-…)") against what's
436 // actually deployed — the numbers here are the server's, not the
437 // bot's.
438 ` · version ${a.version || 1}` +
439 (m.chat ? " · chat view" : "") +
440 ((m.places ? m.places.includes("chat") : m.groupCapable) ? " · group-capable" : "") +
441 ((m.commands || []).length ? " · " + m.commands.map((c) => c.name).join(" ") : "") +
442 // The legacy-format flag (docs/member-apps.md § Legacy formats):
443 // the app still loads fine, but its stored manifest predates the
444 // places vocabulary — the Upgrade button below modernizes it.
445 (manifestIsLegacy(m) ? " · older format" : "") +
446 ` · ${a.id}`;
447 by.appendChild(document.createTextNode(extra));
448 }
449 meta.appendChild(by);
450 row.appendChild(meta);
451 // Buttons live in their own cluster so they wrap as a unit below the
452 // title on a narrow pane instead of spilling off the right edge.
453 const actions = el("div", "actions");
454 if (m.name) {
455 const open = el("button", null, "Open");
456 open.onclick = () => openApp(m, row);
457 actions.appendChild(open);
458 }
459 if (a.mine && a.source) {
460 // A legacy-format app (manifest predates the places vocabulary)
461 // gets a one-tap upgrade: redeploy the SAME source through the
462 // current pipeline — the re-minted manifest speaks places, the
463 // code is untouched, and History keeps the old version. Gated with
464 // the editor (deploys 403 while member apps are off).
465 if (ENABLE_MEMBER_APPS && manifestIsLegacy(m)) {
466 const up = el("button", "primary", "Upgrade");
467 up.title = "Redeploys the same code with a current-format manifest — nothing about the app changes, and History keeps the old version.";
468 up.onclick = async () => {
469 up.disabled = true;
470 const r = await deployMemberApp(a.source, { id: a.id });
471 if (r.error) {
472 up.disabled = false;
473 row.after(el("div", "note err", r.error));
474 return;
475 }
476 // apps_changed reloads to apply, same as a revert deploy.
477 up.textContent = "Upgraded ✓";
478 };
479 actions.appendChild(up);
480 }
481 // Edit code leads to the (gated) editor — with member apps off it
482 // hides with it; History, Download, and Remove stay open.
483 if (ENABLE_MEMBER_APPS) {
484 const edit = el("button", null, "Edit code");
485 edit.onclick = () => {
486 draft = a.source;
487 editing = a.id;
488 editingName = m.name || "";
489 rerender();
490 };
491 actions.appendChild(edit);
492 }
493 const hist = el("button", null, "History");
494 hist.onclick = () => {
495 historyFor = a.id;
496 historyTitle = m.title || m.name || a.id;
497 historyData = null;
498 rerender();
499 };
500 actions.appendChild(hist);
501 const dl = el("button", "icon-btn");
502 dl.innerHTML = ICON_DOWNLOAD;
503 dl.title = "Download source";
504 dl.onclick = () => downloadSource(a.source, m.name || a.id);
505 actions.appendChild(dl);
506 const del = el("button", "danger", "Remove");
507 del.onclick = async () => {
508 if (!confirm(`Remove “${m.title || m.name}”? This deletes the app for everyone.`)) return;
509 del.disabled = true;
510 const r = await retireMemberApp(a.id);
511 if (r.error) {
512 del.disabled = false;
513 const e = el("div", "note err", r.error);
514 row.after(e);
515 return;
516 }
517 refreshList();
518 };
519 actions.appendChild(del);
520 }
521 row.appendChild(actions);
522 listBody.appendChild(row);
523 }
524 };
525
526 // With member apps off there's no authoring surface at all — the header
527 // note above says why, and the list (Open / History / Download / Remove)
528 // is the whole page. Deploys would 403 server-side regardless.
529 if (!ENABLE_MEMBER_APPS) {
530 refreshList();
531 return;
532 }
533
534 // ---- Add / edit an app --------------------------------------------------
535 const editor = el("div");
536 editor.appendChild(el("h3", null, editing ? `Edit “${editingName}”` : "Write a new app"));
537 const guide = el("p", "sub");
538 guide.appendChild(document.createTextNode("One self-contained ES module — see the "));
539 const link = el("a", null, "authoring guide");
540 link.href = "/api/member-app-guide";
541 link.target = "_blank";
542 link.rel = "noreferrer";
543 guide.appendChild(link);
544 guide.appendChild(document.createTextNode("."));
545 editor.appendChild(guide);
546
547 const ta = el("textarea", "eleven-input");
548 ta.spellcheck = false;
549 ta.value = draft || (editing ? "" : STARTER);
550 ta.placeholder = "Paste your app's JavaScript here…";
551 ta.oninput = () => { draft = ta.value; };
552 editor.appendChild(ta);
553
554 const note = el("div", "note");
555 note.style.display = "none";
556 const showNote = (text, kind) => {
557 note.className = "note" + (kind ? " " + kind : "");
558 note.textContent = text;
559 note.style.display = "";
560 };
561
562 const bar = el("div", "bar");
563 const checkBtn = el("button", null, "Check");
564 const deployBtn = el("button", "primary", editing ? "Update" : "Deploy");
565 bar.appendChild(checkBtn);
566 bar.appendChild(deployBtn);
567 if (editing) {
568 const cancel = el("button", null, "New app instead");
569 cancel.onclick = () => { draft = ""; editing = ""; editingName = ""; rerender(); };
570 bar.appendChild(cancel);
571 }
572 editor.appendChild(bar);
573 editor.appendChild(note);
574 wrap.appendChild(editor);
575
576 const setBusy = (on) => {
577 busy = on;
578 checkBtn.disabled = on;
579 deployBtn.disabled = on;
580 };
581
582 checkBtn.onclick = async () => {
583 if (busy) return;
584 setBusy(true);
585 showNote("Checking…");
586 const res = await extractManifest(ta.value);
587 setBusy(false);
588 if (res.error) {
589 showNote(res.error, "err");
590 return;
591 }
592 const m = res.manifest;
593 const bits = [`${m.glyph || "▦"} ${m.title || m.name} (${m.name})`, `${(m.types || []).length} event type(s)`];
594 if ((m.commands || []).length) bits.push("commands: " + m.commands.map((c) => c.name).join(" "));
595 if (m.chat) bits.push("companion chat view");
596 if (m.places ? m.places.includes("chat") : m.groupCapable) bits.push("can be added to groups");
597 showNote("Looks good — " + bits.join(" · "), "ok");
598 };
599
600 deployBtn.onclick = async () => {
601 if (busy) return;
602 setBusy(true);
603 showNote(editing ? "Updating…" : "Deploying…");
604 const res = await deployMemberApp(ta.value, editing ? { id: editing } : {});
605 if (res.error) {
606 setBusy(false);
607 showNote(res.error, "err");
608 return;
609 }
610 if (res.updated) {
611 showNote("Updated ✓ — reloading to apply the new code…", "ok");
612 // The apps_changed broadcast reloads to pick up the new code.
613 return;
614 }
615 setBusy(false);
616 draft = "";
617 editing = "";
618 editingName = "";
619 showNote("Deployed ✓ — it's in your Apps list now.", "ok");
620 refreshList();
621 };
622
623 // Re-render the whole surface (after edit/deploy/remove state changes).
624 function rerender() {
625 app.render(_state, root, ctx);
626 }
627
628 refreshList();
629 },
630});
631