| 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. |
| 13 | import { defineApp, namespace, appByName } from "../apps.js"; |
| 14 | import { ICON_PUZZLE } from "../icons.js"; |
| 15 | import { ENABLE_MEMBER_APPS } from "../shellstate.js"; |
| 16 | import { appOpenPlan } from "../ui.js"; |
| 17 | import { |
| 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). |
| 33 | const N = namespace("appstudio"); |
| 34 | |
| 35 | // Static in-code SVG (safe for innerHTML — no user content). |
| 36 | const 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>'; |
| 38 | const 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). |
| 42 | function 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 | |
| 54 | const STARTER = `import { defineApp, namespace } from "../apps.js"; |
| 55 | |
| 56 | const N = namespace("hello"); // N.ev("tap") → "hello.tap", N.cls("btn") → "hello-btn" |
| 57 | |
| 58 | export 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. |
| 88 | let draft = ""; |
| 89 | let editing = ""; |
| 90 | let editingName = ""; |
| 91 | let busy = false; |
| 92 | // Version-history view: the app whose history is open (""), its title, and the |
| 93 | // fetched history (null until loaded). |
| 94 | let historyFor = ""; |
| 95 | let historyTitle = ""; |
| 96 | let historyData = null; |
| 97 | |
| 98 | function 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). |
| 110 | function 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. |
| 119 | let chooser = null; |
| 120 | function closeChooser() { |
| 121 | if (chooser) { |
| 122 | chooser.remove(); |
| 123 | chooser = null; |
| 124 | } |
| 125 | } |
| 126 | function 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 | } |
| 135 | function chooserCancel(box) { |
| 136 | const c = el("button", "cancel", "Cancel"); |
| 137 | c.onclick = closeChooser; |
| 138 | box.appendChild(c); |
| 139 | } |
| 140 | function 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. |
| 149 | function 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. |
| 180 | function 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. |
| 215 | async 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 | |
| 241 | export 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 | |