fix: Improve plugin ui entry points, Fix chat scroll, fix notifications, fix user rights

This commit is contained in:
2026-05-17 16:09:16 +02:00
parent 8e3ccf4157
commit 8631290c01
35 changed files with 1560 additions and 619 deletions

View File

@@ -8,18 +8,18 @@ This page maps the app routes and important DOM areas. It is useful for plugin a
## Angular Routes
| Route | Component | Purpose |
| --- | --- | --- |
| `/` | Redirect | Redirects to `/search`. |
| `/login` | `LoginComponent` | User login. |
| `/register` | `RegisterComponent` | User registration. |
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
| `/search` | `ServerSearchComponent` | Search and join servers. |
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
| Route | Component | Purpose |
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
| `/` | Redirect | Redirects to `/search`. |
| `/login` | `LoginComponent` | User login. |
| `/register` | `RegisterComponent` | User registration. |
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
| `/search` | `ServerSearchComponent` | Search and join servers. |
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
## Page Shell
@@ -46,6 +46,7 @@ The server page is the most important page for plugins.
<section>Text Channels</section>
<section>Voice Channels</section>
<section data-testid="plugin-room-side-panel">
<button>View plugins</button>
<app-plugin-render-host></app-plugin-render-host>
</section>
<section>Members</section>
@@ -135,11 +136,11 @@ Prefer plugin APIs over DOM selectors. When direct DOM mounting is necessary, us
Common targets:
| Selector | Area |
| --- | --- |
| `body` | Global overlays or modals. |
| `app-chat-messages` | Main text channel surface. |
| `app-rooms-side-panel` | Server side panel. |
| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar. |
| Selector | Area |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `body` | Global overlays or modals. |
| `app-chat-messages` | Main text channel surface. |
| `app-rooms-side-panel` | Server side panel. |
| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar, including the View plugins trigger for `registerToolbarAction()` tiles. |
Avoid depending on Tailwind utility classes; they are layout details and may change.
Avoid depending on Tailwind utility classes; they are layout details and may change.

View File

