| new file mode 100644 |
| index 0000000..b35cffd |
| --- /dev/null |
| +++ b/docs/building-apps.md |
| @@ -0,0 +1,616 @@ |
| +# π€ Building a mini-app β a self-contained authoring guide |
| + |
| +This guide teaches you (a person, or an LLM asked to help) how to write a |
| +**mini-app** for an Eleven Messenger / lchat instance: a small client-side |
| +program that lives alongside chats β a to-do list, a poll, a tracker, a game. |
| +It is fully self-contained: you do not need the server's source code, a |
| +repository checkout, or any build tooling. Every instance serves this document |
| +at `GET /api/app-guide`. |
| + |
| +You will produce **exactly one JavaScript file** β a plain ES module β that the |
| +instance admin installs. Everything the app is (logic, rendering, CSS) ships in |
| +that one file. |
| + |
| +## The one idea |
| + |
| +An app has no server. It trades **typed events** over the same end-to-end |
| +encrypted message stream that chat uses, and derives its state by **folding** |
| +those events. The server only ever sees ciphertext, so it can't run or even |
| +recognise app logic β everything happens in the browser. |
| + |
| +> **The encrypted thread log IS the app's database.** You never store state; you |
| +> emit events and fold them back into state. Two devices that fold the same |
| +> events arrive at the same state by construction. |
| + |
| +An app is four small things: |
| + |
| +1. an **events map** β the event types it owns and how each folds into state, |
| +2. an **initialState** β the empty state to start from, |
| +3. **commands** and/or a **message action** β how a user creates events, |
| +4. a **surface** β how state renders (a full pane, or a panel above a chat). |
| + |
| +On top of that model the framework gives every app three capabilities for free |
| +(each has its own section below): |
| + |
| +- **Time** β `ctx.schedule(event, when)` has the *server* deliver an event into |
| + the thread later, so things happen when no client is open (reminders that |
| + actually fire). The server holds only ciphertext and a timestamp. |
| +- **Memory** β folded state is snapshotted (encrypted) to the server and |
| + re-seeded on open, so an app's state survives beyond the loaded message |
| + window. 1,000 items work as well as 10. Apps do nothing to opt in. |
| +- **Voice** β an event can carry a `notice` (or the app a `notify` hook) to |
| + alert the user the moment it arrives: an in-app announcement, a system |
| + notification when hidden, a push notification when closed. All wording stays |
| + encrypted end-to-end. |
| + |
| +## Hard constraints β self-check every one before delivering |
| + |
| +An app that breaks any of these will be rejected or will misbehave. Check your |
| +finished file against each rule: |
| + |
| +1. **Exactly one file.** All logic, rendering, and CSS live in a single |
| + `<name>.js` β no companion files, no assets, no build step. |
| +2. **A plain ES module.** No bundler output, no TypeScript, no JSX, no |
| + `require()` β just standard `import`/`export` syntax a browser runs as-is. |
| +3. **Imports are allowed ONLY from `"../apps.js"` and `"../format.js"`.** |
| + Nothing else β no other relative paths, no URLs, no npm packages, no dynamic |
| + `import()`. The exact exports you may use are enumerated in |
| + "Allowed imports" below. |
| +4. **No network calls.** Never `fetch`, `XMLHttpRequest`, or `WebSocket`. All |
| + I/O flows through `ctx` (emit, schedule, snapshots) β if you're reaching for |
| + the network, you've taken a wrong turn. |
| +5. **No browser storage.** Never `localStorage`, `indexedDB`, or |
| + `document.cookie`. Durable state lives in the fold (it's encrypted and |
| + synced for free); transient UI state (an expanded/collapsed toggle) lives in |
| + module-level variables. |
| +6. **Never `innerHTML` user content.** Build DOM with `createElement` and set |
| + text with `textContent`. `innerHTML` is acceptable only for static, |
| + in-code string literals β never anything that includes user input. |
| +7. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.** |
| + The framework stops `ctx.interval` tickers on redraw and thread reset; a |
| + bare `setInterval` leaks one timer per redraw. |
| +8. **Every event type is prefixed with the app's `name`.** App `name: "note"` |
| + β types `note.add`, `note.remove`. Event types are globally unique across |
| + all apps. |
| +9. **Reducers are pure, immutable, and idempotent by entity id.** Return new |
| + state, never mutate the old; folding the same add twice must produce one |
| + item (the collection helpers guarantee this). |
| +10. **Never set `name`, `ts`, or `uid` on an emitted event** β the transport |
| + injects them. |
| +11. **Validate events in the reducer, not at the emitter.** Malformed or |
| + hostile events must fold to a no-op, never a crash. |
| +12. **You never touch the server, keys, or tokens.** Persistence, scheduling, |
| + and notifications all flow through `ctx`. |
| + |
| +## Interview β ask the user these first |
| + |
| +Before writing any code, ask the person who wants the app: |
| + |
| +1. **Personal or shared?** Is this *their own private tool* (a to-do list, |
| + bookmarks β `scope: "personal"`, its own thread in the sidebar, full-pane) |
| + or a *group tool everyone in a chat uses together* (a poll, a scoreboard β |
| + `scope: "shared"`, a panel above the chat)? |
| +2. **Takeover or companion?** For a personal app: should its surface *be* the |
| + whole thread (takeover β the default; right for boards, games, dashboards), |
| + or should the thread also offer a Chat view where the app's actions read as |
| + a conversation (companion β implement `chatMessage(event)`; right for |
| + list-like tools where "added X" reads naturally as a chat line)? |
| +3. **Which slash commands, and what syntax?** e.g. `/note buy milk` β what's |
| + the command name, what arguments does it take, and what should happen on |
| + empty/bad input? (Reply with a usage line, never fail silently.) |
| +4. **Anything time-based?** Should something happen *later* β a reminder, an |
| + expiry, a recurring nudge? That's `ctx.schedule`. |
| +5. **Should anything alert people?** Which events deserve a notification, and |
| + what should it say? That's a `notice` on the event (or a `notify` hook for |
| + state-dependent wording). Everything else stays silent. |
| +6. **What is the core entity, and what is its lifecycle?** A note: created, |
| + removed. A task: created, completed, reopened, removed. A vote: cast, |
| + changed. **Derive the events map directly from the answer** β one event |
| + type per lifecycle transition, e.g. `task.add`, `task.done`, `task.reopen`, |
| + `task.remove`. |
| +7. **How big can the list get?** If a surface can grow long (hundreds of |
| + rows), plan for `incremental: true` + `keyedList` so redraws stay cheap. |
| + |
| +## The shape of an app: `defineApp(spec)` |
| + |
| +Your file calls `defineApp` once, at module load, and exports the result: |
| + |
| +```js |
| +import { defineApp } from "../apps.js"; |
| +export const app = defineApp({ /* fields below */ }); |
| +``` |
| + |
| +| field | required | what it is | |
| +| -------------- | -------- | ---------- | |
| +| `name` | β | Unique lowercase id. Also the prefix for your event types. | |
| +| `title` | β | Human name (sidebar, header). | |
| +| `glyph` | β | One emoji/character used as the app's avatar. | |
| +| `scope` | β | `"personal"` or `"shared"` β see the interview above. | |
| +| `initialState` | β | `() => state`. A **factory**, called to seed each thread's empty state. | |
| +| `events` | β | `{ "<type>": (state, event, meta) => state }`. The fold. | |
| +| `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }` β `desc`/`args` feed the `/` typeahead. | |
| +| `render` | | `(state, el, ctx) => void`. Draws the surface. | |
| +| `chatMessage` | | `(event) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` β the app **takes over** the thread's full surface. | |
| +| `chatIntro` | | The empty-state intro line shown the first time the companion Chat view opens. | |
| +| `messageAction`| | `(msg) => { label, glyph, run(ctx) } \| null`. Adds an item to the chat message menu. | |
| +| `renderEvent` | | `(event, meta) => Node \| null`. An inline line in the message feed. | |
| +| `notify` | | `(event, ctx) => { title, body } \| null`. Speak on a live event (see Voice). | |
| +| `ephemeral` | | `(event, ctx) => void`. Handle relay-only events (see Ephemeral events). | |
| +| `preview` | | `(event) => string \| null`. Sidebar preview line for an event (default: silent). | |
| +| `unread` | | `(event) => boolean`. Whether a live event lights the thread's unread dot (default: no). | |
| +| `snapshot` | | Set `false` to opt out of state snapshots (only for an app whose state IS the log). | |
| +| `incremental` | | Set `true` to keep the surface between renders and reconcile it yourself (see `keyedList`). | |
| +| `styles` | | A CSS string, injected once. Style with the theme variables (below). | |
| + |
| +`defineApp` returns the app object with `.types` (the owned event types) and |
| +`.reduce` (the synthesised fold function) derived from `events` β handy for |
| +testing the reducer in Node. |
| + |
| +## The events map & the reducer |
| + |
| +Each handler is `(state, event, meta) => newState`. Rules: |
| + |
| +- **Pure & immutable.** Return new state; never mutate `state`. Two devices |
| + fold the same events and must converge; the UI also re-folds on reopen. |
| +- **No `default` case.** The framework returns state unchanged for any type |
| + your app doesn't handle. Your map lists only what you own. |
| +- **Event types are globally unique.** Prefix every type with your app `name` |
| + (`note.add`, `poll.vote`). |
| +- `meta` carries `{ id, ts }` where `id` is the server message id (used for |
| + dedupe) and `ts` the sender's send time. You rarely need it. |
| + |
| +### Collection helpers |
| + |
| +Most apps fold into a list of `{ id, β¦ }` items. Import these from |
| +`"../apps.js"` and your reducer is correct by construction β every op returns |
| +a **new array**, and `addItem` **dedupes by id**: |
| + |
| +```js |
| +addItem(list, item) // append unless the id is already present; always a fresh array |
| +updateItem(list, id, patch) // replace the matching item with { ...item, ...patch } |
| +removeItem(list, id) // drop the matching item |
| +``` |
| + |
| +Because `addItem` always returns a fresh array, you can `.sort()` its result |
| +in place safely. Not every fold is a flat list β a per-entity map is fine too, |
| +as long as you update it immutably: |
| + |
| +```js |
| +"poll.vote": (s, e) => |
| + ({ polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: e.choice } } : p)) }), |
| +``` |
| + |
| +## Emitting events β and the three free fields |
| + |
| +You create events with `ctx.emit(event)`. The event needs a `type` and |
| +whatever data your reducer reads. **Mint `ctx.id()` once for a new entity** |
| +and reuse that id in follow-up events (done / remove / vote): |
| + |
| +```js |
| +ctx.emit({ type: "note.add", id: ctx.id(), text }); // new entity β new id |
| +ctx.emit({ type: "note.remove", id: item.id }); // follow-up β the existing id |
| +``` |
| + |
| +Three fields are **added for you** by the transport on every emit β never set |
| +them yourself, just read them in your reducer: |
| + |
| +| field | value | |
| +| -------- | ----- | |
| +| `e.name` | the sender's screen name | |
| +| `e.ts` | the send time (epoch ms) β use this for sorting, not `Date.now()` | |
| +| `e.uid` | the sender's user id β e.g. a poll keys votes by `e.uid` | |
| + |
| +Encryption is automatic: `emit` encrypts the event to the thread key and sends |
| +it as a normal message. You never touch keys, and never send plaintext to the |
| +server. |
| + |
| +## `ctx` β what a handler and a render get |
| + |
| +| member | in | what it does | |
| +| ---------------------------- | ------------- | ------------ | |
| +| `ctx.emit(event)` | anywhere | Emit a typed event (encrypted + sent). Returns `Promise<boolean>`. | |
| +| `ctx.schedule(event, when)` | anywhere | Have the **server** deliver `event` into the thread at `when` (epoch ms). Returns `Promise<sid \| null>`. | |
| +| `ctx.unschedule(sid)` | anywhere | Cancel a pending scheduled event. | |
| +| `ctx.emitEphemeral(event)` | anywhere | Relay-only delivery β never stored, never folded (see Ephemeral events). | |
| +| `ctx.id()` | anywhere | A fresh unique id for a new entity. | |
| +| `ctx.me` | anywhere | The current user's id. | |
| +| `ctx.meName` | anywhere | The current user's screen name. | |
| +| `ctx.getState()` | anywhere | The app's latest folded state for this thread. | |
| +| `ctx.threadId` | anywhere | The thread the app is acting in. | |
| +| `ctx.reply(spec)` | commands only | A private "Only visible to you" confirmation rendered where the user typed. `spec` is `{ text, short?, actions? }` β `short` is the terse one-liner it calms down to; `actions` is `[{ label, run }]` chips. | |
| +| `ctx.open()` | action chips | Navigate to the app's thread β offer it as an action chip; **never** call it as a command's side effect. | |
| +| `ctx.interval(fn, ms)` | render only | A ticker the framework stops for you on redraw / reset. | |
| +| `ctx.onCleanup(fn)` | render only | Run `fn` before the next redraw or on thread reset. | |
| + |
| +(`ctx.host` also exists β a narrow privileged bridge the host injects for |
| +built-in connected-service apps. Leave it alone.) |
| + |
| +### Commands answer in place |
| + |
| +Running a command produces a **reply in the current context** β not a UI |
| +change, not a navigation. On bad input, reply with a usage line: |
| + |
| +```js |
| +commands: { |
| + "/note": { |
| + desc: "File a note", |
| + args: "<text>", |
| + run: (args, ctx) => { |
| + const text = args.trim(); |
| + if (!text) return ctx.reply({ text: "Usage: /note <text> β e.g. /note call the plumber" }); |
| + ctx.emit({ type: "note.add", id: ctx.id(), text }); |
| + ctx.reply({ text: `Filed β${text}β.`, short: `filed β${text}β`, actions: [{ label: "View notes", run: ctx.open }] }); |
| + }, |
| + }, |
| +}, |
| +``` |
| + |
| +For a personal app, the event is routed into the user's own app-thread no |
| +matter which chat they typed in; for a shared app it targets the current chat. |
| +If a command should move the user somewhere, offer that as an **action chip** |
| +on the reply β leaving the current context is always the user's tap. |
| + |
| +## Time β scheduled events |
| + |
| +`ctx.schedule(event, whenMs)` is `ctx.emit` shifted into the future, executed |
| +by the server: the event is encrypted **now**, the server holds the |
| +ciphertext, and at the due moment it lands in the thread's message log and |
| +fans out exactly like a posted message β every device folds it, closed apps |
| +get a push, open apps see it live. The server learns only *(thread, time)*, |
| +never what was scheduled. |
| + |
| +```js |
| +// Schedule first β the sid is part of the reminder's record. |
| +const id = ctx.id(); |
| +const sid = await ctx.schedule( |
| + { type: "remind.fire", id, notice: { title: "β° Reminder", body: text } }, |
| + whenMs, |
| +); |
| +ctx.emit({ type: "remind.add", id, text, when: whenMs, sid: sid || "" }); |
| +``` |
| + |
| +Rules of thumb: |
| + |
| +- **Store the returned `sid` inside an emitted event.** It's how *any* device |
| + β not just the one that scheduled β can later `ctx.unschedule(sid)`. |
| +- **Give a scheduled event a `notice`** (see Voice) β it's usually the whole |
| + point of scheduling one. |
| +- **Handle `sid === null`** (offline / error): degrade gracefully. |
| +- **The scheduled event is still just an event.** Its fold handler must be |
| + idempotent like any other β a cancel can race a delivery, so a fire folding |
| + onto an already-removed id must be a no-op. |
| +- Cancelling an id that already fired succeeds quietly; that race is expected. |
| + |
| +## Memory β snapshots (automatic) |
| + |
| +The client only holds a window of a thread's messages (~100), but folded app |
| +state is **snapshotted automatically**: after new events fold, the framework |
| +saves the state β encrypted under the thread key β to the server, and re-seeds |
| +it when the thread opens, folding only newer events on top. |
| + |
| +You get this for free. It works *because* of the reducer rules: folds are pure |
| +and deterministic, so `snapshot + newer events β‘ folding all of history`. What |
| +it means for you: |
| + |
| +- **Don't bound your state by the window.** A 1,000-item list is fine. |
| +- **State is durable, so treat it with respect**: never put anything in |
| + reducer state you wouldn't put in an event (it's all encrypted, but it's |
| + persisted). |
| +- **Schema-evolve defensively.** A snapshot written by an old version of your |
| + app will be seeded into a new version. Reducers and render must tolerate |
| + missing fields (`e.note || ""` β the same rule as for old events). |
| + |
| +## Voice β talking to the user |
| + |
| +Apps can speak the moment something happens, wherever the user is: |
| + |
| +1. **`notice` on an event** β the simple, usual way. Any event (typically a |
| + scheduled one) may carry `notice: { title, body }`. When it arrives live, |
| + the framework announces it in-app and raises a system notification if the |
| + page is hidden; when the app is **closed**, the service worker decrypts the |
| + push and shows the same notice. One field, every situation, end-to-end |
| + encrypted. |
| + |
| +2. **A `notify(event, ctx)` hook** β for wording that depends on state. Called |
| + only for **live** events (never during history refolds β replaying the past |
| + must not re-alert it). Return `{ title, body }` or `null` for silence: |
| + |
| + ```js |
| + notify(e, ctx) { |
| + if (e.type !== "poll.create" || e.uid === ctx.me) return null; |
| + return { title: "π New poll", body: e.q }; |
| + }, |
| + ``` |
| + |
| +App events with neither are silent β an add from your other device never |
| +buzzes anyone. |
| + |
| +## Ephemeral events β presence without a trace |
| + |
| +`ctx.emitEphemeral(event)` relays an event to the thread's connected members |
| +and **forgets it**: never stored, never pushed, never folded. It's for |
| +presence traffic β typing indicators, live cursors β where persistence would |
| +be wrong, not just wasteful. Receivers handle it in the app's |
| +`ephemeral(event, ctx)` hook; senders get their own echo (check |
| +`e.uid !== ctx.me`). |
| + |
| +Two rules: |
| + |
| +- **Own the type.** Routing is by event type, so an ephemeral type must appear |
| + in your `events` map β claim it with a no-op fold |
| + (`"myapp.cursor": (s) => s`). That also makes it harmless if a hostile |
| + client stores one as a real message. |
| +- **Never touch reducer state from the hook.** Ephemeral events are allowed to |
| + differ between devices; folding one would break convergence. Keep transient |
| + UI in module-level state and redraw it yourself. |
| + |
| +## Rendering a surface |
| + |
| +`render(state, el, ctx)` is called whenever state changes. The framework |
| +clears `el` first, so **always rebuild from `state`** β don't diff. |
| + |
| +- **Personal** apps render full-pane: `el` is the thread view. Usually an add |
| + form on top, then the list. |
| +- **Shared** apps render a panel above the chat feed. **Render nothing when |
| + state is empty** (just `return`) so the panel collapses until there's |
| + something to show. |
| +- **Never `innerHTML` user content.** Build nodes with `createElement` and set |
| + text with `textContent`. |
| +- Ship your CSS in the `styles` field and read the theme's variables so the |
| + app follows light/dark and the user's chosen theme. |
| + |
| +### Theme CSS variables |
| + |
| +These custom properties are defined by every theme β style with them instead |
| +of hard-coded colours: |
| + |
| +| variable | what it is | |
| +| -------- | ---------- | |
| +| `--bg` | page background | |
| +| `--panel` | panel / card background | |
| +| `--sidebar` | sidebar background | |
| +| `--ink` | primary text colour | |
| +| `--muted` | secondary / de-emphasised text | |
| +| `--border` | hairline borders and dividers | |
| +| `--accent` | the theme's accent colour (buttons, highlights) | |
| +| `--accent-ink` | text colour that sits on `--accent` | |
| +| `--link` | link colour | |
| +| `--bubble-me` | own-message bubble background | |
| +| `--bubble-me-ink` | text colour on `--bubble-me` | |
| +| `--bubble-them` | others' bubble background β also the standard hover/selected tint | |
| +| `--danger` | destructive-action colour | |
| +| `--warn` | warning colour | |
| +| `--radius` | the standard corner radius | |
| +| `--shadow` | the standard shadow colour | |
| +| `--font-display` | the theme's display font stack | |
| + |
| +For anything clickable, use `cursor: var(--ui-cursor, pointer)` so the app |
| +respects the platform's cursor convention. |
| + |
| +## Incremental surfaces β `keyedList` |
| + |
| +By default the framework wipes and rebuilds an app's surface on every state |
| +change β the right default for small surfaces. For long lists set |
| +`incremental: true` (the framework stops wiping) and reconcile with |
| +`keyedList` from `"../apps.js"`: |
| + |
| +```js |
| +keyedList(listEl, state.items, { |
| + key: (m) => m.id, // stable identity |
| + rev: (m) => m.editedAt, // re-render an item only when this changes |
| + render: (m) => buildRow(m) // build one item's DOM |
| +}); |
| +``` |
| + |
| +Unchanged items keep their exact DOM nodes β scroll position, focus, and |
| +loaded images survive; appends build only the new item. The container must be |
| +fully managed by `keyedList` (no other children). Build any once-only skeleton |
| +(a form, headers) on first render by checking whether it already exists. |
| + |
| +## Optional hooks |
| + |
| +### `messageAction(msg)` |
| + |
| +Contribute an item to a chat message's long-press / hover menu. Given the |
| +decrypted message `{ text, from }`, return `{ label, glyph, run(ctx) }` to |
| +offer the action, or `null` to sit this message out. The `run` gets a `ctx` |
| +already bound to your app-thread (for a personal app), so the action files |
| +into *your* app: |
| + |
| +```js |
| +import { firstLink } from "../format.js"; |
| +// β¦ |
| +messageAction(msg) { |
| + const url = firstLink(msg.text || ""); |
| + if (!url) return null; |
| + return { |
| + label: "Save link", |
| + glyph: "π", |
| + run(ctx) { |
| + ctx.emit({ type: "bookmark.add", id: ctx.id(), url, from: msg.from || "" }); |
| + }, |
| + }; |
| +}, |
| +``` |
| + |
| +### `renderEvent(event, meta)` |
| + |
| +Return a DOM node to show an inline line in the message feed for one of your |
| +events (e.g. "Poll created"), or `null` to keep it silent. Most apps return |
| +nothing and render only in their surface. |
| + |
| +### `chatMessage(event)` β companion presentation |
| + |
| +A personal app that implements `chatMessage` gets a **Chat view** alongside |
| +its **Interface view**, toggled in the thread header. Project each event into |
| +a chat line β return `{ text }` for a bot bubble, `{ text, action: true }` for |
| +a `/me`-style action line, or `null` to keep the event out of the chat: |
| + |
| +```js |
| +chatMessage(e) { |
| + if (e.type === "todo.add") return { text: `added β${e.text}β`, action: true }; |
| + return null; |
| +}, |
| +``` |
| + |
| +Both views fold from the same event log β one source of truth β so an action |
| +in one shows in the other. No `chatMessage` β the app takes over the whole |
| +thread surface (the default). |
| + |
| +### `preview(event)` / `unread(event)` |
| + |
| +How your events appear in the chat list: `preview` returns the sidebar |
| +preview line (or `null` to leave the existing preview), `unread` says whether |
| +a live event lights the thread's unread dot. Both default to silent β opt in |
| +only for events a user would want surfaced (a new poll: yes; every vote: no). |
| + |
| +## Allowed imports |
| + |
| +Import **only** from these two modules, and only these exports. |
| + |
| +From `"../apps.js"`: |
| + |
| +| export | what it is | |
| +| ------ | ---------- | |
| +| `defineApp(spec)` | registers the app β call exactly once | |
| +| `addItem(list, item)` | immutable append, deduped by `id` | |
| +| `updateItem(list, id, patch)` | immutable in-place patch | |
| +| `removeItem(list, id)` | immutable removal | |
| +| `partitionByGrace(items, doneAtOf, now, graceMs?)` | split items into `{ active, grace, archived }` by a completion timestamp β for "linger then tuck away" UI | |
| +| `DONE_GRACE_MS` | the standard grace-period length (ms) | |
| +| `keyedList(el, items, opts)` | incremental DOM reconciliation for `incremental: true` surfaces | |
| + |
| +(`apps.js` exports other functions β `setEmitTransport`, `foldEvent`, |
| +`runCommand`, β¦ β those are host/framework wiring. An app must not call them.) |
| + |
| +From `"../format.js"` (all pure text helpers): |
| + |
| +| export | what it is | |
| +| ------ | ---------- | |
| +| `tokenizeText(text)` | split text into `{ text }` / `{ text, href }` tokens β XSS-safe linkifying | |
| +| `richTokens(text)` | tokens with inline formatting too: `{ text, mark }`, mark β `b`/`i`/`s`/`code` | |
| +| `blockTokens(text)` | block structure: `{ type: "p"\|"code"\|"quote", text }` | |
| +| `firstLink(text)` | the first http(s) link in a message, skipping code blocks | |
| +| `youtubeId(url)` | parse a YouTube URL into `{ id, start }`, or `null` | |
| +| `replySnippet(payload, max?)` | short plain-text preview of a message | |
| +| `emojiOnlyCount(text)` | how many emoji, if the text is emoji-only (else 0) | |
| +| `typingSummary(names)` | "X is typingβ¦" one-liner | |
| +| `autoGroupTitle(names)` | member names joined into a group title | |
| +| `formatBytes(n)` | human-readable file size | |
| + |
| +Render tokens as DOM nodes (`document.createTextNode` for plain text, an |
| +element per marked token) β that's what keeps them XSS-safe. |
| + |
| +## A complete app |
| + |
| +A real, working app β copy it, rename it, and change the guts. It's a personal |
| +"Notes" list: `/note <text>` from any chat files a note; the app's own sidebar |
| +pane lists them. |
| + |
| +```js |
| +// note.js |
| +import { defineApp, addItem, removeItem } from "../apps.js"; |
| + |
| +export const app = defineApp({ |
| + name: "note", // unique id, lowercase β also the event-type prefix |
| + title: "Notes", // shown in the sidebar and header |
| + glyph: "π", // avatar glyph |
| + scope: "personal", // its own sidebar thread, rendered full-pane |
| + |
| + // The empty state. A factory (not a value) so every thread gets its own. |
| + initialState: () => ({ items: [] }), |
| + |
| + // The events map: KEYS are the event types this app owns, VALUES fold each |
| + // event into new state. Handlers must be PURE: return new state, never |
| + // mutate the old. The collection helpers keep adds idempotent. |
| + events: { |
| + "note.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s), |
| + "note.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| + }, |
| + |
| + // A slash command a user can type in any chat. It emits an event; because |
| + // this is a personal app, the event is routed into the user's own Notes |
| + // thread wherever they typed it, with a private confirmation reply. |
| + commands: { |
| + "/note": { |
| + desc: "File a note", |
| + args: "<text>", |
| + run: (args, ctx) => { |
| + const text = args.trim(); |
| + if (!text) return ctx.reply({ text: "Usage: /note <text> β e.g. /note call the plumber" }); |
| + ctx.emit({ type: "note.add", id: ctx.id(), text }); |
| + ctx.reply({ text: `Filed β${text}β.`, short: `filed β${text}β` }); |
| + }, |
| + }, |
| + }, |
| + |
| + // The app's CSS travels with it β injected once by the framework. Read the |
| + // theme's CSS variables so it follows the theme. |
| + styles: ` |
| +.note-item { display: flex; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); } |
| +.note-text { flex: 1 1 auto; overflow-wrap: anywhere; } |
| +`, |
| + |
| + // Personal scope: `el` is the whole thread pane. Build DOM nodes and set |
| + // text with textContent β NEVER innerHTML for anything a user typed. |
| + render(state, el, ctx) { |
| + for (const item of (state && state.items) || []) { |
| + const row = document.createElement("div"); |
| + row.className = "note-item"; |
| + const text = document.createElement("span"); |
| + text.className = "note-text"; |
| + text.textContent = item.text; // user content β textContent, never innerHTML |
| + const rm = document.createElement("button"); |
| + rm.textContent = "Γ"; |
| + rm.onclick = () => ctx.emit({ type: "note.remove", id: item.id }); |
| + row.append(text, rm); |
| + el.appendChild(row); |
| + } |
| + }, |
| +}); |
| +``` |
| + |
| +## Invariants (the things that fail silently if you skip them) |
| + |
| +1. **Idempotent fold.** The message window is re-folded from scratch on every |
| + thread reopen, so folding the same add twice must produce one item. |
| + `addItem` handles this by deduping on `id`; a custom fold must dedupe too. |
| +2. **Pure, immutable reducers.** Never mutate prior state. The collection |
| + helpers return fresh arrays; if you go custom, spread (`{ ...obj }`, |
| + `[...arr]`). This is also what makes snapshots correct. |
| +3. **Don't set `name` / `ts` / `uid` on emit** β the transport injects them. |
| + Setting `ts: Date.now()` is dead code (the transport overwrites it). |
| +4. **Namespace event types** with your app `name`; they're globally unique. |
| +5. **Never `innerHTML` user content** β `textContent` / DOM nodes only. |
| +6. **Shared apps render nothing when empty** so their panel collapses. |
| +7. **You never touch the server or keys.** State persistence, scheduling, and |
| + notifications all flow through `ctx` β if you find yourself reaching for |
| + `fetch` or a key, you've taken a wrong turn. |
| +8. **Validate events in the reducer, not at the emitter.** Anything a thread |
| + member (person, other device, bot) can send is a legal input to your fold β |
| + malformed or hostile events must fold to a no-op, never a crash or a lie. |
| +9. **A scheduled event will arrive later, into different state.** Fold |
| + handlers for scheduled types must tolerate their target being gone |
| + (dismissed while the delivery was in flight). |
| + |
| +## Delivering the app |
| + |
| +Produce **one file**, named after the app (`note.js`, `poll.js`, β¦), passing |
| +every hard constraint above. Then hand it to the person who runs the instance: |
| +custom apps are installed by the **instance admin** (from the admin console's |
| +Apps section, where available) β you don't deploy anything yourself, and the |
| +file needs no changes to be installed. |
| + |
| +Before delivering, self-review against the hard-constraints list and the |
| +invariants. The instance also serves a static checker that verifies the |
| +mechanical rules (one `defineApp`, allowed imports only, no forbidden APIs, |
| +event-type prefixes, β¦). Download it from the same server this guide came |
| +from and run it with Node β it has zero dependencies: |
| + |
| +```sh |
| +curl -sO https://<instance>/api/check-app.mjs |
| +node check-app.mjs your-app.js |
| +``` |
| + |
| +and iterate until it passes. (If you happen to have the lchat repository |
| +instead, the same file is `tools/check-app.mjs`.) |