Plugin maturity: more UI slots, message decorators, two examples

Adds topbar_item and sidebar_item slots, plus addMessageDecorator() —
a per-message hook handed a stable read-only view {id,nick,text,kind,
ts,mine}, each rendered inside its own error boundary. Ships orbit-clock
(topbar) and orbit-copy (decorator) examples and documents both.
This commit is contained in:
reverse 2026-06-20 08:30:27 +00:00
parent 6a42d30812
commit 228973992a
11 changed files with 161 additions and 7 deletions

View file

@ -69,6 +69,7 @@ bound to the app's React) — runtime template markup, no build step. Prefer
| `orbit.storage.get(key, def)/set(key, val)` | namespaced persistence |
| `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 => …)` | append UI to every message; `m` = `{id, nick, text, kind, ts, mine}` |
| `orbit.h / orbit.html` | render helpers |
| `log(…)` | namespaced console logger |
@ -81,10 +82,14 @@ bound to the app's React) — runtime template markup, no build step. Prefer
| 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) |
| `settings_section` | a whole section in Settings (own nav entry + pane) — use `orbit.addSettingsSection()` |
More slots (message decorators, side panels) will be added as the core grows
stable homes for them.
Message decorators are added with `orbit.addMessageDecorator(m => …)` rather than
a slot — the callback runs for every rendered message and receives a read-only
view of it. Every contributed slot and decorator renders inside its own error
boundary, so a crashing plugin renders nothing instead of taking down the app.
## Compiled plugins (write real React)
@ -117,5 +122,10 @@ replacement. Those would couple plugins to internals that are still moving; the
API above is the deliberately stable surface. Ask (or open an issue) if you need
a hook that isn't here.
See [`public/plugins/orbit-demo.js`](../public/plugins/orbit-demo.js) for a
complete working example.
## Working examples
| File | Shows |
|---|---|
| [`orbit-demo.js`](../public/plugins/orbit-demo.js) | events, a `composer_button`, an IRC action |
| [`orbit-clock.js`](../public/plugins/orbit-clock.js) | a `topbar_item` with live React-hook state |
| [`orbit-copy.js`](../public/plugins/orbit-copy.js) | a `message_decorator` (per-message copy button) |

View file

@ -0,0 +1,31 @@
/*
* Orbit clock plugin adds a live HH:MM clock to the topbar.
*
* Demonstrates the `topbar_item` UI slot and using React hooks through
* orbit.React. Note the pattern: addUi returns an ELEMENT (orbit.h(Clock)), so
* Clock is a real component that owns its own hook state across re-renders.
*
* Load via config.json: "plugins": ["/app/plugins/orbit-clock.js"]
*/
Orbit.plugin('orbit-clock', (orbit, log) => {
const { useState, useEffect } = orbit.React;
function Clock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 15000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
return orbit.html`
<span
title="Local time (clock plugin)"
style=${{ font: '600 .82rem/1 inherit', opacity: 0.7, padding: '0 .45rem', alignSelf: 'center' }}
>${hh}:${mm}</span>
`;
}
orbit.addUi('topbar_item', () => orbit.h(Clock));
log('clock ready');
});

View file

@ -0,0 +1,22 @@
/*
* Orbit copy-message plugin appends a tiny "copy" button after every message.
*
* Demonstrates the `message_decorator` hook, which runs for each rendered
* message and receives a read-only view of it: { id, nick, text, kind, ts, mine }.
* Each decorator runs inside its own error boundary, so a throw here can never
* take down the message list.
*
* Load via config.json: "plugins": ["/app/plugins/orbit-copy.js"]
*/
Orbit.plugin('orbit-copy', (orbit, log) => {
orbit.addMessageDecorator((m) =>
orbit.html`
<button
title="Copy message text"
style=${{ border: 0, background: 'transparent', cursor: 'pointer', opacity: 0.45, fontSize: '.78rem', padding: 0 }}
onClick=${() => navigator.clipboard && navigator.clipboard.writeText(m.text)}
></button>
`,
);
log('copy decorator ready');
});