@@ -71,18 +71,18 @@ Plugins run only in the renderer. They do not run in Electron main and do not ru
Choose communication APIs like this:
| Need | Use | Notes |
| --- | --- | --- |
| Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. |
| Visible bot-style message | `api.server.registerPluginUser` plus `api.messages.sendAsPluginUser` | Requires `users.manage` and `messages.send`. |
| Plugin state sync between connected clients | `api.messageBus.publish` and `api.messageBus.subscribe` | P2P data-channel envelope, not a visible chat message. |
| Plugin state sync plus recent chat snapshot | `api.messageBus.publish` with `includeLatestMessages` | Also needs `messages.read`. |
| Metadata through signaling server | `api.events.publishServer` and `api.events.subscribeServer` | Event must be declared in manifest. |
| Low-level peer data | `api.p2p.broadcastData` or `api.p2p.sendData` | Prefer message bus for structured topics/subscriptions. |
| Local user preferences | `api.clientData` | User-scoped local storage/database. |
| Local per-server plugin data | `api.serverData` | User-scoped and current-server-scoped local storage/database. |
| App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. |
| Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. |
| Need | Use | Notes |
| ------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
| Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. |
| Visible bot-style message | `api.server.registerPluginUser` plus `api.messages.sendAsPluginUser` | Requires `users.manage` and `messages.send`. |
| Plugin state sync between connected clients | `api.messageBus.publish` and `api.messageBus.subscribe` | P2P data-channel envelope, not a visible chat message. |
| Plugin state sync plus recent chat snapshot | `api.messageBus.publish` with `includeLatestMessages` | Also needs `messages.read`. |
| Metadata through signaling server | `api.events.publishServer` and `api.events.subscribeServer` | Event must be declared in manifest. |
| Low-level peer data | `api.p2p.broadcastData` or `api.p2p.sendData` | Prefer message bus for structured topics/subscriptions. |
| Local user preferences | `api.clientData` | User-scoped local storage/database. |
| Local per-server plugin data | `api.serverData` | User-scoped and current-server-scoped local storage/database. |
| App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. |
| Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. |
## How The App Looks
@@ -122,47 +122,51 @@ Main server page shape:
Important routes:
| Route | Purpose |
| --- | --- |
| `/search` | Search and join servers. |
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
| `/plugin-store` | Browse and install plugins. |
| `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. |
| Route | Purpose |
| ------------------------------- | ------------------------------------------------------------------- |
| `/search` | Search and join servers. |
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
| `/plugin-store` | Browse and install plugins. |
| `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. |
Direct DOM mounting is a last resort. Route-specific targets may not exist when `activate` runs. If `api.ui.mountElement` cannot find the target, it throws `Plugin mount target not found: <selector>` and plugin activation fails.
Stable direct-mount targets when necessary:
| Selector | Area |
| --- | --- |
| `body` | Safest global target for overlays, badges, and modals. It exists during activation. |
| `app-chat-messages` | Main text channel surface. Use only after checking the element exists. |
| Selector | Area |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `body` | Safest global target for overlays, badges, and modals. It exists during activation. |
| `app-chat-messages` | Main text channel surface. Use only after checking the element exists. |
| `app-rooms-side-panel` | Server side panel. Use only after checking the element exists. Prefer `registerSidePanel` for plugin sidebar content. |
Do not mount directly into `[data-testid="plugin-room-side-panel"]`. That area is owned by the plugin side-panel registry and is rendered only on the server page. For server sidebar UI, use:
```js
context.subscriptions.push(api.ui.registerSidePanel('control-panel', {
label: 'Control Panel',
order: 20,
render: () => {
const root = document.createElement('section');
const button = document.createElement('button');
context.subscriptions.push(
api.ui.registerSidePanel('control-panel', {
label: 'Control Panel',
order: 20,
render: () => {
const root = document.createElement('section');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Run Action';
button.addEventListener('click', () => {
api.logger.info('Side-panel action clicked');
});
button.type = 'button';
button.textContent = 'Run Action';
button.addEventListener('click', () => {
api.logger.info('Side-panel action clicked');
});
root.append(button);
return root;
}
}));
root.append(button);
return root;
}
})
);
```
For small command-style plugin entries, use `api.ui.registerToolbarAction()`. Those actions appear as icon tiles in the server side panel's View plugins menu and receive `source: 'toolbarAction'` in their action context.
Do not depend on Tailwind classes or internal styling classes.
## Manifest
@@ -300,10 +304,10 @@ Validation rules:
Scope meanings:
| Scope | Meaning |
| --- | --- |
| `client` or omitted | Installed globally for this local user/client. |
| `server` | Installed for a specific chat server as local client plugin plus server requirement metadata. |
| Scope | Meaning |
| ------------------- | --------------------------------------------------------------------------------------------- |
| `client` or omitted | Installed globally for this local user/client. |
| `server` | Installed for a specific chat server as local client plugin plus server requirement metadata. |
Most generated plugins should use `kind: "client"`. Use `kind: "library"` only for dependency metadata with no executable entrypoint.
@@ -326,10 +330,7 @@ interface TojuClientPluginModule {
ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
onServerRequirementsChanged?: (
context: TojuPluginActivationContext,
snapshot: PluginRequirementsSnapshot
) => Promise<void> | void;
onServerRequirementsChanged?: (context: TojuPluginActivationContext, snapshot: PluginRequirementsSnapshot) => Promise<void> | void;
}
```
@@ -579,9 +580,20 @@ interface ChannelPermissionOverride {
## Full Plugin API Types
```ts
interface PluginApiProfileUpdate { displayName: string; description?: string }
interface PluginApiAvatarUpdate { avatarUrl: string; avatarMime: string; avatarHash: string }
interface PluginApiChannelRequest { name: string; id?: string; position?: number }
interface PluginApiProfileUpdate {
displayName: string;
description?: string;
}
interface PluginApiAvatarUpdate {
avatarUrl: string;
avatarMime: string;
avatarHash: string;
}
interface PluginApiChannelRequest {
name: string;
id?: string;
position?: number;
}
interface PluginApiServerSettingsUpdate {
name?: string;
description?: string;
@@ -590,10 +602,24 @@ interface PluginApiServerSettingsUpdate {
password?: string;
maxUsers?: number;
}
interface PluginApiPluginUserRequest { displayName: string; id?: string; avatarUrl?: string }
interface PluginApiMessageAsPluginUserRequest { pluginUserId: string; content: string; channelId?: string }
interface PluginApiAudioClipRequest { url: string; volume?: number }
interface PluginApiCustomStreamRequest { stream: MediaStream; label?: string }
interface PluginApiPluginUserRequest {
displayName: string;
id?: string;
avatarUrl?: string;
}
interface PluginApiMessageAsPluginUserRequest {
pluginUserId: string;
content: string;
channelId?: string;
}
interface PluginApiAudioClipRequest {
url: string;
volume?: number;
}
interface PluginApiCustomStreamRequest {
stream: MediaStream;
label?: string;
}
type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
interface PluginApiActionContext {
@@ -660,13 +686,41 @@ interface PluginApiMessageBusSubscription {
handler: (event: PluginApiMessageBusEnvelope) => void;
}
interface PluginApiPageContribution { label: string; path: string; render: () => HTMLElement | string }
interface PluginApiSettingsPageContribution { label: string; settingsKey?: string; order?: number; render: () => HTMLElement | string }
interface PluginApiPanelContribution { label: string; order?: number; render: () => HTMLElement | string }
interface PluginApiChannelSectionContribution { label: string; type?: 'audio' | 'video' | 'custom'; order?: number }
interface PluginApiActionContribution { label: string; icon?: string; run: (context: PluginApiActionContext) => Promise<void> | void }
interface PluginApiEmbedRendererContribution { embedType: string; render: (payload: unknown) => HTMLElement | string }
interface PluginApiDomMountRequest { target: Element | string; element: HTMLElement; position?: InsertPosition }
interface PluginApiPageContribution {
label: string;
path: string;
render: () => HTMLElement | string;
}
interface PluginApiSettingsPageContribution {
label: string;
settingsKey?: string;
order?: number;
render: () => HTMLElement | string;
}
interface PluginApiPanelContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
}
interface PluginApiChannelSectionContribution {
label: string;
type?: 'audio' | 'video' | 'custom';
order?: number;
}
interface PluginApiActionContribution {
label: string;
icon?: string;
run: (context: PluginApiActionContext) => Promise<void> | void;
}
interface PluginApiEmbedRendererContribution {
embedType: string;
render: (payload: unknown) => HTMLElement | string;
}
interface PluginApiDomMountRequest {
target: Element | string;
element: HTMLElement;
position?: InsertPosition;
}
interface TojuClientPluginApi {
readonly context: { getCurrent: () => PluginApiActionContext };
@@ -890,10 +944,7 @@ Capabilities: `messages.read`, `messages.send`, `messages.editOwn`, `messages.de
```js
const visibleMessages = api.messages.readCurrent();
const sent = api.messages.send(
'Build completed successfully. Docs are ready for review.',
'general'
);
const sent = api.messages.send('Build completed successfully. Docs are ready for review.', 'general');
api.messages.edit(sent.id, 'Build completed successfully. Docs and plugin examples are ready.');
api.messages.delete(sent.id);
@@ -1115,88 +1166,110 @@ Desktop uses Electron's local database when available, with renderer localStorag
Capabilities:
| Method | Required capability |
| --- | --- |
| `registerAppPage` | `ui.pages` |
| `registerSettingsPage` | `ui.settings` |
| `registerSidePanel` | `ui.sidePanel` |
| Method | Required capability |
| ------------------------ | -------------------- |
| `registerAppPage` | `ui.pages` |
| `registerSettingsPage` | `ui.settings` |
| `registerSidePanel` | `ui.sidePanel` |
| `registerChannelSection` | `ui.channelsSection` |
| `registerComposerAction` | `ui.pages` |
| `registerProfileAction` | `ui.pages` |
| `registerToolbarAction` | `ui.pages` |
| `registerEmbedRenderer` | `ui.embeds` |
| `mountElement` | `ui.dom` |
| `registerComposerAction` | `ui.pages` |
| `registerProfileAction` | `ui.pages` |
| `registerToolbarAction` | `ui.pages` |
| `registerEmbedRenderer` | `ui.embeds` |
| `mountElement` | `ui.dom` |
Register side panel:
```js
context.subscriptions.push(api.ui.registerSidePanel('summary', {
label: 'Plugin Summary',
order: 10,
render: () => {
const root = document.createElement('aside');
const heading = document.createElement('h2');
const text = document.createElement('p');
context.subscriptions.push(
api.ui.registerSidePanel('summary', {
label: 'Plugin Summary',
order: 10,
render: () => {
const root = document.createElement('aside');
const heading = document.createElement('h2');
const text = document.createElement('p');
heading.textContent = 'Plugin Summary';
text.textContent = 'No active tasks.';
root.append(heading, text);
return root;
}
}));
heading.textContent = 'Plugin Summary';
text.textContent = 'No active tasks.';
root.append(heading, text);
return root;
}
})
);
```
Use `registerSidePanel` for content that belongs in the server sidebar plugin area. Do not query `[data-testid="plugin-room-side-panel"]` and pass it to `mountElement`; that route-specific host may not exist while the plugin activates.
Register toolbar action for the View plugins menu:
```js
context.subscriptions.push(
api.ui.registerToolbarAction('quick-status', {
icon: 'QS',
label: 'Quick Status',
run: (actionContext) => {
api.logger.info('Quick Status clicked', {
serverId: actionContext.server?.id,
source: actionContext.source
});
}
})
);
```
Register app page:
```js
context.subscriptions.push(api.ui.registerAppPage('dashboard', {
label: 'Build Dashboard',
path: '/plugins/example.build-dashboard/dashboard',
render: () => {
const root = document.createElement('section');
const title = document.createElement('h1');
const button = document.createElement('button');
const output = document.createElement('p');
context.subscriptions.push(
api.ui.registerAppPage('dashboard', {
label: 'Build Dashboard',
path: '/plugins/example.build-dashboard/dashboard',
render: () => {
const root = document.createElement('section');
const title = document.createElement('h1');
const button = document.createElement('button');
const output = document.createElement('p');
title.textContent = 'Build Dashboard';
button.type = 'button';
button.textContent = 'Send status';
output.textContent = 'Idle.';
title.textContent = 'Build Dashboard';
button.type = 'button';
button.textContent = 'Send status';
output.textContent = 'Idle.';
button.addEventListener('click', () => {
const message = api.messages.send('Build dashboard status: ready.');
output.textContent = `Sent message ${message.id}`;
});
button.addEventListener('click', () => {
const message = api.messages.send('Build dashboard status: ready.');
output.textContent = `Sent message ${message.id}`;
});
root.append(title, button, output);
return root;
}
}));
root.append(title, button, output);
return root;
}
})
);
```
Register actions:
```js
context.subscriptions.push(api.ui.registerComposerAction('insert-template', {
label: 'Insert Template',
icon: 'file-text',
run: (actionContext) => {
api.messages.send(
'Template: Please review the latest build notes.',
actionContext.textChannel?.id
);
}
}));
context.subscriptions.push(
api.ui.registerComposerAction('insert-template', {
label: 'Insert Template',
icon: 'file-text',
run: (actionContext) => {
api.messages.send('Template: Please review the latest build notes.', actionContext.textChannel?.id);
}
})
);
context.subscriptions.push(api.ui.registerToolbarAction('post-standup', {
label: 'Post Standup',
icon: 'megaphone',
run: () => {
api.messages.send('Standup starts now. Join the voice channel when ready.');
}
}));
context.subscriptions.push(
api.ui.registerToolbarAction('post-standup', {
label: 'Post Standup',
icon: 'megaphone',
run: () => {
api.messages.send('Standup starts now. Join the voice channel when ready.');
}
})
);
```
Mount DOM directly:
@@ -1210,11 +1283,13 @@ banner.textContent = 'Plugin banner mounted in chat messages.';
const target = document.querySelector('app-chat-messages');
if (target) {
context.subscriptions.push(api.ui.mountElement('chat-banner', {
target,
element: banner,
position: 'afterbegin'
}));
context.subscriptions.push(
api.ui.mountElement('chat-banner', {
target,
element: banner,
position: 'afterbegin'
})
);
}
```
@@ -1224,56 +1299,58 @@ Global overlay example:
const badge = document.createElement('div');
badge.textContent = 'Plugin active';
context.subscriptions.push(api.ui.mountElement('global-badge', {
target: 'body',
element: badge,
position: 'beforeend'
}));
context.subscriptions.push(
api.ui.mountElement('global-badge', {
target: 'body',
element: badge,
position: 'beforeend'
})
);
```
`mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload.
## Capability Cheat Sheet
| API call group | Capabilities |
| --- | --- |
| `profile.getCurrent` | `profile.read` |
| `profile.update`, `profile.updateAvatar` | `profile.write` |
| `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` |
| `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` |
| `roles.list` | `roles.read` |
| `users.setRole`, `roles.setAssignments` | `roles.manage` |
| `server.getCurrent` | `server.read` |
| `server.updatePermissions`, `server.updateSettings` | `server.manage` |
| `channels.list`, `channels.select` | `channels.read` |
| `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` |
| `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` |
| `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` |
| `messages.edit` | `messages.editOwn` |
| `messages.delete` | `messages.deleteOwn` |
| `messages.moderateDelete` | `messages.moderate` |
| `messages.sync` | `messages.sync` |
| `events.publishServer` | `events.server.publish` |
| `events.subscribeServer` | `events.server.subscribe` |
| `events.publishP2p` | `events.p2p.publish` |
| `events.subscribeP2p` | `events.p2p.subscribe` |
| `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true |
| `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` |
| `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true |
| `p2p.*` | `p2p.data` |
| `media.playAudioClip` | `media.playAudio` |
| `media.addCustomAudioStream` | `media.addAudioStream` |
| `media.addCustomVideoStream` | `media.addVideoStream` |
| `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` |
| `clientData.*`, `storage.*` | `storage.local` |
| `serverData.read` | `storage.serverData.read` |
| `serverData.write`, `serverData.remove` | `storage.serverData.write` |
| `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` |
| `ui.registerSettingsPage` | `ui.settings` |
| `ui.registerSidePanel` | `ui.sidePanel` |
| `ui.registerChannelSection` | `ui.channelsSection` |
| `ui.registerEmbedRenderer` | `ui.embeds` |
| `ui.mountElement` | `ui.dom` |
| API call group | Capabilities |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `profile.getCurrent` | `profile.read` |
| `profile.update`, `profile.updateAvatar` | `profile.write` |
| `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` |
| `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` |
| `roles.list` | `roles.read` |
| `users.setRole`, `roles.setAssignments` | `roles.manage` |
| `server.getCurrent` | `server.read` |
| `server.updatePermissions`, `server.updateSettings` | `server.manage` |
| `channels.list`, `channels.select` | `channels.read` |
| `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` |
| `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` |
| `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` |
| `messages.edit` | `messages.editOwn` |
| `messages.delete` | `messages.deleteOwn` |
| `messages.moderateDelete` | `messages.moderate` |
| `messages.sync` | `messages.sync` |
| `events.publishServer` | `events.server.publish` |
| `events.subscribeServer` | `events.server.subscribe` |
| `events.publishP2p` | `events.p2p.publish` |
| `events.subscribeP2p` | `events.p2p.subscribe` |
| `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true |
| `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` |
| `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true |
| `p2p.*` | `p2p.data` |
| `media.playAudioClip` | `media.playAudio` |
| `media.addCustomAudioStream` | `media.addAudioStream` |
| `media.addCustomVideoStream` | `media.addVideoStream` |
| `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` |
| `clientData.*`, `storage.*` | `storage.local` |
| `serverData.read` | `storage.serverData.read` |
| `serverData.write`, `serverData.remove` | `storage.serverData.write` |
| `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` |
| `ui.registerSettingsPage` | `ui.settings` |
| `ui.registerSidePanel` | `ui.sidePanel` |
| `ui.registerChannelSection` | `ui.channelsSection` |
| `ui.registerEmbedRenderer` | `ui.embeds` |
| `ui.mountElement` | `ui.dom` |
## Complete Example Plugin
@@ -1319,25 +1396,31 @@ export function activate(context) {
api.logger.info('Voice Notes activated');
context.subscriptions.push(api.messageBus.subscribe({
topic: BUS_TOPIC,
replayLatest: false,
handler: (event) => {
api.logger.debug('Received voice notes draft update', event.payload);
}
}));
context.subscriptions.push(
api.messageBus.subscribe({
topic: BUS_TOPIC,
replayLatest: false,
handler: (event) => {
api.logger.debug('Received voice notes draft update', event.payload);
}
})
);
context.subscriptions.push(api.ui.registerSidePanel('voice-notes-panel', {
label: 'Voice Notes',
order: 20,
render: () => renderPanel(context)
}));
context.subscriptions.push(
api.ui.registerSidePanel('voice-notes-panel', {
label: 'Voice Notes',
order: 20,
render: () => renderPanel(context)
})
);
context.subscriptions.push(api.ui.registerAppPage('voice-notes', {
label: 'Voice Notes',
path: '/plugins/example.voice-notes/voice-notes',
render: () => renderPanel(context)
}));
context.subscriptions.push(
api.ui.registerAppPage('voice-notes', {
label: 'Voice Notes',
path: '/plugins/example.voice-notes/voice-notes',
render: () => renderPanel(context)
})
);
}
function renderPanel(context) {
@@ -1352,9 +1435,7 @@ function renderPanel(context) {
const current = api.context.getCurrent();
heading.textContent = 'Voice Notes';
meta.textContent = current.voiceChannel
? `Connected to ${current.voiceChannel.name}`
: 'Not connected to a voice channel.';
meta.textContent = current.voiceChannel ? `Connected to ${current.voiceChannel.name}` : 'Not connected to a voice channel.';
textarea.rows = 6;
textarea.placeholder = 'Write notes from the current voice session.';
save.type = 'button';
@@ -1363,16 +1444,19 @@ function renderPanel(context) {
post.textContent = 'Post Notes';
status.textContent = 'Loading draft...';
void api.serverData.read(DRAFT_KEY).then((value) => {
if (value && typeof value === 'object' && typeof value.text === 'string') {
textarea.value = value.text;
}
void api.serverData
.read(DRAFT_KEY)
.then((value) => {
if (value && typeof value === 'object' && typeof value.text === 'string') {
textarea.value = value.text;
}
status.textContent = 'Draft loaded.';
}).catch((error) => {
api.logger.warn('Could not load voice notes draft', error);
status.textContent = 'Could not load draft.';
});
status.textContent = 'Draft loaded.';
})
.catch((error) => {
api.logger.warn('Could not load voice notes draft', error);
status.textContent = 'Could not load draft.';
});
save.addEventListener('click', async () => {
const draft = {

View File

@@ -60,24 +60,24 @@ interface PluginApiAvatarUpdate {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. |
| `profile.update(profile)` | `profile.write` | Updates display name and optional description. |
| Method | Capability | Description |
| ------------------------------ | --------------- | ------------------------------------------------- |
| `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. |
| `profile.update(profile)` | `profile.write` | Updates display name and optional description. |
| `profile.updateAvatar(avatar)` | `profile.write` | Updates avatar URL, MIME type, and hash metadata. |
## Users and Roles
| Method | Capability | Description |
| --- | --- | --- |
| `users.getCurrent()` | `users.read` | Returns current `User` or `null`. |
| `users.list()` | `users.read` | Returns known users. |
| `users.readMembers()` | `users.read` | Returns active room members. |
| `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. |
| `users.kick(userId)` | `users.manage` | Kicks a user. |
| `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. |
| `roles.list()` | `roles.read` | Returns room roles. |
| `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. |
| Method | Capability | Description |
| ----------------------------------- | -------------- | --------------------------------- |
| `users.getCurrent()` | `users.read` | Returns current `User` or `null`. |
| `users.list()` | `users.read` | Returns known users. |
| `users.readMembers()` | `users.read` | Returns active room members. |
| `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. |
| `users.kick(userId)` | `users.manage` | Kicks a user. |
| `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. |
| `roles.list()` | `roles.read` | Returns room roles. |
| `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. |
## Server
@@ -98,12 +98,12 @@ interface PluginApiPluginUserRequest {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. |
| `server.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. |
| `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. |
| `server.updateSettings(settings)` | `server.manage` | Updates room settings. |
| Method | Capability | Description |
| --------------------------------------- | --------------- | -------------------------------------------- |
| `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. |
| `server.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. |
| `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. |
| `server.updateSettings(settings)` | `server.manage` | Updates room settings. |
## Channels
@@ -115,14 +115,14 @@ interface PluginApiChannelRequest {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `channels.list()` | `channels.read` | Returns current room channels. |
| `channels.select(channelId)` | `channels.read` | Selects a channel. |
| `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. |
| Method | Capability | Description |
| ----------------------------------- | ----------------- | ---------------------------------- |
| `channels.list()` | `channels.read` | Returns current room channels. |
| `channels.select(channelId)` | `channels.read` | Selects a channel. |
| `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. |
| `channels.addVideoChannel(request)` | `channels.manage` | Registers a video channel section. |
| `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. |
| `channels.remove(channelId)` | `channels.manage` | Removes a channel. |
| `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. |
| `channels.remove(channelId)` | `channels.manage` | Removes a channel. |
## Messages
@@ -134,17 +134,17 @@ interface PluginApiMessageAsPluginUserRequest {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `messages.readCurrent()` | `messages.read` | Returns current visible messages. |
| `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. |
| `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. |
| `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. |
| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. |
| `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. |
| `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. |
| `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. |
| `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. |
| Method | Capability | Description |
| ------------------------------------------ | -------------------- | -------------------------------------------------- |
| `messages.readCurrent()` | `messages.read` | Returns current visible messages. |
| `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. |
| `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. |
| `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. |
| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. |
| `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. |
| `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. |
| `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. |
| `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. |
## Events
@@ -167,12 +167,12 @@ interface PluginEventEnvelope<TPayload = unknown> {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. |
| `events.subscribeServer(subscription)` | `events.server.subscribe` | Subscribes to a declared server plugin event. |
| `events.publishP2p(eventName, payload)` | `events.p2p.publish` | Sends a declared plugin event over peer paths. |
| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. |
| Method | Capability | Description |
| ------------------------------------------ | ------------------------- | ----------------------------------------------------------- |
| `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. |
| `events.subscribeServer(subscription)` | `events.server.subscribe` | Subscribes to a declared server plugin event. |
| `events.publishP2p(eventName, payload)` | `events.p2p.publish` | Sends a declared plugin event over peer paths. |
| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. |
## Message Bus
@@ -215,11 +215,11 @@ interface PluginApiMessageBusSubscription {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. |
| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | Sends a latest-message snapshot. |
| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. |
| Method | Capability | Description |
| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
| `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. |
| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | Sends a latest-message snapshot. |
| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. |
## P2P and Media
@@ -235,30 +235,30 @@ interface PluginApiCustomStreamRequest {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. |
| `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. |
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. |
| `media.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. |
| `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. |
| `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. |
| `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. |
| `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. |
| Method | Capability | Description |
| ------------------------------------------ | ---------------------- | --------------------------------------------- |
| `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. |
| `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. |
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. |
| `media.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. |
| `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. |
| `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. |
| `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. |
| `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. |
## Storage
| Method | Capability | Description |
| --- | --- | --- |
| `clientData.read(key)` | `storage.local` | Reads async plugin-local data. |
| `clientData.write(key, value)` | `storage.local` | Writes async plugin-local data. |
| `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. |
| `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server data. |
| `serverData.write(key, value)` | `storage.serverData.write` | Writes local per-user/per-server data. |
| `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. |
| `storage.get(key)` | `storage.local` | Legacy synchronous local read. |
| `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. |
| `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. |
| Method | Capability | Description |
| ------------------------------ | -------------------------- | --------------------------------------- |
| `clientData.read(key)` | `storage.local` | Reads async plugin-local data. |
| `clientData.write(key, value)` | `storage.local` | Writes async plugin-local data. |
| `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. |
| `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server data. |
| `serverData.write(key, value)` | `storage.serverData.write` | Writes local per-user/per-server data. |
| `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. |
| `storage.get(key)` | `storage.local` | Legacy synchronous local read. |
| `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. |
| `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. |
## UI Contributions
@@ -306,24 +306,24 @@ interface PluginApiDomMountRequest {
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds a toolbar action. |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. |
| `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. |
| Method | Capability | Description |
| --------------------------------------------- | -------------------- | --------------------------------------------------------------- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds an action tile to the server side panel View plugins menu. |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. |
| `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. |
## Context and Logger
| Method | Capability | Description |
| --- | --- | --- |
| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. |
| `logger.debug(message, data?)` | None | Writes a debug plugin log entry. |
| `logger.info(message, data?)` | None | Writes an info plugin log entry. |
| `logger.warn(message, data?)` | None | Writes a warning plugin log entry. |
| `logger.error(message, data?)` | None | Writes an error plugin log entry. |
| Method | Capability | Description |
| ------------------------------ | ---------- | -------------------------------------------------------------------------- |
| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. |
| `logger.debug(message, data?)` | None | Writes a debug plugin log entry. |
| `logger.info(message, data?)` | None | Writes an info plugin log entry. |
| `logger.warn(message, data?)` | None | Writes a warning plugin log entry. |
| `logger.error(message, data?)` | None | Writes an error plugin log entry. |

View File

@@ -37,21 +37,23 @@ Example context shape:
## Action Context
Composer, toolbar, and profile actions receive context directly.
Composer, toolbar, and profile actions receive context directly. Toolbar actions are launched from the server side panel's View plugins menu and report `source: 'toolbarAction'`.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('where-am-i', {
label: 'Where am I?',
run: (actionContext) => {
context.api.logger.info('Toolbar action context', {
source: actionContext.source,
serverId: actionContext.server?.id,
textChannelId: actionContext.textChannel?.id,
voiceChannelId: actionContext.voiceChannel?.id
});
}
}));
context.subscriptions.push(
context.api.ui.registerToolbarAction('where-am-i', {
label: 'Where am I?',
run: (actionContext) => {
context.api.logger.info('Toolbar action context', {
source: actionContext.source,
serverId: actionContext.server?.id,
textChannelId: actionContext.textChannel?.id,
voiceChannelId: actionContext.voiceChannel?.id
});
}
})
);
}
```
@@ -70,4 +72,4 @@ export function activate(context) {
}
```
Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents.
Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents.

View File

@@ -10,17 +10,17 @@ Prefer registered UI contributions over direct DOM mounting. Contribution APIs l
## Required Capabilities
| Method | Capability |
| --- | --- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` |
| Method | Capability |
| --------------------------------------------- | -------------------- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` |
| `ui.mountElement(id, request)` | `ui.dom` |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` |
| `ui.mountElement(id, request)` | `ui.dom` |
Every registration returns a disposable. Push it into `context.subscriptions`.
@@ -28,15 +28,17 @@ Every registration returns a disposable. Push it into `context.subscriptions`.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerAppPage('dashboard', {
label: 'Raid Dashboard',
path: '/plugins/example.raid-helper/dashboard',
render: () => {
const root = document.createElement('section');
root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>';
return root;
}
}));
context.subscriptions.push(
context.api.ui.registerAppPage('dashboard', {
label: 'Raid Dashboard',
path: '/plugins/example.raid-helper/dashboard',
render: () => {
const root = document.createElement('section');
root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>';
return root;
}
})
);
}
```
@@ -46,22 +48,24 @@ The page is hosted by `/plugins/:pluginId/:pageId`.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerSettingsPage('preferences', {
label: 'Raid Helper',
settingsKey: 'raid-helper',
order: 20,
render: () => {
const wrapper = document.createElement('section');
const label = document.createElement('label');
const checkbox = document.createElement('input');
context.subscriptions.push(
context.api.ui.registerSettingsPage('preferences', {
label: 'Raid Helper',
settingsKey: 'raid-helper',
order: 20,
render: () => {
const wrapper = document.createElement('section');
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
label.append(checkbox, ' Enable ready-check reminders');
wrapper.append(label);
return wrapper;
}
}));
checkbox.type = 'checkbox';
checkbox.checked = true;
label.append(checkbox, ' Enable ready-check reminders');
wrapper.append(label);
return wrapper;
}
})
);
}
```
@@ -71,23 +75,26 @@ Use `ui.registerSidePanel` for content that belongs in the server sidebar plugin
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerSidePanel('soundboard', {
label: 'Soundboard',
order: 10,
render: () => {
const panel = document.createElement('div');
const button = document.createElement('button');
context.subscriptions.push(
context.api.ui.registerSidePanel('soundboard', {
label: 'Soundboard',
order: 10,
render: () => {
const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Play chime';
button.onclick = () => context.api.media.playAudioClip({
url: 'https://cdn.example.com/chime.wav',
volume: 0.6
});
panel.append(button);
return panel;
}
}));
button.type = 'button';
button.textContent = 'Play chime';
button.onclick = () =>
context.api.media.playAudioClip({
url: 'https://cdn.example.com/chime.wav',
volume: 0.6
});
panel.append(button);
return panel;
}
})
);
}
```
@@ -97,11 +104,13 @@ Capabilities required: `ui.sidePanel` and `media.playAudio`.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerChannelSection('events', {
label: 'Event Rooms',
type: 'custom',
order: 50
}));
context.subscriptions.push(
context.api.ui.registerChannelSection('events', {
label: 'Event Rooms',
type: 'custom',
order: 50
})
);
}
```
@@ -109,16 +118,15 @@ export function activate(context) {
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerComposerAction('insert-standup', {
icon: 'ST',
label: 'Insert standup prompt',
run: (actionContext) => {
context.api.messages.send(
'Standup: yesterday I..., today I..., blocked by...',
actionContext.textChannel?.id
);
}
}));
context.subscriptions.push(
context.api.ui.registerComposerAction('insert-standup', {
icon: 'ST',
label: 'Insert standup prompt',
run: (actionContext) => {
context.api.messages.send('Standup: yesterday I..., today I..., blocked by...', actionContext.textChannel?.id);
}
})
);
}
```
@@ -128,45 +136,65 @@ Capabilities required: `ui.pages` and `messages.send`.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerProfileAction('wave', {
label: 'Wave',
run: (actionContext) => {
context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`);
}
}));
context.subscriptions.push(
context.api.ui.registerProfileAction('wave', {
label: 'Wave',
run: (actionContext) => {
context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`);
}
})
);
}
```
## Toolbar Action
Toolbar actions are command-style plugin entries shown in the server side panel's View plugins menu. Use them for small actions that should be easy to launch from a server, such as opening a plugin page, sending a status message, starting a timer, or toggling a plugin feature.
The View plugins link appears in `[data-testid="plugin-room-side-panel"]` when the plugin side-panel area is rendered. Opening it shows an overlay menu, positioned like profile-card overlays, with registered actions laid out as plugin icon tiles. The `icon` field can be short text such as `RH`, an emoji, or an image URL; when omitted, MetoYou falls back to initials from the plugin/action labels.
Toolbar action callbacks receive an action context with `source: 'toolbarAction'`, the current user, current server, active text channel, and current voice channel when available.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('open-dashboard', {
label: 'Raid Helper',
run: () => {
context.api.logger.info('Open the Raid Helper plugin page from /plugins/example.raid-helper/dashboard');
}
}));
context.subscriptions.push(
context.api.ui.registerToolbarAction('open-dashboard', {
icon: 'RH',
label: 'Raid Helper',
run: (actionContext) => {
context.api.logger.info('Raid Helper opened', {
channelId: actionContext.textChannel?.id,
serverId: actionContext.server?.id
});
}
})
);
}
```
Capabilities required: `ui.pages`. Add any capability your action uses, such as `messages.send` or `server.read`.
Use `registerSidePanel` instead when the plugin needs persistent sidebar content, and use `registerAppPage` when the plugin needs a full-page workflow.
## Embed Renderer
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerEmbedRenderer('raid-card', {
embedType: 'raid.card',
render: (payload) => {
const card = document.createElement('article');
const title = document.createElement('h3');
const body = document.createElement('p');
context.subscriptions.push(
context.api.ui.registerEmbedRenderer('raid-card', {
embedType: 'raid.card',
render: (payload) => {
const card = document.createElement('article');
const title = document.createElement('h3');
const body = document.createElement('p');
title.textContent = payload?.title ?? 'Raid';
body.textContent = payload?.description ?? 'No description provided.';
card.append(title, body);
return card;
}
}));
title.textContent = payload?.title ?? 'Raid';
body.textContent = payload?.description ?? 'No description provided.';
card.append(title, body);
return card;
}
})
);
}
```
@@ -202,11 +230,13 @@ export function activate(context) {
badge.style.color = 'white';
badge.style.borderRadius = '6px';
context.subscriptions.push(context.api.ui.mountElement('active-badge', {
target: 'body',
position: 'beforeend',
element: badge
}));
context.subscriptions.push(
context.api.ui.mountElement('active-badge', {
target: 'body',
position: 'beforeend',
element: badge
})
);
}
```
@@ -224,12 +254,14 @@ export function activate(context) {
const banner = document.createElement('div');
banner.textContent = 'Raid helper active in this chat.';
context.subscriptions.push(context.api.ui.mountElement('chat-banner', {
target,
position: 'afterbegin',
element: banner
}));
context.subscriptions.push(
context.api.ui.mountElement('chat-banner', {
target,
position: 'afterbegin',
element: banner
})
);
}
```
The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible.
The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible.

View File

@@ -6,44 +6,44 @@ sidebar_position: 3
Capabilities protect privileged app surfaces. A plugin must declare a capability in its manifest and the user must grant it before the runtime allows the corresponding API call.
| Capability | API areas | Notes |
| --- | --- | --- |
| `profile.read` | `profile.getCurrent()` | Reads the current user. |
| `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. |
| `users.read` | `users.getCurrent()`, `users.list()`, `users.readMembers()` | Reads users and server members. |
| `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. |
| `roles.read` | `roles.list()` | Reads server roles. |
| `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user roles. |
| `messages.read` | `messages.readCurrent()`, message bus latest snapshots | Reads current channel messages. |
| `messages.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. |
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. |
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
| `server.read` | `server.getCurrent()` | Reads active server. |
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. |
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
| `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. |
| `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. |
| `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. |
| `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. |
| `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. |
| `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and actions. |
| `ui.sidePanel` | `ui.registerSidePanel()` | Adds side panels. |
| `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. |
| `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. |
| `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. |
| `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. |
| `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. |
| `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. |
| `events.server.publish` | `events.publishServer()` | Publishes declared server plugin events. |
| `events.server.subscribe` | `events.subscribeServer()` | Subscribes to declared server plugin events. |
| `events.p2p.publish` | `events.publishP2p()`, `messageBus.publish()`, `messageBus.sendLatestMessages()` | Publishes declared P2P/plugin bus events. |
| `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. |
| Capability | API areas | Notes |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `profile.read` | `profile.getCurrent()` | Reads the current user. |
| `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. |
| `users.read` | `users.getCurrent()`, `users.list()`, `users.readMembers()` | Reads users and server members. |
| `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. |
| `roles.read` | `roles.list()` | Reads server roles. |
| `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user roles. |
| `messages.read` | `messages.readCurrent()`, message bus latest snapshots | Reads current channel messages. |
| `messages.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. |
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. |
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
| `server.read` | `server.getCurrent()` | Reads active server. |
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. |
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
| `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. |
| `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. |
| `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. |
| `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. |
| `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. |
| `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and action entry points, including View plugins menu actions. |
| `ui.sidePanel` | `ui.registerSidePanel()` | Adds side panels. |
| `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. |
| `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. |
| `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. |
| `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. |
| `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. |
| `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. |
| `events.server.publish` | `events.publishServer()` | Publishes declared server plugin events. |
| `events.server.subscribe` | `events.subscribeServer()` | Subscribes to declared server plugin events. |
| `events.p2p.publish` | `events.publishP2p()`, `messageBus.publish()`, `messageBus.sendLatestMessages()` | Publishes declared P2P/plugin bus events. |
| `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. |
## Recommended Practice

View File

@@ -27,7 +27,7 @@ The manifest file can be named `toju-plugin.json` or `plugin.json`. Entrypoints
"schemaVersion": 1,
"id": "example.hello-world",
"title": "Hello World",
"description": "Adds a toolbar action that sends a message.",
"description": "Adds a View plugins menu action that sends a message.",
"version": "1.0.0",
"kind": "client",
"scope": "client",
@@ -49,6 +49,7 @@ export function activate(context) {
api.logger.info('Hello World activated');
const disposable = api.ui.registerToolbarAction('hello', {
icon: 'HI',
label: 'Hello',
run: () => api.messages.send('Hello from my plugin')
});
@@ -65,15 +66,17 @@ export function deactivate(context) {
}
```
`registerToolbarAction()` adds an action tile to the server side panel's View plugins menu. Use `icon` for the tile badge and keep the `label` short enough to scan in a grid.
## Lifecycle Hooks
| Hook | When it runs | Use it for |
| --- | --- | --- |
| `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. |
| `ready(context)` | After the load-order pass has activated ready plugins. | Cross-plugin coordination that needs other plugins loaded. |
| `deactivate(context)` | During unload or reload. | Flush state and log shutdown. Disposables are also cleaned up by the host. |
| `onPluginDataChanged(context, event)` | When plugin data changes are observed. | React to plugin-scoped persistence changes. |
| `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. |
| Hook | When it runs | Use it for |
| ------------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------- |
| `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. |
| `ready(context)` | After the load-order pass has activated ready plugins. | Cross-plugin coordination that needs other plugins loaded. |
| `deactivate(context)` | During unload or reload. | Flush state and log shutdown. Disposables are also cleaned up by the host. |
| `onPluginDataChanged(context, event)` | When plugin data changes are observed. | React to plugin-scoped persistence changes. |
| `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. |
## Cleanup

