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 = {