plugins: add orbit.modal() for plugin dialogs

This commit is contained in:
Jean Chevronnet 2026-07-04 03:37:50 +00:00
parent 6f2015b73c
commit e28b8370a4
No known key found for this signature in database
4 changed files with 59 additions and 12 deletions

View file

@ -42,6 +42,12 @@ Orbit.plugin('orbit-demo', (orbit, log) => {
run: (args) => orbit.irc.say('\u{1F3B2} ' + (1 + Math.floor(Math.random() * Math.max(2, +args[0] || 6)))),
});
// Register a keyboard shortcut: mod+shift+d fires a notification.
orbit.addShortcut('mod+shift+d', () => orbit.notify('Demo plugin', 'shortcut fired'));
// Register a keyboard shortcut: mod+shift+d opens a modal dialog.
orbit.addShortcut('mod+shift+d', () => {
const close = orbit.modal(
() => orbit.html`<p style="padding:.5rem 0">Hello from the demo plugin.
<button class="upbtn upbtn--primary" onClick=${() => close()}>Close</button></p>`,
{ title: 'Demo plugin' },
);
});
});

View file

@ -6,6 +6,8 @@ import { Avatar } from '../Avatar';
import { SettingsModal } from '../settings/SettingsModal';
import { QuickSwitcher } from '../QuickSwitcher';
import { Shortcuts } from '../Shortcuts';
import { usePluginRegistry } from '../../plugins/registry';
import { PluginBoundary } from '../PluginBoundary';
function Modal({ title, onClose, children, wide }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean }) {
const { t } = useTranslation();
@ -289,15 +291,32 @@ function ReportModal() {
);
}
// A modal opened by a plugin via orbit.modal(): the core owns the shell, the
// plugin owns the body (rendered inside its own error boundary).
function PluginModal() {
const spec = usePluginRegistry((s) => s.modal);
const close = usePluginRegistry((s) => s.closeModal);
if (!spec) return null;
return (
<Modal title={spec.title || ''} wide={spec.wide} onClose={close}>
<PluginBoundary render={spec.render} label="modal" />
</Modal>
);
}
export function Modals() {
const modal = useChat((s) => s.modal);
if (modal === 'join') return <JoinDialog />;
if (modal === 'settings') return <SettingsModal />;
if (modal === 'explore') return <ExploreModal />;
if (modal === 'friends') return <FriendsModal />;
if (modal === 'chanadmin') return <ChanAdminModal />;
if (modal === 'report') return <ReportModal />;
if (modal === 'switcher') return <QuickSwitcher />;
if (modal === 'shortcuts') return <Shortcuts />;
return null;
return (
<>
{modal === 'join' && <JoinDialog />}
{modal === 'settings' && <SettingsModal />}
{modal === 'explore' && <ExploreModal />}
{modal === 'friends' && <FriendsModal />}
{modal === 'chanadmin' && <ChanAdminModal />}
{modal === 'report' && <ReportModal />}
{modal === 'switcher' && <QuickSwitcher />}
{modal === 'shortcuts' && <Shortcuts />}
<PluginModal />
</>
);
}

View file

@ -22,7 +22,7 @@ const THEMES: Theme[] = ['light', 'dark', 'orbit', 'orbit-dark', 'yomirc', 'yomi
// Plugin API contract version. Bumped on a breaking change to the surface below
// so plugins can guard (e.g. `if (Orbit.apiVersion < 2) …`). Still experimental.
const API_VERSION = 4;
const API_VERSION = 5;
const registered = new Map<string, OrbitPluginApi>();
@ -108,6 +108,9 @@ export interface OrbitPluginApi {
/** Register a global keyboard shortcut, e.g. "mod+shift+k" (mod = Cmd/Ctrl).
* Runs in the chat view; built-in shortcuts take priority. */
addShortcut: (combo: string, run: (e: KeyboardEvent) => void) => () => void;
/** Open a modal dialog: `render` fills the body, the core supplies the backdrop,
* title bar and close button. Returns a function that closes it. */
modal: (render: () => ReactNode, opts?: { title?: string; wide?: boolean }) => () => void;
}
function makeApi(name: string): OrbitPluginApi {
@ -171,6 +174,7 @@ function makeApi(name: string): OrbitPluginApi {
addCommand: (cmd, spec) => usePluginRegistry.getState().addCommand(name, cmd, spec.run, spec.help),
notify: (title, body) => pluginNotify(title, body),
addShortcut: (combo, run) => usePluginRegistry.getState().addShortcut(name, combo, run),
modal: (render, opts) => usePluginRegistry.getState().openModal({ plugin: name, render, title: opts?.title, wide: opts?.wide }),
};
}

View file

@ -77,6 +77,15 @@ export interface PluginShortcut {
run: (e: KeyboardEvent) => void;
}
// A plugin-opened modal dialog. `render` fills the body; the core supplies the
// backdrop, title bar and close button. Only one is shown at a time.
export interface PluginModalSpec {
plugin: string;
render: () => ReactNode;
title?: string;
wide?: boolean;
}
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || '');
// True when a keydown event matches a "mod+shift+k"-style combo.
export function matchShortcut(e: KeyboardEvent, combo: string): boolean {
@ -102,6 +111,7 @@ interface RegistryState {
messageFilters: PluginFilter[];
commands: PluginCommand[];
shortcuts: PluginShortcut[];
modal: PluginModalSpec | null;
addUi: (slot: UiSlot, plugin: string, render: () => ReactNode, meta?: PluginUi['meta']) => () => void;
addDecorator: (plugin: string, render: (m: MessageInfo) => ReactNode) => () => void;
addAction: (plugin: string, render: (m: MessageInfo) => ReactNode) => () => void;
@ -109,6 +119,8 @@ interface RegistryState {
addMessageFilter: (plugin: string, fn: (m: FilterableMessage) => boolean) => () => void;
addCommand: (plugin: string, name: string, run: PluginCommand['run'], help?: string) => () => void;
addShortcut: (plugin: string, combo: string, run: PluginShortcut['run']) => () => void;
openModal: (spec: PluginModalSpec) => () => void;
closeModal: () => void;
}
export const usePluginRegistry = create<RegistryState>((set) => ({
@ -119,6 +131,7 @@ export const usePluginRegistry = create<RegistryState>((set) => ({
messageFilters: [],
commands: [],
shortcuts: [],
modal: null,
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 }] }));
@ -155,4 +168,9 @@ export const usePluginRegistry = create<RegistryState>((set) => ({
set((s) => ({ shortcuts: [...s.shortcuts, { id, plugin, combo, run }] }));
return () => set((s) => ({ shortcuts: s.shortcuts.filter((k) => k.id !== id) }));
},
openModal: (spec) => {
set({ modal: spec });
return () => set((s) => (s.modal === spec ? { modal: null } : {}));
},
closeModal: () => set({ modal: null }),
}));