View file

@ -1,7 +1,7 @@
// Tchatou service worker — installable PWA + offline app shell.
// Scope: /app/. Only handles same-origin /app/ GETs; the IRC websocket and all
// API calls (cloudflare, change_password, upload) pass straight through.
const CACHE = 'tchatou-v44';
const CACHE = 'tchatou-v45';
const SHELL = ['/app/', '/app/index.html', '/app/favicon.svg', '/app/orbit-icon.svg', '/app/manifest.webmanifest'];
self.addEventListener('install', (e) => {

View file

@ -5,8 +5,23 @@ import type { ChatMessage } from '../../irc/types';
import { fmtTime, nickColor, IRCOP_COLOR, formatIrc } from '../../lib/format';
import { useTheme } from '../../ui/theme';
import { Avatar } from '../Avatar';
import { usePluginRegistry, type DecoratorInfo } from '../../plugins/registry';
import { PluginBoundary } from '../PluginBoundary';
const QUICK = ['👍', '😂', '❤️', '🔥'];
// Renders every plugin-contributed message decorator for one message. Each runs
// inside its own error boundary so a crashing plugin can't take down the list.
function MsgDecorations({ m }: { m: ChatMessage }) {
const decs = usePluginRegistry((s) => s.decorators);
if (!decs.length) return null;
const info: DecoratorInfo = { id: m.id, nick: m.from, text: m.text, kind: m.kind, ts: m.ts, mine: !!m.self };
return (
<span className="msg-deco">
{decs.map((d) => <PluginBoundary key={d.id} render={() => d.render(info)} label="message_decorator" />)}
</span>
);
}
function MsgRow({ m, cont }: { m: ChatMessage; cont: boolean }) {
const { t } = useTranslation();
const react = useChat((s) => s.toggleReaction);
@ -48,6 +63,7 @@ function MsgRow({ m, cont }: { m: ChatMessage; cont: boolean }) {
</button>{' '}
<span className="mircline__txt">
{m.redacted ? `${t('messages.deleted')}` : formatIrc(m.text, m.self)}
{!m.redacted && <MsgDecorations m={m} />}
</span>
{m.reactions && m.reactions.length > 0 && (
<span className="reactions reactions--inline">
@ -90,6 +106,7 @@ function MsgRow({ m, cont }: { m: ChatMessage; cont: boolean }) {
)}
<div className={`line ${m.kind === 'action' ? 'line--action' : ''} ${m.kind === 'notice' ? 'line--notice' : ''} ${m.redacted ? 'line--redacted' : ''}`}>
{m.redacted ? `${t('messages.deleted')}` : (m.kind === 'action' ? <em>{formatIrc(m.text, m.self)}</em> : formatIrc(m.text, m.self))}
{!m.redacted && <MsgDecorations m={m} />}
</div>
{showCtx && (
<button

View file

@ -4,6 +4,8 @@ import { useChat, SERVER } from '../../store';
import { avatarBg } from '../../lib/format';
import { useTheme } from '../../ui/theme';
import { Avatar } from '../Avatar';
import { usePluginRegistry } from '../../plugins/registry';
import { PluginBoundary } from '../PluginBoundary';
export function Rail() {
const { t } = useTranslation();
const nick = useChat((s) => s.nick);
@ -37,6 +39,7 @@ export function Sidebar({ onNavigate }: { onNavigate: () => void }) {
const setModal = useChat((s) => s.setModal);
const openUser = useChat((s) => s.openUser);
const closeBuffer = useChat((s) => s.closeBuffer);
const sidebarItems = usePluginRegistry((s) => s.ui);
const away = useChat((s) => s.away);
const setAway = useChat((s) => s.setAway);
const [q, setQ] = useState('');
@ -86,6 +89,7 @@ export function Sidebar({ onNavigate }: { onNavigate: () => void }) {
<aside className="sidebar">
<div className="side-top">
<h2 className="side-title">{t('nav.home')}</h2>
{sidebarItems.filter((u) => u.slot === 'sidebar_item').map((u) => <PluginBoundary key={u.id} render={u.render} label="sidebar_item" />)}
<button className="side-compose" title={t('sidebar.newChat')} aria-label={t('sidebar.newChat')} onClick={() => setModal('join')}></button>
</div>

View file

@ -5,6 +5,8 @@ import { avatarBg } from '../../lib/format';
import { getConfig } from '../../config';
import { useTheme } from '../../ui/theme';
import { NotifyMenu } from './NotifyMenu';
import { usePluginRegistry } from '../../plugins/registry';
import { PluginBoundary } from '../PluginBoundary';
export function Topbar({ onMenu, onMembers }: { onMenu: () => void; onMembers: () => void }) {
const { t } = useTranslation();
const buffer = useChat((s) => s.buffers[s.active]);
@ -14,6 +16,7 @@ export function Topbar({ onMenu, onMembers }: { onMenu: () => void; onMembers: (
const myPrefix = useChat((s) => { const b = s.buffers[s.active]; const m = b?.members[s.nick]; return m?.prefixes || m?.prefix || ''; });
const amOp = /[~&@!%]/.test(myPrefix);
const mirc = useTheme().startsWith('yomirc');
const topbarItems = usePluginRegistry((s) => s.ui);
const [searching, setSearching] = useState(false);
if (!buffer) return <div className="topbar"><button className="nav-toggle" onClick={onMenu} aria-label={t('sidebar.channels')}></button></div>;
const n = Object.keys(buffer.members).length;
@ -50,6 +53,7 @@ export function Topbar({ onMenu, onMembers }: { onMenu: () => void; onMembers: (
? <span className="topbar__topic">{buffer.topic}</span>
: buffer.isChannel && <span className="topbar__topic topbar__topic--muted">{t('topbar.publicChannel', { n })}</span>}
</div>
{topbarItems.filter((u) => u.slot === 'topbar_item').map((u) => <PluginBoundary key={u.id} render={u.render} label="topbar_item" />)}
{!isServer && <button className="topbar__search" title={t('topbar.search')} aria-label={t('topbar.search')} onClick={() => setSearching(true)}>🔍</button>}
{buffer.isChannel && <NotifyMenu />}
{buffer.isChannel && amOp && <button className="topbar__search" title={t('topbar.manage')} aria-label={t('topbar.manage')} onClick={() => setModal('chanadmin')}>🛠</button>}

View file

@ -1678,3 +1678,6 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
.nmenu__txt b { font-size: .88rem; font-weight: 600; }
.nmenu__txt span { font-size: .73rem; color: var(--muted); }
.nmenu__check { flex: none; color: var(--green-d); font-weight: 700; }
/* Plugin message decorators (appended inline after a message's text) */
.msg-deco { display: inline-flex; align-items: center; gap: .25rem; margin-left: .35rem; vertical-align: middle; }

View file

@ -13,7 +13,7 @@ import { useChat } from '../store';
import { getTheme, setTheme, type Theme } from '../ui/theme';
import { getConfig } from '../config';
import { bus } from './bus';
import { usePluginRegistry, type UiSlot } from './registry';
import { usePluginRegistry, type UiSlot, type DecoratorInfo } from './registry';
const html = htm.bind(React.createElement);
const THEMES: Theme[] = ['light', 'dark', 'orbit', 'orbit-dark', 'yomirc', 'yomirc-dark'];
@ -73,6 +73,8 @@ export interface OrbitPluginApi {
addUi: (slot: UiSlot, render: () => ReactNode) => () => void;
/** Add a whole section to Settings (own nav entry + pane). */
addSettingsSection: (opts: { label: string; icon?: string; render: () => ReactNode }) => () => void;
/** Decorate every rendered message (e.g. a badge appended after the text). */
addMessageDecorator: (render: (m: DecoratorInfo) => ReactNode) => () => void;
}
function makeApi(name: string): OrbitPluginApi {
@ -111,6 +113,7 @@ function makeApi(name: string): OrbitPluginApi {
addUi: (slot, render) => usePluginRegistry.getState().addUi(slot, name, render),
addSettingsSection: (opts) =>
usePluginRegistry.getState().addUi('settings_section', name, opts.render, { label: opts.label, icon: opts.icon }),
addMessageDecorator: (render) => usePluginRegistry.getState().addDecorator(name, render),
};
}

View file

@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { usePluginRegistry, type DecoratorInfo } from './registry';
const reset = () => usePluginRegistry.setState({ ui: [], decorators: [] });
describe('plugin registry', () => {
beforeEach(reset);
it('adds UI to a named slot and removes it via the returned disposer', () => {
const remove = usePluginRegistry.getState().addUi('topbar_item', 'p', () => null);
const ui = usePluginRegistry.getState().ui;
expect(ui).toHaveLength(1);
expect(ui[0]).toMatchObject({ slot: 'topbar_item', plugin: 'p' });
remove();
expect(usePluginRegistry.getState().ui).toHaveLength(0);
});
it('keeps slots from different plugins independent', () => {
usePluginRegistry.getState().addUi('sidebar_item', 'a', () => null);
usePluginRegistry.getState().addUi('composer_button', 'b', () => null);
const slots = usePluginRegistry.getState().ui.map((u) => u.slot);
expect(slots).toEqual(['sidebar_item', 'composer_button']);
});
it('registers a message decorator and passes it the message info', () => {
let seen: DecoratorInfo | null = null;
const remove = usePluginRegistry.getState().addDecorator('p', (m) => { seen = m; return null; });
const dec = usePluginRegistry.getState().decorators[0];
const info: DecoratorInfo = { id: '1', nick: 'bob', text: 'hi', kind: 'privmsg', ts: 0, mine: false };
dec.render(info);
expect(seen).toEqual(info);
remove();
expect(usePluginRegistry.getState().decorators).toHaveLength(0);
});
});

View file

@ -5,7 +5,7 @@ import { create } from 'zustand';
import type { ReactNode } from 'react';
// The UI slots the core currently exposes. Add more as components grow homes.
export type UiSlot = 'composer_button' | 'settings_section';
export type UiSlot = 'composer_button' | 'settings_section' | 'topbar_item' | 'sidebar_item';
export interface PluginUi {
id: string;
@ -16,16 +16,41 @@ export interface PluginUi {
meta?: { label?: string; icon?: string };
}
// A stable, plugin-facing subset of a message handed to message decorators —
// intentionally NOT the internal ChatMessage shape, so plugins don't couple to it.
export interface DecoratorInfo {
id: string;
nick: string;
text: string;
kind: string;
ts: number;
mine: boolean;
}
export interface PluginDecorator {
id: string;
plugin: string;
render: (m: DecoratorInfo) => ReactNode;
}
interface RegistryState {
ui: PluginUi[];
decorators: PluginDecorator[];
addUi: (slot: UiSlot, plugin: string, render: () => ReactNode, meta?: PluginUi['meta']) => () => void;
addDecorator: (plugin: string, render: (m: DecoratorInfo) => ReactNode) => () => void;
}
export const usePluginRegistry = create<RegistryState>((set) => ({
ui: [],
decorators: [],
addUi: (slot, plugin, render, meta) => {
const id = `${plugin}:${slot}:${Math.random().toString(36).slice(2, 8)}`;
set((s) => ({ ui: [...s.ui, { id, plugin, slot, render, meta }] }));
return () => set((s) => ({ ui: s.ui.filter((u) => u.id !== id) }));
},
addDecorator: (plugin, render) => {
const id = `${plugin}:dec:${Math.random().toString(36).slice(2, 8)}`;
set((s) => ({ decorators: [...s.decorators, { id, plugin, render }] }));
return () => set((s) => ({ decorators: s.decorators.filter((d) => d.id !== id) }));
},
}));