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:
parent
6a42d30812
commit
228973992a
11 changed files with 161 additions and 7 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
31
public/plugins/orbit-clock.js
Normal file
31
public/plugins/orbit-clock.js
Normal 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');
|
||||
});
|
||||
22
public/plugins/orbit-copy.js
Normal file
22
public/plugins/orbit-copy.js
Normal 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');
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
35
src/plugins/registry.test.ts
Normal file
35
src/plugins/registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) }));
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue