orbit-client-docs/docs/plugins.md
2026-07-04 03:38:53 +00:00

171 lines
7.6 KiB
Markdown

Section: Plugins
Order: 200
# Plugin system
Orbit has a small, **operator-controlled** plugin system. A deployment lists
plugin scripts in `config.json`; each is loaded at startup and registers against
a global `Orbit` object to hook events, read state, send IRC, theme the UI, and
add UI in predefined slots.
> **Experimental.** Orbit is a work in progress and this API may change between
> releases. Plugins are deployment-controlled — same trust level as the app
> itself. There is no user-uploaded plugin marketplace, by design.
## Enabling plugins
Add script URLs to `plugins` in [`config.json`](https://orbit.tchatou.fr/docs/config/):
```json
{ "plugins": ["/app/plugins/orbit-demo.js"] }
```
They load in order, after the app boots. Host them anywhere the page can reach
(same-origin recommended). For a third-party origin, pin the file with
Subresource Integrity by giving an object instead of a URL:
```json
{ "plugins": [{ "url": "https://cdn.example/x.js", "integrity": "sha384-…" }] }
```
(`crossorigin` defaults to `anonymous` when an `integrity` hash is set.)
## A quick plugin
A plugin is a plain `.js` file that calls `Orbit.plugin()`. UI is authored with
the `html` tagged template (runtime markup, no build step), or with
`orbit.h(...)` (`React.createElement`).
```js
Orbit.plugin('my-plugin', (orbit, log) => {
log('loaded, Orbit v' + orbit.version);
orbit.on('message', (m) => log(m.from, 'said', m.text, 'in', m.target));
orbit.addUi('composer_button', () =>
orbit.html`<button class="composer__emoji" title="Shrug"
onClick=${() => orbit.irc.say('¯\\_(ツ)_/¯')}>🤷</button>`);
});
```
For anything substantial — real components with state — build a compiled plugin
instead. See **[Compiled plugins](https://orbit.tchatou.fr/docs/compiled-plugins/)**.
## The `Orbit` API
### Global
| Member | Description |
|---|---|
| `Orbit.version` / `Orbit.commit` | app version + git commit (build-time) |
| `Orbit.apiVersion` | plugin API contract version (bumped on breaking changes) — guard with `if (Orbit.apiVersion < N) …` |
| `Orbit.plugin(name, fn)` | register a plugin; `fn(orbit, log)` |
| `Orbit.on/once/off/emit(event, …)` | the app event bus |
| `Orbit.config()` | the resolved runtime config |
| `Orbit.React` / `Orbit.ReactDOM` / `Orbit.jsxRuntime` / `Orbit.Fragment` / `Orbit.h` / `Orbit.html` | render primitives (externalization targets for compiled plugins) |
### Inside `Orbit.plugin(name, (orbit) => …)`
| Member | Description |
|---|---|
| `orbit.on/once/off/emit` | event bus (see events below) |
| `orbit.state.active()` | active buffer name |
| `orbit.state.nick()` / `account()` | your nick / logged-in account |
| `orbit.state.buffers()` | open buffer names |
| `orbit.state.get()` | full store snapshot (read-only) |
| `orbit.irc.send(line)` | send a raw IRC line |
| `orbit.irc.msg(target, text)` | PRIVMSG a target |
| `orbit.irc.say(text)` | send to the active buffer |
| `orbit.irc.join(chan)` / `part(chan)` | join / part |
| `orbit.irc.list()` | request the channel list |
| `orbit.themes.current()/list()/set(id)` | read/set the theme |
| `orbit.storage.get(key, def)/set(key, val)` | namespaced persistence |
| `orbit.config()` | the resolved runtime config (branding, features, …) |
| `orbit.i18n.language()` | current UI language code (e.g. `es`, `pt-BR`) |
| `orbit.i18n.t(key, opts?)` | translate an app locale key (supports `{{interpolation}}`) |
| `orbit.i18n.pick(table)` | pick a string from a `{ lang: text }` table by the current language |
| `orbit.addUi(slot, render)` | add UI to a slot (returns a remover) |
| `orbit.addSettingsSection({label, icon?, render})` | add a whole Settings section |
| `orbit.addMessageDecorator(m => …)` | inline UI after every message's text; `m` = `{id, nick, text, raw, kind, ts, mine}` (`text` is formatting-stripped, `raw` keeps mIRC codes) |
| `orbit.addMessageAction(m => …)` | a button in every message's hover action toolbar (next to reply/react); same `m` |
| `orbit.addCommand(name, {run, help?})` | register a `/slash` command — `run(args, rest)`; built-ins take priority |
| `orbit.addShortcut(combo, run)` | register a keyboard shortcut, e.g. `"mod+shift+k"` (mod = Cmd/Ctrl) |
| `orbit.addTheme(id, {name, vars, css?, icon?, color?})` | register a runtime theme; shows in Settings → Appearance |
| `orbit.notify(title, body?)` | fire a brand-styled desktop notification (asks permission once) |
| `orbit.modal(render, {title?, wide?})` | open a modal dialog (core owns the shell); returns a close function |
| `orbit.h / orbit.html` | render helpers |
| `log(…)` | namespaced console logger |
### Events
`ready`, `connected` (`{nick}`), `status` (connection status string),
`buffer.active` (buffer name), `message` (`{from, target, text, self}`),
`raw` (the parsed IRC message).
### UI slots
| Slot | Where |
|---|---|
| `composer_button` | a button in the message composer toolbar |
| `topbar_item` | an item in the channel topbar action row (next to search / notifications) |
| `sidebar_item` | an item in the conversation sidebar header (next to the compose button) |
| `footer_item` | a button in the app footer bar's actions (next to the away / settings buttons) — style it with the `appbar__act` class to match |
| `settings_section` | a whole section in Settings (own nav entry + pane) — use `orbit.addSettingsSection()` |
Two per-message hooks run for every rendered message:
`orbit.addMessageDecorator(m => …)` appends inline UI after the text, and
`orbit.addMessageAction(m => …)` adds a button to the hover action toolbar next
to reply/react. Every contributed slot, action and decorator renders inside its
own error boundary, so a crashing plugin renders nothing instead of taking down
the app.
Commands, shortcuts and themes hang off the same api:
```js
orbit.addCommand('roll', {
help: 'Roll a die: /roll [sides]',
run: (args) => orbit.irc.say('rolled ' + (1 + Math.floor(Math.random() * (+args[0] || 6)))),
});
orbit.addShortcut('mod+shift+r', () => orbit.notify('Orbit', 'shortcut fired'));
orbit.addTheme('midnight', {
name: 'Midnight',
color: '#0b0f1a',
vars: { '--bg': '#0b0f1a', '--accent': '#6ea8fe' },
});
```
A `/command` you register loses to a built-in of the same name. `mod` in a
shortcut is Cmd on macOS and Ctrl elsewhere. `orbit.notify` fires even when the
tab is focused and asks for permission the first time.
### Localization (i18n)
The UI ships in 10 languages — keep your plugin in step so it isn't stuck in one. Two ways:
- **Self-contained** — carry a `{ lang: text }` table and let Orbit pick the current language:
```js
const LABEL = { en: 'Copy', fr: 'Copier', de: 'Kopieren' };
orbit.html`<button title=${orbit.i18n.pick(LABEL)}></button>`;
```
- **Interpolated** — `orbit.i18n.t('key', { name })` resolves an app locale key with
`{{placeholders}}`, so word order is correct per language. The four bundled plugins
(clock, copy, invite, games) use this.
Read the string **at render time**, not once at load: plugin UI re-renders when the language
changes, so the text follows automatically. `orbit.i18n.language()` returns the current code
if you need to branch.
> `orbit.i18n` and `orbit.config()` were added in **`apiVersion` 4** — guard with
> `if (orbit.apiVersion >= 4)` if your plugin must run on older builds.
## Trust & security
Plugins are **operator-controlled**: a deployment lists them in `config.json`, so
they run with the same trust as the app itself. There is no user-uploaded plugin
mechanism. Orbit deliberately does **not** expose internal modules or runtime
component replacement — that would couple plugins to internals that are still
moving. The API above is the stable surface.