View File

@@ -13,7 +13,7 @@ sidebar_position: 5
"schemaVersion": 1,
"id": "example.toolbar-message",
"title": "Toolbar Message",
"description": "Adds a toolbar action that sends a reusable message.",
"description": "Adds a View plugins menu action that sends a reusable message.",
"version": "1.0.0",
"kind": "client",
"scope": "client",
@@ -33,13 +33,18 @@ sidebar_position: 5
export function activate(context) {
const { api } = context;
context.subscriptions.push(api.ui.registerToolbarAction('standup-message', {
label: 'Standup',
run: () => api.messages.send('Standup: yesterday, today, blocked')
}));
context.subscriptions.push(
api.ui.registerToolbarAction('standup-message', {
icon: 'ST',
label: 'Standup',
run: (actionContext) => api.messages.send('Standup: yesterday, today, blocked', actionContext.textChannel?.id)
})
);
}
```
The action appears as a tile in the server side panel's View plugins menu and runs with `source: 'toolbarAction'`.
## Settings Page Plugin
```json
@@ -67,19 +72,21 @@ export function activate(context) {
export function activate(context) {
const { api } = context;
context.subscriptions.push(api.ui.registerSettingsPage('preferences', {
label: 'Example Preferences',
render: () => {
const root = document.createElement('section');
const button = document.createElement('button');
context.subscriptions.push(
api.ui.registerSettingsPage('preferences', {
label: 'Example Preferences',
render: () => {
const root = document.createElement('section');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Remember preference';
button.onclick = () => api.storage.set('enabled', true);
root.append(button);
return root;
}
}));
button.type = 'button';
button.textContent = 'Remember preference';
button.onclick = () => api.storage.set('enabled', true);
root.append(button);
return root;
}
})
);
}
```
@@ -99,13 +106,7 @@ A server-scoped plugin can be installed as a server requirement and auto-install
"apiVersion": "1.0.0",
"compatibility": { "minimumTojuVersion": "1.0.0" },
"entrypoint": "./main.js",
"capabilities": [
"server.read",
"users.manage",
"ui.sidePanel",
"media.playAudio",
"messages.send"
],
"capabilities": ["server.read", "users.manage", "ui.sidePanel", "media.playAudio", "messages.send"],
"pluginUser": {
"displayName": "Soundboard",
"label": "Audio helper"
@@ -121,23 +122,25 @@ export function activate(context) {
displayName: 'Soundboard'
});
context.subscriptions.push(api.ui.registerSidePanel('sounds', {
label: 'Soundboard',
render: () => {
const panel = document.createElement('div');
const button = document.createElement('button');
context.subscriptions.push(
api.ui.registerSidePanel('sounds', {
label: 'Soundboard',
render: () => {
const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Play chime';
button.onclick = async () => {
await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 });
api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' });
};
button.type = 'button';
button.textContent = 'Play chime';
button.onclick = async () => {
await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 });
api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' });
};
panel.append(button);
return panel;
}
}));
panel.append(button);
return panel;
}
})
);
}
```
@@ -162,12 +165,14 @@ export function activate(context) {
export function activate(context) {
const { api } = context;
context.subscriptions.push(api.messageBus.subscribe({
topic: 'poll:votes',
replayLatest: true,
latestMessageLimit: 20,
handler: (event) => api.logger.info('Vote received', event.payload)
}));
context.subscriptions.push(
api.messageBus.subscribe({
topic: 'poll:votes',
replayLatest: true,
latestMessageLimit: 20,
handler: (event) => api.logger.info('Vote received', event.payload)
})
);
api.messageBus.publish({
topic: 'poll:votes',
@@ -192,10 +197,12 @@ export function activate(context) {
badge.style.right = '1rem';
badge.style.bottom = '1rem';
context.subscriptions.push(context.api.ui.mountElement('active-badge', {
target: 'body',
element: badge
}));
context.subscriptions.push(
context.api.ui.mountElement('active-badge', {
target: 'body',
element: badge
})
);
}
```

View File

@@ -8,11 +8,11 @@ Plugins add features to MetoYou. They can add pages, buttons, panels, settings,
## Types of Plugins
| Type | What it means |
| --- | --- |
| Client plugin | Installed for your app. It follows you across servers when active. |
| Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. |
| Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. |
| Type | What it means |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| Client plugin | Installed for your app. It follows you across servers when active. |
| Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. |
| Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. |
## Install from the Plugin Store
@@ -26,6 +26,10 @@ Plugins add features to MetoYou. They can add pages, buttons, panels, settings,
Server-scoped plugins installed to the server you are currently viewing are enabled and activated automatically after install, so their panels, actions, or embeds can appear immediately.
## Use Plugin Actions
When plugins add quick actions to a server, the server side panel shows a View plugins link in the plugin area. Open it to see a grid of plugin action tiles. Selecting a tile runs that plugin's action in the current server and channel context.
## Install a Local Plugin
Desktop builds can discover local plugin folders from the app data plugins directory.
@@ -40,12 +44,12 @@ Desktop builds can discover local plugin folders from the app data plugins direc
When a server uses plugins, MetoYou may show a prompt.
| Status | Meaning |
| --- | --- |
| Required | You must install the plugin to join or continue using that server. |
| Recommended | The server suggests the plugin, but you can choose. |
| Optional | The plugin is available for the server, but not required. |
| Blocked | The server marks the plugin as not allowed. |
| Status | Meaning |
| ------------ | --------------------------------------------------------------------------------- |
| Required | You must install the plugin to join or continue using that server. |
| Recommended | The server suggests the plugin, but you can choose. |
| Optional | The plugin is available for the server, but not required. |
| Blocked | The server marks the plugin as not allowed. |
| Incompatible | The plugin version does not work with your app version or the server requirement. |
Required plugins are still installed locally on your device. The signaling server stores requirement metadata only; it does not run plugin code.
@@ -56,13 +60,13 @@ Plugins must ask for capabilities before using sensitive features.
Examples:
| Capability area | Why a plugin might ask |
| --- | --- |
| Messages | Send messages, read current messages, moderate messages, or render embeds. |
| Users and roles | Read member lists, create plugin users, or manage users. |
| Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. |
| UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. |
| Storage | Save plugin preferences locally or per server. |
| Capability area | Why a plugin might ask |
| --------------- | -------------------------------------------------------------------------- |
| Messages | Send messages, read current messages, moderate messages, or render embeds. |
| Users and roles | Read member lists, create plugin users, or manage users. |
| Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. |
| UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. |
| Storage | Save plugin preferences locally or per server. |
Only grant capabilities to plugins you trust.
@@ -79,4 +83,4 @@ The Plugin Manager lets you:
## Plugin Safety Notes
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it.
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it.