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 ## Angular Routes
| Route | Component | Purpose | | Route | Component | Purpose |
| --- | --- | --- | | ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
| `/` | Redirect | Redirects to `/search`. | | `/` | Redirect | Redirects to `/search`. |
| `/login` | `LoginComponent` | User login. | | `/login` | `LoginComponent` | User login. |
| `/register` | `RegisterComponent` | User registration. | | `/register` | `RegisterComponent` | User registration. |
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. | | `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
| `/search` | `ServerSearchComponent` | Search and join servers. | | `/search` | `ServerSearchComponent` | Search and join servers. |
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. | | `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. | | `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. | | `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. | | `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. | | `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. | | `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
## Page Shell ## Page Shell
@@ -46,6 +46,7 @@ The server page is the most important page for plugins.
<section>Text Channels</section> <section>Text Channels</section>
<section>Voice Channels</section> <section>Voice Channels</section>
<section data-testid="plugin-room-side-panel"> <section data-testid="plugin-room-side-panel">
<button>View plugins</button>
<app-plugin-render-host></app-plugin-render-host> <app-plugin-render-host></app-plugin-render-host>
</section> </section>
<section>Members</section> <section>Members</section>
@@ -135,11 +136,11 @@ Prefer plugin APIs over DOM selectors. When direct DOM mounting is necessary, us
Common targets: Common targets:
| Selector | Area | | Selector | Area |
| --- | --- | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `body` | Global overlays or modals. | | `body` | Global overlays or modals. |
| `app-chat-messages` | Main text channel surface. | | `app-chat-messages` | Main text channel surface. |
| `app-rooms-side-panel` | Server side panel. | | `app-rooms-side-panel` | Server side panel. |
| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar. | | `[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: Choose communication APIs like this:
| Need | Use | Notes | | Need | Use | Notes |
| --- | --- | --- | | ------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
| Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. | | 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`. | | 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 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`. | | 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. | | 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. | | 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 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. | | 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. | | App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. |
| Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. | | Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. |
## How The App Looks ## How The App Looks
@@ -122,47 +122,51 @@ Main server page shape:
Important routes: Important routes:
| Route | Purpose | | Route | Purpose |
| --- | --- | | ------------------------------- | ------------------------------------------------------------------- |
| `/search` | Search and join servers. | | `/search` | Search and join servers. |
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. | | `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
| `/dm` and `/dm/:conversationId` | Direct-message workspace. | | `/dm` and `/dm/:conversationId` | Direct-message workspace. |
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. | | `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
| `/plugin-store` | Browse and install plugins. | | `/plugin-store` | Browse and install plugins. |
| `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. | | `/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. 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: Stable direct-mount targets when necessary:
| Selector | Area | | Selector | Area |
| --- | --- | | ---------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `body` | Safest global target for overlays, badges, and modals. It exists during activation. | | `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-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. | | `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: 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 ```js
context.subscriptions.push(api.ui.registerSidePanel('control-panel', { context.subscriptions.push(
label: 'Control Panel', api.ui.registerSidePanel('control-panel', {
order: 20, label: 'Control Panel',
render: () => { order: 20,
const root = document.createElement('section'); render: () => {
const button = document.createElement('button'); const root = document.createElement('section');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Run Action'; button.textContent = 'Run Action';
button.addEventListener('click', () => { button.addEventListener('click', () => {
api.logger.info('Side-panel action clicked'); api.logger.info('Side-panel action clicked');
}); });
root.append(button); root.append(button);
return root; 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. Do not depend on Tailwind classes or internal styling classes.
## Manifest ## Manifest
@@ -300,10 +304,10 @@ Validation rules:
Scope meanings: Scope meanings:
| Scope | Meaning | | Scope | Meaning |
| --- | --- | | ------------------- | --------------------------------------------------------------------------------------------- |
| `client` or omitted | Installed globally for this local user/client. | | `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. | | `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. 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; ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void; deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void; onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
onServerRequirementsChanged?: ( onServerRequirementsChanged?: (context: TojuPluginActivationContext, snapshot: PluginRequirementsSnapshot) => Promise<void> | void;
context: TojuPluginActivationContext,
snapshot: PluginRequirementsSnapshot
) => Promise<void> | void;
} }
``` ```
@@ -579,9 +580,20 @@ interface ChannelPermissionOverride {
## Full Plugin API Types ## Full Plugin API Types
```ts ```ts
interface PluginApiProfileUpdate { displayName: string; description?: string } interface PluginApiProfileUpdate {
interface PluginApiAvatarUpdate { avatarUrl: string; avatarMime: string; avatarHash: string } displayName: string;
interface PluginApiChannelRequest { name: string; id?: string; position?: number } description?: string;
}
interface PluginApiAvatarUpdate {
avatarUrl: string;
avatarMime: string;
avatarHash: string;
}
interface PluginApiChannelRequest {
name: string;
id?: string;
position?: number;
}
interface PluginApiServerSettingsUpdate { interface PluginApiServerSettingsUpdate {
name?: string; name?: string;
description?: string; description?: string;
@@ -590,10 +602,24 @@ interface PluginApiServerSettingsUpdate {
password?: string; password?: string;
maxUsers?: number; maxUsers?: number;
} }
interface PluginApiPluginUserRequest { displayName: string; id?: string; avatarUrl?: string } interface PluginApiPluginUserRequest {
interface PluginApiMessageAsPluginUserRequest { pluginUserId: string; content: string; channelId?: string } displayName: string;
interface PluginApiAudioClipRequest { url: string; volume?: number } id?: string;
interface PluginApiCustomStreamRequest { stream: MediaStream; label?: 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'; type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
interface PluginApiActionContext { interface PluginApiActionContext {
@@ -660,13 +686,41 @@ interface PluginApiMessageBusSubscription {
handler: (event: PluginApiMessageBusEnvelope) => void; handler: (event: PluginApiMessageBusEnvelope) => void;
} }
interface PluginApiPageContribution { label: string; path: string; render: () => HTMLElement | string } interface PluginApiPageContribution {
interface PluginApiSettingsPageContribution { label: string; settingsKey?: string; order?: number; render: () => HTMLElement | string } label: string;
interface PluginApiPanelContribution { label: string; order?: number; render: () => HTMLElement | string } path: string;
interface PluginApiChannelSectionContribution { label: string; type?: 'audio' | 'video' | 'custom'; order?: number } render: () => HTMLElement | string;
interface PluginApiActionContribution { label: string; icon?: string; run: (context: PluginApiActionContext) => Promise<void> | void } }
interface PluginApiEmbedRendererContribution { embedType: string; render: (payload: unknown) => HTMLElement | string } interface PluginApiSettingsPageContribution {
interface PluginApiDomMountRequest { target: Element | string; element: HTMLElement; position?: InsertPosition } 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 { interface TojuClientPluginApi {
readonly context: { getCurrent: () => PluginApiActionContext }; readonly context: { getCurrent: () => PluginApiActionContext };
@@ -890,10 +944,7 @@ Capabilities: `messages.read`, `messages.send`, `messages.editOwn`, `messages.de
```js ```js
const visibleMessages = api.messages.readCurrent(); const visibleMessages = api.messages.readCurrent();
const sent = api.messages.send( const sent = api.messages.send('Build completed successfully. Docs are ready for review.', 'general');
'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.edit(sent.id, 'Build completed successfully. Docs and plugin examples are ready.');
api.messages.delete(sent.id); api.messages.delete(sent.id);
@@ -1115,88 +1166,110 @@ Desktop uses Electron's local database when available, with renderer localStorag
Capabilities: Capabilities:
| Method | Required capability | | Method | Required capability |
| --- | --- | | ------------------------ | -------------------- |
| `registerAppPage` | `ui.pages` | | `registerAppPage` | `ui.pages` |
| `registerSettingsPage` | `ui.settings` | | `registerSettingsPage` | `ui.settings` |
| `registerSidePanel` | `ui.sidePanel` | | `registerSidePanel` | `ui.sidePanel` |
| `registerChannelSection` | `ui.channelsSection` | | `registerChannelSection` | `ui.channelsSection` |
| `registerComposerAction` | `ui.pages` | | `registerComposerAction` | `ui.pages` |
| `registerProfileAction` | `ui.pages` | | `registerProfileAction` | `ui.pages` |
| `registerToolbarAction` | `ui.pages` | | `registerToolbarAction` | `ui.pages` |
| `registerEmbedRenderer` | `ui.embeds` | | `registerEmbedRenderer` | `ui.embeds` |
| `mountElement` | `ui.dom` | | `mountElement` | `ui.dom` |
Register side panel: Register side panel:
```js ```js
context.subscriptions.push(api.ui.registerSidePanel('summary', { context.subscriptions.push(
label: 'Plugin Summary', api.ui.registerSidePanel('summary', {
order: 10, label: 'Plugin Summary',
render: () => { order: 10,
const root = document.createElement('aside'); render: () => {
const heading = document.createElement('h2'); const root = document.createElement('aside');
const text = document.createElement('p'); const heading = document.createElement('h2');
const text = document.createElement('p');
heading.textContent = 'Plugin Summary'; heading.textContent = 'Plugin Summary';
text.textContent = 'No active tasks.'; text.textContent = 'No active tasks.';
root.append(heading, text); root.append(heading, text);
return root; 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. 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: Register app page:
```js ```js
context.subscriptions.push(api.ui.registerAppPage('dashboard', { context.subscriptions.push(
label: 'Build Dashboard', api.ui.registerAppPage('dashboard', {
path: '/plugins/example.build-dashboard/dashboard', label: 'Build Dashboard',
render: () => { path: '/plugins/example.build-dashboard/dashboard',
const root = document.createElement('section'); render: () => {
const title = document.createElement('h1'); const root = document.createElement('section');
const button = document.createElement('button'); const title = document.createElement('h1');
const output = document.createElement('p'); const button = document.createElement('button');
const output = document.createElement('p');
title.textContent = 'Build Dashboard'; title.textContent = 'Build Dashboard';
button.type = 'button'; button.type = 'button';
button.textContent = 'Send status'; button.textContent = 'Send status';
output.textContent = 'Idle.'; output.textContent = 'Idle.';
button.addEventListener('click', () => { button.addEventListener('click', () => {
const message = api.messages.send('Build dashboard status: ready.'); const message = api.messages.send('Build dashboard status: ready.');
output.textContent = `Sent message ${message.id}`; output.textContent = `Sent message ${message.id}`;
}); });
root.append(title, button, output); root.append(title, button, output);
return root; return root;
} }
})); })
);
``` ```
Register actions: Register actions:
```js ```js
context.subscriptions.push(api.ui.registerComposerAction('insert-template', { context.subscriptions.push(
label: 'Insert Template', api.ui.registerComposerAction('insert-template', {
icon: 'file-text', label: 'Insert Template',
run: (actionContext) => { icon: 'file-text',
api.messages.send( run: (actionContext) => {
'Template: Please review the latest build notes.', api.messages.send('Template: Please review the latest build notes.', actionContext.textChannel?.id);
actionContext.textChannel?.id }
); })
} );
}));
context.subscriptions.push(api.ui.registerToolbarAction('post-standup', { context.subscriptions.push(
label: 'Post Standup', api.ui.registerToolbarAction('post-standup', {
icon: 'megaphone', label: 'Post Standup',
run: () => { icon: 'megaphone',
api.messages.send('Standup starts now. Join the voice channel when ready.'); run: () => {
} api.messages.send('Standup starts now. Join the voice channel when ready.');
})); }
})
);
``` ```
Mount DOM directly: Mount DOM directly:
@@ -1210,11 +1283,13 @@ banner.textContent = 'Plugin banner mounted in chat messages.';
const target = document.querySelector('app-chat-messages'); const target = document.querySelector('app-chat-messages');
if (target) { if (target) {
context.subscriptions.push(api.ui.mountElement('chat-banner', { context.subscriptions.push(
target, api.ui.mountElement('chat-banner', {
element: banner, target,
position: 'afterbegin' element: banner,
})); position: 'afterbegin'
})
);
} }
``` ```
@@ -1224,56 +1299,58 @@ Global overlay example:
const badge = document.createElement('div'); const badge = document.createElement('div');
badge.textContent = 'Plugin active'; badge.textContent = 'Plugin active';
context.subscriptions.push(api.ui.mountElement('global-badge', { context.subscriptions.push(
target: 'body', api.ui.mountElement('global-badge', {
element: badge, target: 'body',
position: 'beforeend' 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. `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 ## Capability Cheat Sheet
| API call group | Capabilities | | API call group | Capabilities |
| --- | --- | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `profile.getCurrent` | `profile.read` | | `profile.getCurrent` | `profile.read` |
| `profile.update`, `profile.updateAvatar` | `profile.write` | | `profile.update`, `profile.updateAvatar` | `profile.write` |
| `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` | | `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` |
| `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` | | `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` |
| `roles.list` | `roles.read` | | `roles.list` | `roles.read` |
| `users.setRole`, `roles.setAssignments` | `roles.manage` | | `users.setRole`, `roles.setAssignments` | `roles.manage` |
| `server.getCurrent` | `server.read` | | `server.getCurrent` | `server.read` |
| `server.updatePermissions`, `server.updateSettings` | `server.manage` | | `server.updatePermissions`, `server.updateSettings` | `server.manage` |
| `channels.list`, `channels.select` | `channels.read` | | `channels.list`, `channels.select` | `channels.read` |
| `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` | | `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` |
| `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` | | `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` |
| `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` | | `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` |
| `messages.edit` | `messages.editOwn` | | `messages.edit` | `messages.editOwn` |
| `messages.delete` | `messages.deleteOwn` | | `messages.delete` | `messages.deleteOwn` |
| `messages.moderateDelete` | `messages.moderate` | | `messages.moderateDelete` | `messages.moderate` |
| `messages.sync` | `messages.sync` | | `messages.sync` | `messages.sync` |
| `events.publishServer` | `events.server.publish` | | `events.publishServer` | `events.server.publish` |
| `events.subscribeServer` | `events.server.subscribe` | | `events.subscribeServer` | `events.server.subscribe` |
| `events.publishP2p` | `events.p2p.publish` | | `events.publishP2p` | `events.p2p.publish` |
| `events.subscribeP2p` | `events.p2p.subscribe` | | `events.subscribeP2p` | `events.p2p.subscribe` |
| `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true | | `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true |
| `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` | | `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` |
| `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true | | `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true |
| `p2p.*` | `p2p.data` | | `p2p.*` | `p2p.data` |
| `media.playAudioClip` | `media.playAudio` | | `media.playAudioClip` | `media.playAudio` |
| `media.addCustomAudioStream` | `media.addAudioStream` | | `media.addCustomAudioStream` | `media.addAudioStream` |
| `media.addCustomVideoStream` | `media.addVideoStream` | | `media.addCustomVideoStream` | `media.addVideoStream` |
| `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` | | `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` |
| `clientData.*`, `storage.*` | `storage.local` | | `clientData.*`, `storage.*` | `storage.local` |
| `serverData.read` | `storage.serverData.read` | | `serverData.read` | `storage.serverData.read` |
| `serverData.write`, `serverData.remove` | `storage.serverData.write` | | `serverData.write`, `serverData.remove` | `storage.serverData.write` |
| `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` | | `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` |
| `ui.registerSettingsPage` | `ui.settings` | | `ui.registerSettingsPage` | `ui.settings` |
| `ui.registerSidePanel` | `ui.sidePanel` | | `ui.registerSidePanel` | `ui.sidePanel` |
| `ui.registerChannelSection` | `ui.channelsSection` | | `ui.registerChannelSection` | `ui.channelsSection` |
| `ui.registerEmbedRenderer` | `ui.embeds` | | `ui.registerEmbedRenderer` | `ui.embeds` |
| `ui.mountElement` | `ui.dom` | | `ui.mountElement` | `ui.dom` |
## Complete Example Plugin ## Complete Example Plugin
@@ -1319,25 +1396,31 @@ export function activate(context) {
api.logger.info('Voice Notes activated'); api.logger.info('Voice Notes activated');
context.subscriptions.push(api.messageBus.subscribe({ context.subscriptions.push(
topic: BUS_TOPIC, api.messageBus.subscribe({
replayLatest: false, topic: BUS_TOPIC,
handler: (event) => { replayLatest: false,
api.logger.debug('Received voice notes draft update', event.payload); handler: (event) => {
} api.logger.debug('Received voice notes draft update', event.payload);
})); }
})
);
context.subscriptions.push(api.ui.registerSidePanel('voice-notes-panel', { context.subscriptions.push(
label: 'Voice Notes', api.ui.registerSidePanel('voice-notes-panel', {
order: 20, label: 'Voice Notes',
render: () => renderPanel(context) order: 20,
})); render: () => renderPanel(context)
})
);
context.subscriptions.push(api.ui.registerAppPage('voice-notes', { context.subscriptions.push(
label: 'Voice Notes', api.ui.registerAppPage('voice-notes', {
path: '/plugins/example.voice-notes/voice-notes', label: 'Voice Notes',
render: () => renderPanel(context) path: '/plugins/example.voice-notes/voice-notes',
})); render: () => renderPanel(context)
})
);
} }
function renderPanel(context) { function renderPanel(context) {
@@ -1352,9 +1435,7 @@ function renderPanel(context) {
const current = api.context.getCurrent(); const current = api.context.getCurrent();
heading.textContent = 'Voice Notes'; heading.textContent = 'Voice Notes';
meta.textContent = current.voiceChannel meta.textContent = current.voiceChannel ? `Connected to ${current.voiceChannel.name}` : 'Not connected to a voice channel.';
? `Connected to ${current.voiceChannel.name}`
: 'Not connected to a voice channel.';
textarea.rows = 6; textarea.rows = 6;
textarea.placeholder = 'Write notes from the current voice session.'; textarea.placeholder = 'Write notes from the current voice session.';
save.type = 'button'; save.type = 'button';
@@ -1363,16 +1444,19 @@ function renderPanel(context) {
post.textContent = 'Post Notes'; post.textContent = 'Post Notes';
status.textContent = 'Loading draft...'; status.textContent = 'Loading draft...';
void api.serverData.read(DRAFT_KEY).then((value) => { void api.serverData
if (value && typeof value === 'object' && typeof value.text === 'string') { .read(DRAFT_KEY)
textarea.value = value.text; .then((value) => {
} if (value && typeof value === 'object' && typeof value.text === 'string') {
textarea.value = value.text;
}
status.textContent = 'Draft loaded.'; status.textContent = 'Draft loaded.';
}).catch((error) => { })
api.logger.warn('Could not load voice notes draft', error); .catch((error) => {
status.textContent = 'Could not load draft.'; api.logger.warn('Could not load voice notes draft', error);
}); status.textContent = 'Could not load draft.';
});
save.addEventListener('click', async () => { save.addEventListener('click', async () => {
const draft = { const draft = {

View File

@@ -60,24 +60,24 @@ interface PluginApiAvatarUpdate {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------ | --------------- | ------------------------------------------------- |
| `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. | | `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. |
| `profile.update(profile)` | `profile.write` | Updates display name and optional description. | | `profile.update(profile)` | `profile.write` | Updates display name and optional description. |
| `profile.updateAvatar(avatar)` | `profile.write` | Updates avatar URL, MIME type, and hash metadata. | | `profile.updateAvatar(avatar)` | `profile.write` | Updates avatar URL, MIME type, and hash metadata. |
## Users and Roles ## Users and Roles
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ----------------------------------- | -------------- | --------------------------------- |
| `users.getCurrent()` | `users.read` | Returns current `User` or `null`. | | `users.getCurrent()` | `users.read` | Returns current `User` or `null`. |
| `users.list()` | `users.read` | Returns known users. | | `users.list()` | `users.read` | Returns known users. |
| `users.readMembers()` | `users.read` | Returns active room members. | | `users.readMembers()` | `users.read` | Returns active room members. |
| `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. | | `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. |
| `users.kick(userId)` | `users.manage` | Kicks a user. | | `users.kick(userId)` | `users.manage` | Kicks a user. |
| `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. | | `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. |
| `roles.list()` | `roles.read` | Returns room roles. | | `roles.list()` | `roles.read` | Returns room roles. |
| `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. | | `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. |
## Server ## Server
@@ -98,12 +98,12 @@ interface PluginApiPluginUserRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | --------------------------------------- | --------------- | -------------------------------------------- |
| `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. | | `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.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. |
| `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. | | `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. |
| `server.updateSettings(settings)` | `server.manage` | Updates room settings. | | `server.updateSettings(settings)` | `server.manage` | Updates room settings. |
## Channels ## Channels
@@ -115,14 +115,14 @@ interface PluginApiChannelRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ----------------------------------- | ----------------- | ---------------------------------- |
| `channels.list()` | `channels.read` | Returns current room channels. | | `channels.list()` | `channels.read` | Returns current room channels. |
| `channels.select(channelId)` | `channels.read` | Selects a channel. | | `channels.select(channelId)` | `channels.read` | Selects a channel. |
| `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. | | `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. |
| `channels.addVideoChannel(request)` | `channels.manage` | Registers a video channel section. | | `channels.addVideoChannel(request)` | `channels.manage` | Registers a video channel section. |
| `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. | | `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. |
| `channels.remove(channelId)` | `channels.manage` | Removes a channel. | | `channels.remove(channelId)` | `channels.manage` | Removes a channel. |
## Messages ## Messages
@@ -134,17 +134,17 @@ interface PluginApiMessageAsPluginUserRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------------------ | -------------------- | -------------------------------------------------- |
| `messages.readCurrent()` | `messages.read` | Returns current visible messages. | | `messages.readCurrent()` | `messages.read` | Returns current visible messages. |
| `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. | | `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.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.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. |
| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. | | `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. |
| `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. | | `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. |
| `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. | | `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. |
| `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. | | `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. |
| `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. | | `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. |
## Events ## Events
@@ -167,12 +167,12 @@ interface PluginEventEnvelope<TPayload = unknown> {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------------------ | ------------------------- | ----------------------------------------------------------- |
| `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. | | `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.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.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. | | `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. |
## Message Bus ## Message Bus
@@ -215,11 +215,11 @@ interface PluginApiMessageBusSubscription {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
| `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. | | `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.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. | | `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. |
## P2P and Media ## P2P and Media
@@ -235,30 +235,30 @@ interface PluginApiCustomStreamRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------------------ | ---------------------- | --------------------------------------------- |
| `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. | | `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. |
| `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. | | `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. |
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. | | `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.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. |
| `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. | | `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. |
| `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. | | `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. |
| `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. | | `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. |
| `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. | | `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. |
## Storage ## Storage
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------ | -------------------------- | --------------------------------------- |
| `clientData.read(key)` | `storage.local` | Reads async plugin-local data. | | `clientData.read(key)` | `storage.local` | Reads async plugin-local data. |
| `clientData.write(key, value)` | `storage.local` | Writes 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. | | `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. |
| `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server 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.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. | | `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. |
| `storage.get(key)` | `storage.local` | Legacy synchronous local read. | | `storage.get(key)` | `storage.local` | Legacy synchronous local read. |
| `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. | | `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. |
| `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. | | `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. |
## UI Contributions ## UI Contributions
@@ -306,24 +306,24 @@ interface PluginApiDomMountRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | --------------------------------------------- | -------------------- | --------------------------------------------------------------- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. | | `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. | | `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. | | `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. | | `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. | | `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. | | `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds a toolbar 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.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. | | `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. |
## Context and Logger ## Context and Logger
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------ | ---------- | -------------------------------------------------------------------------- |
| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. | | `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.debug(message, data?)` | None | Writes a debug plugin log entry. |
| `logger.info(message, data?)` | None | Writes an info 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.warn(message, data?)` | None | Writes a warning plugin log entry. |
| `logger.error(message, data?)` | None | Writes an error 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 ## 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 ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('where-am-i', { context.subscriptions.push(
label: 'Where am I?', context.api.ui.registerToolbarAction('where-am-i', {
run: (actionContext) => { label: 'Where am I?',
context.api.logger.info('Toolbar action context', { run: (actionContext) => {
source: actionContext.source, context.api.logger.info('Toolbar action context', {
serverId: actionContext.server?.id, source: actionContext.source,
textChannelId: actionContext.textChannel?.id, serverId: actionContext.server?.id,
voiceChannelId: actionContext.voiceChannel?.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 ## Required Capabilities
| Method | Capability | | Method | Capability |
| --- | --- | | --------------------------------------------- | -------------------- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` | | `ui.registerAppPage(id, contribution)` | `ui.pages` |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | | `ui.registerSettingsPage(id, contribution)` | `ui.settings` |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | | `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | | `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | | `ui.registerComposerAction(id, contribution)` | `ui.pages` |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | | `ui.registerProfileAction(id, contribution)` | `ui.pages` |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | | `ui.registerToolbarAction(id, contribution)` | `ui.pages` |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | | `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` |
| `ui.mountElement(id, request)` | `ui.dom` | | `ui.mountElement(id, request)` | `ui.dom` |
Every registration returns a disposable. Push it into `context.subscriptions`. 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 ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerAppPage('dashboard', { context.subscriptions.push(
label: 'Raid Dashboard', context.api.ui.registerAppPage('dashboard', {
path: '/plugins/example.raid-helper/dashboard', label: 'Raid Dashboard',
render: () => { path: '/plugins/example.raid-helper/dashboard',
const root = document.createElement('section'); render: () => {
root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>'; const root = document.createElement('section');
return root; 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 ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerSettingsPage('preferences', { context.subscriptions.push(
label: 'Raid Helper', context.api.ui.registerSettingsPage('preferences', {
settingsKey: 'raid-helper', label: 'Raid Helper',
order: 20, settingsKey: 'raid-helper',
render: () => { order: 20,
const wrapper = document.createElement('section'); render: () => {
const label = document.createElement('label'); const wrapper = document.createElement('section');
const checkbox = document.createElement('input'); const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox'; checkbox.type = 'checkbox';
checkbox.checked = true; checkbox.checked = true;
label.append(checkbox, ' Enable ready-check reminders'); label.append(checkbox, ' Enable ready-check reminders');
wrapper.append(label); wrapper.append(label);
return wrapper; return wrapper;
} }
})); })
);
} }
``` ```
@@ -71,23 +75,26 @@ Use `ui.registerSidePanel` for content that belongs in the server sidebar plugin
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerSidePanel('soundboard', { context.subscriptions.push(
label: 'Soundboard', context.api.ui.registerSidePanel('soundboard', {
order: 10, label: 'Soundboard',
render: () => { order: 10,
const panel = document.createElement('div'); render: () => {
const button = document.createElement('button'); const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Play chime'; button.textContent = 'Play chime';
button.onclick = () => context.api.media.playAudioClip({ button.onclick = () =>
url: 'https://cdn.example.com/chime.wav', context.api.media.playAudioClip({
volume: 0.6 url: 'https://cdn.example.com/chime.wav',
}); volume: 0.6
panel.append(button); });
return panel; panel.append(button);
} return panel;
})); }
})
);
} }
``` ```
@@ -97,11 +104,13 @@ Capabilities required: `ui.sidePanel` and `media.playAudio`.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerChannelSection('events', { context.subscriptions.push(
label: 'Event Rooms', context.api.ui.registerChannelSection('events', {
type: 'custom', label: 'Event Rooms',
order: 50 type: 'custom',
})); order: 50
})
);
} }
``` ```
@@ -109,16 +118,15 @@ export function activate(context) {
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerComposerAction('insert-standup', { context.subscriptions.push(
icon: 'ST', context.api.ui.registerComposerAction('insert-standup', {
label: 'Insert standup prompt', icon: 'ST',
run: (actionContext) => { label: 'Insert standup prompt',
context.api.messages.send( run: (actionContext) => {
'Standup: yesterday I..., today I..., blocked by...', context.api.messages.send('Standup: yesterday I..., today I..., blocked by...', actionContext.textChannel?.id);
actionContext.textChannel?.id }
); })
} );
}));
} }
``` ```
@@ -128,45 +136,65 @@ Capabilities required: `ui.pages` and `messages.send`.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerProfileAction('wave', { context.subscriptions.push(
label: 'Wave', context.api.ui.registerProfileAction('wave', {
run: (actionContext) => { label: 'Wave',
context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`); run: (actionContext) => {
} context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`);
})); }
})
);
} }
``` ```
## Toolbar Action ## 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 ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('open-dashboard', { context.subscriptions.push(
label: 'Raid Helper', context.api.ui.registerToolbarAction('open-dashboard', {
run: () => { icon: 'RH',
context.api.logger.info('Open the Raid Helper plugin page from /plugins/example.raid-helper/dashboard'); 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 ## Embed Renderer
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerEmbedRenderer('raid-card', { context.subscriptions.push(
embedType: 'raid.card', context.api.ui.registerEmbedRenderer('raid-card', {
render: (payload) => { embedType: 'raid.card',
const card = document.createElement('article'); render: (payload) => {
const title = document.createElement('h3'); const card = document.createElement('article');
const body = document.createElement('p'); const title = document.createElement('h3');
const body = document.createElement('p');
title.textContent = payload?.title ?? 'Raid'; title.textContent = payload?.title ?? 'Raid';
body.textContent = payload?.description ?? 'No description provided.'; body.textContent = payload?.description ?? 'No description provided.';
card.append(title, body); card.append(title, body);
return card; return card;
} }
})); })
);
} }
``` ```
@@ -202,11 +230,13 @@ export function activate(context) {
badge.style.color = 'white'; badge.style.color = 'white';
badge.style.borderRadius = '6px'; badge.style.borderRadius = '6px';
context.subscriptions.push(context.api.ui.mountElement('active-badge', { context.subscriptions.push(
target: 'body', context.api.ui.mountElement('active-badge', {
position: 'beforeend', target: 'body',
element: badge position: 'beforeend',
})); element: badge
})
);
} }
``` ```
@@ -224,12 +254,14 @@ export function activate(context) {
const banner = document.createElement('div'); const banner = document.createElement('div');
banner.textContent = 'Raid helper active in this chat.'; banner.textContent = 'Raid helper active in this chat.';
context.subscriptions.push(context.api.ui.mountElement('chat-banner', { context.subscriptions.push(
target, context.api.ui.mountElement('chat-banner', {
position: 'afterbegin', target,
element: banner 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. 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 | | Capability | API areas | Notes |
| --- | --- | --- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `profile.read` | `profile.getCurrent()` | Reads the current user. | | `profile.read` | `profile.getCurrent()` | Reads the current user. |
| `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. | | `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.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. | | `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. |
| `roles.read` | `roles.list()` | Reads server roles. | | `roles.read` | `roles.list()` | Reads server roles. |
| `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user 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.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.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. |
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. | | `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. | | `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. | | `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. | | `messages.sync` | `messages.sync()` | Syncs message arrays into client state. |
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. | | `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. | | `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
| `server.read` | `server.getCurrent()` | Reads active server. | | `server.read` | `server.getCurrent()` | Reads active server. |
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. | | `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.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. | | `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. | | `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
| `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. | | `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. |
| `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. | | `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. |
| `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. | | `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. |
| `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. | | `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. |
| `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. | | `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. |
| `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and actions. | | `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.sidePanel` | `ui.registerSidePanel()` | Adds side panels. |
| `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. | | `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. |
| `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. | | `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. |
| `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. | | `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. |
| `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. | | `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.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. | | `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.publish` | `events.publishServer()` | Publishes declared server plugin events. |
| `events.server.subscribe` | `events.subscribeServer()` | Subscribes to 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.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. | | `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. |
## Recommended Practice ## Recommended Practice

View File

@@ -27,7 +27,7 @@ The manifest file can be named `toju-plugin.json` or `plugin.json`. Entrypoints
"schemaVersion": 1, "schemaVersion": 1,
"id": "example.hello-world", "id": "example.hello-world",
"title": "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", "version": "1.0.0",
"kind": "client", "kind": "client",
"scope": "client", "scope": "client",
@@ -49,6 +49,7 @@ export function activate(context) {
api.logger.info('Hello World activated'); api.logger.info('Hello World activated');
const disposable = api.ui.registerToolbarAction('hello', { const disposable = api.ui.registerToolbarAction('hello', {
icon: 'HI',
label: 'Hello', label: 'Hello',
run: () => api.messages.send('Hello from my plugin') 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 ## Lifecycle Hooks
| Hook | When it runs | Use it for | | Hook | When it runs | Use it for |
| --- | --- | --- | | ------------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------- |
| `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. | | `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. | | `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. | | `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. | | `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. | | `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. |
## Cleanup ## Cleanup

View File

@@ -13,7 +13,7 @@ sidebar_position: 5
"schemaVersion": 1, "schemaVersion": 1,
"id": "example.toolbar-message", "id": "example.toolbar-message",
"title": "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", "version": "1.0.0",
"kind": "client", "kind": "client",
"scope": "client", "scope": "client",
@@ -33,13 +33,18 @@ sidebar_position: 5
export function activate(context) { export function activate(context) {
const { api } = context; const { api } = context;
context.subscriptions.push(api.ui.registerToolbarAction('standup-message', { context.subscriptions.push(
label: 'Standup', api.ui.registerToolbarAction('standup-message', {
run: () => api.messages.send('Standup: yesterday, today, blocked') 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 ## Settings Page Plugin
```json ```json
@@ -67,19 +72,21 @@ export function activate(context) {
export function activate(context) { export function activate(context) {
const { api } = context; const { api } = context;
context.subscriptions.push(api.ui.registerSettingsPage('preferences', { context.subscriptions.push(
label: 'Example Preferences', api.ui.registerSettingsPage('preferences', {
render: () => { label: 'Example Preferences',
const root = document.createElement('section'); render: () => {
const button = document.createElement('button'); const root = document.createElement('section');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Remember preference'; button.textContent = 'Remember preference';
button.onclick = () => api.storage.set('enabled', true); button.onclick = () => api.storage.set('enabled', true);
root.append(button); root.append(button);
return root; return root;
} }
})); })
);
} }
``` ```
@@ -99,13 +106,7 @@ A server-scoped plugin can be installed as a server requirement and auto-install
"apiVersion": "1.0.0", "apiVersion": "1.0.0",
"compatibility": { "minimumTojuVersion": "1.0.0" }, "compatibility": { "minimumTojuVersion": "1.0.0" },
"entrypoint": "./main.js", "entrypoint": "./main.js",
"capabilities": [ "capabilities": ["server.read", "users.manage", "ui.sidePanel", "media.playAudio", "messages.send"],
"server.read",
"users.manage",
"ui.sidePanel",
"media.playAudio",
"messages.send"
],
"pluginUser": { "pluginUser": {
"displayName": "Soundboard", "displayName": "Soundboard",
"label": "Audio helper" "label": "Audio helper"
@@ -121,23 +122,25 @@ export function activate(context) {
displayName: 'Soundboard' displayName: 'Soundboard'
}); });
context.subscriptions.push(api.ui.registerSidePanel('sounds', { context.subscriptions.push(
label: 'Soundboard', api.ui.registerSidePanel('sounds', {
render: () => { label: 'Soundboard',
const panel = document.createElement('div'); render: () => {
const button = document.createElement('button'); const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Play chime'; button.textContent = 'Play chime';
button.onclick = async () => { button.onclick = async () => {
await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 }); await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 });
api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' }); api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' });
}; };
panel.append(button); panel.append(button);
return panel; return panel;
} }
})); })
);
} }
``` ```
@@ -162,12 +165,14 @@ export function activate(context) {
export function activate(context) { export function activate(context) {
const { api } = context; const { api } = context;
context.subscriptions.push(api.messageBus.subscribe({ context.subscriptions.push(
topic: 'poll:votes', api.messageBus.subscribe({
replayLatest: true, topic: 'poll:votes',
latestMessageLimit: 20, replayLatest: true,
handler: (event) => api.logger.info('Vote received', event.payload) latestMessageLimit: 20,
})); handler: (event) => api.logger.info('Vote received', event.payload)
})
);
api.messageBus.publish({ api.messageBus.publish({
topic: 'poll:votes', topic: 'poll:votes',
@@ -192,10 +197,12 @@ export function activate(context) {
badge.style.right = '1rem'; badge.style.right = '1rem';
badge.style.bottom = '1rem'; badge.style.bottom = '1rem';
context.subscriptions.push(context.api.ui.mountElement('active-badge', { context.subscriptions.push(
target: 'body', context.api.ui.mountElement('active-badge', {
element: 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 ## Types of Plugins
| Type | What it means | | Type | What it means |
| --- | --- | | -------------- | ----------------------------------------------------------------------------------------------------- |
| Client plugin | Installed for your app. It follows you across servers when active. | | 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. | | 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. | | Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. |
## Install from the Plugin Store ## 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. 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 ## Install a Local Plugin
Desktop builds can discover local plugin folders from the app data plugins directory. 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. When a server uses plugins, MetoYou may show a prompt.
| Status | Meaning | | Status | Meaning |
| --- | --- | | ------------ | --------------------------------------------------------------------------------- |
| Required | You must install the plugin to join or continue using that server. | | Required | You must install the plugin to join or continue using that server. |
| Recommended | The server suggests the plugin, but you can choose. | | Recommended | The server suggests the plugin, but you can choose. |
| Optional | The plugin is available for the server, but not required. | | Optional | The plugin is available for the server, but not required. |
| Blocked | The server marks the plugin as not allowed. | | Blocked | The server marks the plugin as not allowed. |
| Incompatible | The plugin version does not work with your app version or the server requirement. | | 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. 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: Examples:
| Capability area | Why a plugin might ask | | Capability area | Why a plugin might ask |
| --- | --- | | --------------- | -------------------------------------------------------------------------- |
| Messages | Send messages, read current messages, moderate messages, or render embeds. | | Messages | Send messages, read current messages, moderate messages, or render embeds. |
| Users and roles | Read member lists, create plugin users, or manage users. | | 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. | | 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. | | UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. |
| Storage | Save plugin preferences locally or per server. | | Storage | Save plugin preferences locally or per server. |
Only grant capabilities to plugins you trust. Only grant capabilities to plugins you trust.
@@ -79,4 +83,4 @@ The Plugin Manager lets you:
## Plugin Safety Notes ## 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.

Binary file not shown.

View File

@@ -0,0 +1,49 @@
import type { Room } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import { normalizeRoomAccessControl } from './room.rules';
function buildRoom(overrides: Partial<Room> = {}): Room {
return {
id: 'room-1',
name: 'Room',
hostId: 'host-1',
isPrivate: false,
createdAt: 1,
userCount: 1,
members: [
{
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice',
role: 'admin',
joinedAt: 1,
lastSeenAt: 1
}
],
...overrides
};
}
describe('normalizeRoomRoleAssignments', () => {
it('uses legacy member roles when assignments are missing', () => {
const room = normalizeRoomAccessControl(buildRoom());
expect(room.roleAssignments).toEqual([
{
userId: 'user-1',
oderId: 'oder-1',
roleIds: [SYSTEM_ROLE_IDS.admin]
}
]);
expect(room.members?.[0]?.role).toBe('admin');
});
it('honors an explicit empty assignment list', () => {
const room = normalizeRoomAccessControl(buildRoom({ roleAssignments: [] }));
expect(room.roleAssignments).toEqual([]);
expect(room.members?.[0]?.role).toBe('member');
});
});

View File

@@ -45,6 +45,7 @@ export function normalizeRoomRoleAssignments(
): RoomRoleAssignment[] { ): RoomRoleAssignment[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone)); const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const normalizedByUserKey = new Map<string, RoomRoleAssignment>(); const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
const hasExplicitAssignments = Array.isArray(assignments);
for (const assignment of assignments ?? []) { for (const assignment of assignments ?? []) {
if (!assignment || typeof assignment !== 'object') { if (!assignment || typeof assignment !== 'object') {
@@ -72,7 +73,7 @@ export function normalizeRoomRoleAssignments(
}); });
} }
if (normalizedByUserKey.size > 0) { if (hasExplicitAssignments) {
return sortAssignments(Array.from(normalizedByUserKey.values())); return sortAssignments(Array.from(normalizedByUserKey.values()));
} }

View File

@@ -52,6 +52,7 @@ import {
}) })
export class ChatMessagesComponent { export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store); private readonly store = inject(Store);
@@ -98,6 +99,8 @@ export class ChatMessagesComponent {
} }
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void { handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
this.messageList?.scrollToBottomAfterLocalSend();
this.store.dispatch( this.store.dispatch(
MessagesActions.sendMessage({ MessagesActions.sendMessage({
content: event.content, content: event.content,

View File

@@ -141,9 +141,11 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return lookup; return lookup;
}); });
private initialScrollObserver: MutationObserver | null = null; private bottomScrollObserver: MutationObserver | null = null;
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null; private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null; private boundOnImageLoad: (() => void) | null = null;
private localSendScrollPending = false;
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
private isAutoScrolling = false; private isAutoScrolling = false;
private lastMessageCount = 0; private lastMessageCount = 0;
private initialScrollPending = true; private initialScrollPending = true;
@@ -170,10 +172,17 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount; const newMessages = currentCount > this.lastMessageCount;
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
if (newMessages) { if (newMessages) {
if (distanceFromBottom <= 300) { if (forceLocalSendScroll || distanceFromBottom <= 300) {
this.scheduleScrollToBottomSmooth(); if (forceLocalSendScroll) {
this.clearLocalSendScrollPending();
this.scheduleScrollToBottomAfterRender(true);
} else {
this.scheduleScrollToBottomSmooth();
}
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} else { } else {
queueMicrotask(() => this.showNewMessagesBar.set(true)); queueMicrotask(() => this.showNewMessagesBar.set(true));
@@ -198,7 +207,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.isAutoScrolling = false; this.isAutoScrolling = false;
}); });
this.startInitialScrollWatch(); this.clearLocalSendScrollPending();
this.startBottomScrollWatch();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length; this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight(); this.scheduleCodeHighlight();
@@ -214,7 +224,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
this.clearLocalSendScrollPending();
} }
findRepliedMessage(messageId?: string | null): Message | undefined { findRepliedMessage(messageId?: string | null): Message | undefined {
@@ -237,8 +248,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} }
if (this.initialScrollObserver) { if (this.bottomScrollObserver) {
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
} }
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) { if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
@@ -275,6 +286,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} }
scrollToBottomAfterLocalSend(): void {
this.localSendScrollPending = true;
this.showNewMessagesBar.set(false);
this.scheduleScrollToBottomAfterRender(true);
this.armLocalSendScrollTimeout();
}
scrollToMessage(messageId: string): void { scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement; const container = this.messagesContainer?.nativeElement;
@@ -336,54 +354,42 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
private resetScrollingState(): void { private resetScrollingState(): void {
this.initialScrollPending = true; this.initialScrollPending = true;
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
this.clearLocalSendScrollPending();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = 0; this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE); this.displayLimit.set(this.PAGE_SIZE);
} }
private startInitialScrollWatch(): void { private startBottomScrollWatch(): void {
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
const element = this.messagesContainer?.nativeElement; const element = this.messagesContainer?.nativeElement;
if (!element) if (!element)
return; return;
const snapToBottom = () => { this.bottomScrollObserver = new MutationObserver(() => {
const container = this.messagesContainer?.nativeElement; requestAnimationFrame(() => this.scrollToBottomInstant());
if (!container)
return;
this.isAutoScrolling = true;
container.scrollTop = container.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
};
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snapToBottom);
}); });
this.initialScrollObserver.observe(element, { this.bottomScrollObserver.observe(element, {
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ['src'] attributeFilter: ['src']
}); });
this.boundOnImageLoad = () => requestAnimationFrame(snapToBottom); this.boundOnImageLoad = () => requestAnimationFrame(() => this.scrollToBottomInstant());
element.addEventListener('load', this.boundOnImageLoad, true); element.addEventListener('load', this.boundOnImageLoad, true);
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000); this.bottomScrollTimer = setTimeout(() => this.stopBottomScrollWatch(), 5000);
} }
private stopInitialScrollWatch(): void { private stopBottomScrollWatch(): void {
if (this.initialScrollObserver) { if (this.bottomScrollObserver) {
this.initialScrollObserver.disconnect(); this.bottomScrollObserver.disconnect();
this.initialScrollObserver = null; this.bottomScrollObserver = null;
} }
if (this.boundOnImageLoad && this.messagesContainer) { if (this.boundOnImageLoad && this.messagesContainer) {
@@ -392,12 +398,41 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.boundOnImageLoad = null; this.boundOnImageLoad = null;
} }
if (this.initialScrollTimer) { if (this.bottomScrollTimer) {
clearTimeout(this.initialScrollTimer); clearTimeout(this.bottomScrollTimer);
this.initialScrollTimer = null; this.bottomScrollTimer = null;
} }
} }
private armLocalSendScrollTimeout(): void {
if (this.localSendScrollTimer) {
clearTimeout(this.localSendScrollTimer);
}
this.localSendScrollTimer = setTimeout(() => {
this.localSendScrollPending = false;
this.localSendScrollTimer = null;
}, 1000);
}
private clearLocalSendScrollPending(): void {
this.localSendScrollPending = false;
if (this.localSendScrollTimer) {
clearTimeout(this.localSendScrollTimer);
this.localSendScrollTimer = null;
}
}
private shouldForceLocalSendScroll(): boolean {
if (!this.localSendScrollPending)
return false;
const latestMessage = this.channelMessages().at(-1);
return !!latestMessage && latestMessage.senderId === this.currentUserId();
}
private getMessageDateTimestamp(message: Message): number { private getMessageDateTimestamp(message: Message): number {
return message.timestamp || getMessageTimestamp(message); return message.timestamp || getMessageTimestamp(message);
} }
@@ -424,6 +459,31 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
} }
} }
private scrollToBottomInstant(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
this.isAutoScrolling = true;
element.scrollTop = element.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
}
private scheduleScrollToBottomAfterRender(watchForLayoutChanges = false): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.scrollToBottomInstant();
if (watchForLayoutChanges) {
this.startBottomScrollWatch();
}
});
});
}
private scheduleScrollToBottomSmooth(): void { private scheduleScrollToBottomSmooth(): void {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => this.scrollToBottomSmooth()); requestAnimationFrame(() => this.scrollToBottomSmooth());

View File

@@ -13,4 +13,6 @@ Direct calls coordinate private voice sessions started from people cards, direct
7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages. 7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
8. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view. 8. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification.
Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes. Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes.

View File

@@ -87,6 +87,17 @@ describe('DirectCallService', () => {
expect(context.service.incomingCall()).toBeNull(); expect(context.service.incomingCall()).toBeNull();
}); });
it('ignores incoming call events when the current user is not a participant', async () => {
const context = createServiceContext({ currentUser: charlie, allUsers: [alice, bob, charlie] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).toBeNull());
expect(context.audio.playLoop).not.toHaveBeenCalled();
expect(context.directMessages.createConversation).not.toHaveBeenCalled();
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
});
it('answers an incoming call from the modal action', async () => { it('answers an incoming call from the modal action', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });

View File

@@ -355,6 +355,10 @@ export class DirectCallService {
return; return;
} }
if (!this.callPayloadIncludesParticipant(payload, meId)) {
return;
}
const participants = this.callParticipantsFromPayload(payload); const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId); const existing = this.sessionById(payload.callId);
const incomingSession = this.createSession({ const incomingSession = this.createSession({
@@ -671,6 +675,11 @@ export class DirectCallService {
]); ]);
} }
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
return payload.participantIds.includes(participantId)
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
}
private groupConversationTitle(session: DirectCallSession): string { private groupConversationTitle(session: DirectCallSession): string {
const names = Object.values(session.participants) const names = Object.values(session.participants)
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId); .map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);

View File

@@ -23,6 +23,8 @@ direct-message/
5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back. 5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event. 6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history.
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`. Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
## Chat View ## Chat View

View File

@@ -2,6 +2,8 @@ import {
advanceDirectMessageStatus, advanceDirectMessageStatus,
createDirectConversation, createDirectConversation,
createGroupConversation, createGroupConversation,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
getDirectConversationId, getDirectConversationId,
isGroupDirectConversation, isGroupDirectConversation,
updateMessageStatusInConversation, updateMessageStatusInConversation,
@@ -92,6 +94,30 @@ describe('DirectMessageService domain flow', () => {
expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED'); expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED');
expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED'); expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED');
}); });
it('recognises only declared direct-message recipients and participants', () => {
const payload = {
message: createMessage('message-1', 'SENT', 'dm-group-test', ['bob']),
participants: [alice, bob],
sender: alice
};
expect(directMessageEventIncludesUser(payload, 'bob')).toBe(true);
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false);
});
it('recognises only declared sync participants', () => {
const payload = {
conversationId: 'dm-group-test',
messages: [],
participants: [alice, bob],
sender: alice,
syncedAt: 30
};
expect(directMessageSyncIncludesUser(payload, 'alice')).toBe(true);
expect(directMessageSyncIncludesUser(payload, 'charlie')).toBe(false);
});
}); });
function createMessage( function createMessage(

View File

@@ -17,6 +17,9 @@ import {
advanceDirectMessageStatus, advanceDirectMessageStatus,
createDirectConversation, createDirectConversation,
createGroupConversation, createGroupConversation,
directMessageConversationIncludesUser,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
getDirectConversationId, getDirectConversationId,
isGroupDirectConversation, isGroupDirectConversation,
updateMessageStatusInConversation, updateMessageStatusInConversation,
@@ -464,6 +467,11 @@ export class DirectMessageService {
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> { private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) {
return;
}
const currentParticipant = toDirectMessageParticipant(currentUser); const currentParticipant = toDirectMessageParticipant(currentUser);
const sender = payload.sender; const sender = payload.sender;
const conversationId = payload.message.conversationId const conversationId = payload.message.conversationId
@@ -528,7 +536,11 @@ export class DirectMessageService {
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> { private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) {
return;
}
await this.persistConversation(ownerId, this.applyMutation(conversation, payload)); await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
} }
@@ -540,6 +552,14 @@ export class DirectMessageService {
return; return;
} }
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
if (!conversation
|| !directMessageConversationIncludesUser(conversation, currentUserId)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return;
}
if (!payload.isTyping) { if (!payload.isTyping) {
this.typingEntriesSignal.update((entries) => entries.filter((entry) => this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId) !(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
@@ -566,10 +586,12 @@ export class DirectMessageService {
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> { private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId) const conversation = await this.findConversation(ownerId, payload.conversationId);
?? await this.repository.getConversation(ownerId, payload.conversationId);
if (!conversation || payload.sender.userId === ownerId) { if (!conversation
|| payload.sender.userId === ownerId
|| !directMessageConversationIncludesUser(conversation, ownerId)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return; return;
} }
@@ -596,6 +618,10 @@ export class DirectMessageService {
return; return;
} }
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
return;
}
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId) const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId) ?? await this.repository.getConversation(ownerId, payload.conversationId)
?? (payload.conversationKind === 'group' || payload.participants.length > 2 ?? (payload.conversationKind === 'group' || payload.participants.length > 2
@@ -863,10 +889,7 @@ export class DirectMessageService {
} }
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> { private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
await this.loadForOwner(ownerId); const conversation = await this.findConversation(ownerId, conversationId);
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
if (!conversation) { if (!conversation) {
throw new Error('Direct message conversation not found.'); throw new Error('Direct message conversation not found.');
@@ -875,6 +898,13 @@ export class DirectMessageService {
return conversation; return conversation;
} }
private async findConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
await this.loadForOwner(ownerId);
return this.conversationsSignal().find((entry) => entry.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
}
private requireCurrentUser(): User { private requireCurrentUser(): User {
const currentUser = this.currentUser(); const currentUser = this.currentUser();

View File

@@ -1,7 +1,9 @@
import type { import type {
DirectMessage, DirectMessage,
DirectMessageConversation, DirectMessageConversation,
DirectMessageEventPayload,
DirectMessageParticipant, DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageStatus DirectMessageStatus
} from '../models/direct-message.model'; } from '../models/direct-message.model';
@@ -74,6 +76,27 @@ export function isGroupDirectConversation(conversation: DirectMessageConversatio
return conversation.kind === 'group' || conversation.participants.length > 2; return conversation.kind === 'group' || conversation.participants.length > 2;
} }
export function directMessageConversationIncludesUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userId: string
): boolean {
return conversation.participants.includes(userId) || !!conversation.participantProfiles[userId];
}
export function directMessageEventIncludesUser(
payload: DirectMessageEventPayload,
userId: string
): boolean {
return collectDirectMessageEventParticipantIds(payload).has(userId);
}
export function directMessageSyncIncludesUser(
payload: DirectMessageSyncEventPayload,
userId: string
): boolean {
return payload.participants.some((participant) => participant.userId === userId);
}
export function upsertDirectMessage( export function upsertDirectMessage(
conversation: DirectMessageConversation, conversation: DirectMessageConversation,
message: DirectMessage, message: DirectMessage,
@@ -129,6 +152,30 @@ function uniqueDirectMessageParticipants(participants: DirectMessageParticipant[
}); });
} }
function collectDirectMessageEventParticipantIds(payload: DirectMessageEventPayload): Set<string> {
const participantIds = new Set<string>();
if (payload.message.senderId) {
participantIds.add(payload.message.senderId);
}
if (payload.message.recipientId) {
participantIds.add(payload.message.recipientId);
}
for (const recipientId of payload.message.recipientIds ?? []) {
participantIds.add(recipientId);
}
for (const participant of payload.participants ?? []) {
if (participant.userId) {
participantIds.add(participant.userId);
}
}
return participantIds;
}
function buildGroupConversationTitle(participants: DirectMessageParticipant[]): string { function buildGroupConversationTitle(participants: DirectMessageParticipant[]): string {
const names = participants.map((participant) => participant.displayName || participant.username || participant.userId); const names = participants.map((participant) => participant.displayName || participant.username || participant.userId);

View File

@@ -195,6 +195,7 @@ Additional runtime guards:
- Deleted messages never notify. - Deleted messages never notify.
- The current user's own messages never notify. - The current user's own messages never notify.
- Live room messages only notify when the message room is the current room or one of the user's saved rooms.
- Duplicate live events are suppressed with a rolling in-memory set of the last 500 notified message IDs. - Duplicate live events are suppressed with a rolling in-memory set of the last 500 notified message IDs.
- Unread badges are independent from mute state. Muting changes delivery only; it does not hide unread indicators. - Unread badges are independent from mute state. Muting changes delivery only; it does not hide unread indicators.

View File

@@ -172,6 +172,12 @@ export class NotificationsService {
return; return;
} }
const room = this.getKnownRoom(message.roomId);
if (!room) {
return;
}
this.rememberMessageId(message.id); this.rememberMessageId(message.id);
const channelId = resolveMessageChannelId(message); const channelId = resolveMessageChannelId(message);
@@ -198,7 +204,6 @@ export class NotificationsService {
return; return;
} }
const room = getRoomById(context.rooms, message.roomId);
const payload = buildNotificationDisplayPayload( const payload = buildNotificationDisplayPayload(
message, message,
room, room,
@@ -512,6 +517,11 @@ export class NotificationsService {
return this.getCurrentUserIds().has(senderId); return this.getCurrentUserIds().has(senderId);
} }
private getKnownRoom(roomId: string): Room | null {
return getRoomById(this.savedRooms(), roomId)
?? (this.currentRoom()?.id === roomId ? this.currentRoom() ?? null : null);
}
private setSettings(settings: NotificationsSettings): void { private setSettings(settings: NotificationsSettings): void {
this._settings.set(settings); this._settings.set(settings);
this.storage.save(settings); this.storage.save(settings);

View File

@@ -22,6 +22,8 @@ Plugins can communicate over a plugin-only message bus through `api.messageBus`.
Plugins can inspect the current interaction context through `api.context.getCurrent()`. Composer action callbacks also receive this context directly, including the local user, current chat server, active text channel, and the user's current voice channel when connected. Plugins with message access can call `api.messages.setTyping(true | false, channelId?)` and can observe peer typing state with `api.messages.subscribeTyping(handler)`, where typing events include the user, server, text channel, and voice channel when those records are available locally. Plugins can inspect the current interaction context through `api.context.getCurrent()`. Composer action callbacks also receive this context directly, including the local user, current chat server, active text channel, and the user's current voice channel when connected. Plugins with message access can call `api.messages.setTyping(true | false, channelId?)` and can observe peer typing state with `api.messages.subscribeTyping(handler)`, where typing events include the user, server, text channel, and voice channel when those records are available locally.
Plugins can add quick actions to the server sidebar's View plugins menu with `api.ui.registerToolbarAction(id, { icon, label, run })`. The menu is rendered from the room side-panel plugin area as an overlay grid, and callbacks receive a `toolbarAction` interaction context.
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback. Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id. Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.

View File

@@ -0,0 +1,62 @@
<div
appThemeNode="contextMenuSurface"
class="w-80 rounded-lg border border-border bg-card p-3 shadow-xl"
role="menu"
aria-label="Plugin actions"
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground">Plugins</p>
<p class="truncate text-xs text-muted-foreground">{{ actions().length }} available actions</p>
</div>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close plugin menu"
title="Close"
(click)="close()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
@if (actions().length > 0) {
<div class="grid max-h-80 grid-cols-3 gap-2 overflow-auto pr-1">
@for (record of actions(); track record.id) {
<button
type="button"
class="group flex min-h-20 flex-col items-center justify-start gap-2 rounded-md px-2 py-2 text-center transition-colors hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
role="menuitem"
[attr.aria-label]="actionTitle(record)"
[title]="actionTitle(record)"
(click)="runAction(record)"
>
<span
class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-md border border-border bg-secondary text-xs font-semibold text-foreground transition-colors group-hover:border-primary/40"
>
@if (isImageIcon(record)) {
<img
class="h-full w-full object-cover"
[src]="iconText(record)"
[alt]="record.contribution.label"
/>
} @else {
<span class="max-w-full truncate px-1">{{ iconText(record) }}</span>
}
</span>
<span class="line-clamp-2 min-h-8 text-[11px] font-medium leading-4 text-foreground">
{{ record.contribution.label }}
</span>
</button>
}
</div>
} @else {
<p class="rounded-md border border-dashed border-border bg-background/40 px-3 py-4 text-center text-sm text-muted-foreground">
No plugin actions available.
</p>
}
</div>

View File

@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
computed,
inject,
output
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import type { PluginApiActionContribution } from '../../domain/models/plugin-api.models';
import { PluginClientApiService } from '../../application/services/plugin-client-api.service';
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
import type { PluginUiContributionRecord } from '../../application/services/plugin-ui-registry.service';
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
import { ThemeNodeDirective } from '../../../theme';
@Component({
selector: 'app-plugin-action-menu',
standalone: true,
imports: [
CommonModule,
NgIcon,
ThemeNodeDirective
],
viewProviders: [provideIcons({ lucideX })],
templateUrl: './plugin-action-menu.component.html'
})
export class PluginActionMenuComponent {
readonly closed = output<undefined>();
private readonly logger = inject(PluginLoggerService);
private readonly pluginApi = inject(PluginClientApiService);
private readonly pluginRegistry = inject(PluginRegistryService);
private readonly pluginUi = inject(PluginUiRegistryService);
readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()]
.sort((left, right) => this.sortActionRecords(left, right)));
@HostListener('document:keydown.escape')
close(): void {
this.closed.emit(undefined);
}
runAction(record: PluginUiContributionRecord<PluginApiActionContribution>): void {
this.closed.emit(undefined);
void Promise.resolve()
.then(() => record.contribution.run(this.pluginApi.createActionContext('toolbarAction')))
.catch((error: unknown) => this.logger.error(record.pluginId, 'Toolbar action failed', error));
}
pluginName(pluginId: string): string {
return this.pluginRegistry.find(pluginId)?.manifest.title ?? pluginId;
}
actionTitle(record: PluginUiContributionRecord<PluginApiActionContribution>): string {
return `${this.pluginName(record.pluginId)}: ${record.contribution.label}`;
}
iconText(record: PluginUiContributionRecord<PluginApiActionContribution>): string {
const icon = record.contribution.icon?.trim();
if (icon) {
return icon;
}
return createInitials(this.pluginName(record.pluginId), record.contribution.label);
}
isImageIcon(record: PluginUiContributionRecord<PluginApiActionContribution>): boolean {
const icon = record.contribution.icon?.trim() ?? '';
return icon.startsWith('http://')
|| icon.startsWith('https://')
|| icon.startsWith('data:image/')
|| icon.startsWith('blob:');
}
private sortActionRecords(
left: PluginUiContributionRecord<PluginApiActionContribution>,
right: PluginUiContributionRecord<PluginApiActionContribution>
): number {
const leftPlugin = this.pluginName(left.pluginId);
const rightPlugin = this.pluginName(right.pluginId);
const pluginCompare = leftPlugin.localeCompare(rightPlugin);
if (pluginCompare !== 0) {
return pluginCompare;
}
return left.contribution.label.localeCompare(right.contribution.label);
}
}
function createInitials(pluginName: string, actionLabel: string): string {
const words = `${pluginName} ${actionLabel}`
.split(/[^a-zA-Z0-9]+/)
.filter((word) => word.length > 0);
if (words.length === 0) {
return 'PL';
}
return words
.slice(0, 2)
.map((word) => word.charAt(0).toUpperCase())
.join('');
}

View File

@@ -0,0 +1,139 @@
import {
ElementRef,
Injectable,
inject
} from '@angular/core';
import {
ConnectedPosition,
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
Subscription,
filter,
fromEvent
} from 'rxjs';
import { PluginActionMenuComponent } from './plugin-action-menu.component';
const GAP = 10;
const VIEWPORT_MARGIN = 8;
const POSITIONS: ConnectedPosition[] = [
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: GAP },
{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: GAP },
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -GAP },
{ originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX: -GAP }
];
@Injectable({ providedIn: 'root' })
export class PluginActionMenuService {
private readonly overlay = inject(Overlay);
private currentOrigin: HTMLElement | null = null;
private overlayRef: OverlayRef | null = null;
private overlaySubscriptions: Subscription | null = null;
private scrollBlocker: (() => void) | null = null;
open(origin: ElementRef | HTMLElement): void {
const rawEl = origin instanceof ElementRef ? origin.nativeElement : origin;
if (this.overlayRef) {
const sameOrigin = rawEl === this.currentOrigin;
this.close();
if (sameOrigin) {
return;
}
}
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
this.currentOrigin = rawEl;
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(elementRef)
.withPositions(POSITIONS)
.withViewportMargin(VIEWPORT_MARGIN)
.withPush(true);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop()
});
this.syncThemeVars();
const componentRef = this.overlayRef.attach(new ComponentPortal(PluginActionMenuComponent));
const subscriptions = new Subscription();
subscriptions.add(componentRef.instance.closed.subscribe(() => this.close()));
subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown')
.pipe(
filter((event) => {
const target = event.target as Node;
if (this.overlayRef?.overlayElement.contains(target)) {
return false;
}
if (this.currentOrigin?.contains(target)) {
return false;
}
return true;
})
)
.subscribe(() => this.close()));
this.overlaySubscriptions = subscriptions;
this.blockScroll();
}
close(): void {
this.scrollBlocker?.();
this.scrollBlocker = null;
this.overlaySubscriptions?.unsubscribe();
this.overlaySubscriptions = null;
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = null;
this.currentOrigin = null;
}
}
private blockScroll(): void {
const handler = (event: Event): void => {
if (this.overlayRef?.overlayElement.contains(event.target as Node)) {
return;
}
event.preventDefault();
};
const opts: AddEventListenerOptions = { passive: false, capture: true };
document.addEventListener('wheel', handler, opts);
document.addEventListener('touchmove', handler, opts);
this.scrollBlocker = () => {
document.removeEventListener('wheel', handler, opts);
document.removeEventListener('touchmove', handler, opts);
};
}
private syncThemeVars(): void {
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
if (!appRoot || !container) {
return;
}
for (const prop of Array.from(appRoot.style)) {
if (prop.startsWith('--')) {
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
}
}
}
}

View File

@@ -16,4 +16,5 @@ export * from './domain/logic/plugin-manifest-validation.logic';
export * from './domain/models/plugin-api.models'; export * from './domain/models/plugin-api.models';
export * from './domain/models/plugin-runtime.models'; export * from './domain/models/plugin-runtime.models';
export * from './domain/models/plugin-store.models'; export * from './domain/models/plugin-store.models';
export * from './feature/plugin-action-menu/plugin-action-menu.service';
export * from './infrastructure/local-plugin-discovery.service'; export * from './infrastructure/local-plugin-discovery.service';

View File

@@ -259,13 +259,26 @@
</div> </div>
</section> </section>
@if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) { @if (pluginChannelSections().length > 0 || pluginMenuActions().length > 0 || pluginSidePanels().length > 0) {
<section <section
class="border-t border-border px-2 py-3" class="border-t border-border px-2 py-3"
data-testid="plugin-room-side-panel" data-testid="plugin-room-side-panel"
> >
<div class="mb-2 px-1"> <div class="mb-2 flex items-center justify-between gap-2 px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4> <h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground"
aria-haspopup="menu"
title="View plugins"
(click)="openPluginActionMenu($event)"
>
<ng-icon
name="lucidePackage"
class="h-3.5 w-3.5"
/>
<span>View plugins</span>
</button>
</div> </div>
@if (pluginChannelSections().length > 0) { @if (pluginChannelSections().length > 0) {

View File

@@ -24,7 +24,8 @@ import {
lucideUsers, lucideUsers,
lucidePlus, lucidePlus,
lucideVolumeX, lucideVolumeX,
lucideGamepad2 lucideGamepad2,
lucidePackage
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors'; import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import { import {
@@ -53,7 +54,7 @@ import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service'; import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component'; import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { PluginRenderHostComponent } from '../../../domains/plugins/feature/plugin-render-host/plugin-render-host.component'; import { PluginRenderHostComponent } from '../../../domains/plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginUiRegistryService } from '../../../domains/plugins'; import { PluginActionMenuService, PluginUiRegistryService } from '../../../domains/plugins';
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules'; import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
import { import {
canManageMember, canManageMember,
@@ -108,7 +109,8 @@ type PanelMode = 'channels' | 'users';
lucideUsers, lucideUsers,
lucidePlus, lucidePlus,
lucideVolumeX, lucideVolumeX,
lucideGamepad2 lucideGamepad2,
lucidePackage
}) })
], ],
templateUrl: './rooms-side-panel.component.html' templateUrl: './rooms-side-panel.component.html'
@@ -127,6 +129,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
private profileCard = inject(ProfileCardService); private profileCard = inject(ProfileCardService);
private directMessages = inject(DirectMessageService); private directMessages = inject(DirectMessageService);
private readonly externalLinks = inject(ExternalLinkService); private readonly externalLinks = inject(ExternalLinkService);
private readonly pluginActionMenu = inject(PluginActionMenuService);
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService); private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private readonly pluginUi = inject(PluginUiRegistryService); private readonly pluginUi = inject(PluginUiRegistryService);
@@ -144,6 +147,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
textChannels = this.store.selectSignal(selectTextChannels); textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels);
pluginChannelSections = this.pluginUi.channelSectionRecords; pluginChannelSections = this.pluginUi.channelSectionRecords;
pluginMenuActions = this.pluginUi.toolbarActionRecords;
pluginSidePanels = this.pluginUi.sidePanelRecords; pluginSidePanels = this.pluginUi.sidePanelRecords;
localUserHasDesync = this.voiceConnectivity.localUserHasDesync; localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
roomMembers = computed(() => this.currentRoom()?.members ?? []); roomMembers = computed(() => this.currentRoom()?.members ?? []);
@@ -219,6 +223,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
clearInterval(this.activityTimer); clearInterval(this.activityTimer);
this.cancelQueuedProfileCardOpen(); this.cancelQueuedProfileCardOpen();
this.pluginActionMenu.close();
} }
gameActivityElapsed(user: User | null | undefined): string { gameActivityElapsed(user: User | null | undefined): string {
@@ -258,6 +263,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.queueProfileCardOpen(event.currentTarget as HTMLElement, this.roomMemberToUser(member), false); this.queueProfileCardOpen(event.currentTarget as HTMLElement, this.roomMemberToUser(member), false);
} }
openPluginActionMenu(event: Event): void {
event.stopPropagation();
this.cancelQueuedProfileCardOpen();
this.pluginActionMenu.open(event.currentTarget as HTMLElement);
}
async openDirectMessage(event: Event, user: User): Promise<void> { async openDirectMessage(event: Event, user: User): Promise<void> {
event.stopPropagation(); event.stopPropagation();
this.cancelQueuedProfileCardOpen(); this.cancelQueuedProfileCardOpen();

View File

@@ -123,6 +123,8 @@ Room affinity is authoritative at this layer as well. The renderer repairs each
Server-relayed fallbacks are intentionally narrow. Room chat (`chat_message`), direct-message events (`direct-message`, `direct-message-status`, `direct-message-mutation`), and voice presence (`voice_state`) may flow over signaling so users can still see written chat and voice roster state while P2P data channels are down. Media, attachments, message inventory sync, screen/camera state, and plugin data-channel traffic remain peer-plane responsibilities. Server-relayed fallbacks are intentionally narrow. Room chat (`chat_message`), direct-message events (`direct-message`, `direct-message-status`, `direct-message-mutation`), and voice presence (`voice_state`) may flow over signaling so users can still see written chat and voice roster state while P2P data channels are down. Media, attachments, message inventory sync, screen/camera state, and plugin data-channel traffic remain peer-plane responsibilities.
Room-scoped chat intake is also guarded on receipt: a `chat-message` or chat sync payload is processed only when its `roomId` is the current room or one of the user's saved rooms. This keeps broad peer meshes from creating unread state or notifications for unrelated chat-servers.
In UI/debug conversations, a **chat-server** means one of the saved rooms navigated from the server rail. Each chat-server has its own assigned signal server via `sourceId` / `sourceUrl`, and room-scoped feature/config checks must prefer that signal server before considering any global active endpoint. For example, KLIPY GIF picker visibility is resolved against the currently viewed chat-server's signal server so an unrelated offline chat-server does not hide the button everywhere. In UI/debug conversations, a **chat-server** means one of the saved rooms navigated from the server rail. Each chat-server has its own assigned signal server via `sourceId` / `sourceUrl`, and room-scoped feature/config checks must prefer that signal server before considering any global active endpoint. For example, KLIPY GIF picker visibility is resolved against the currently viewed chat-server's signal server so an unrelated offline chat-server does not hide the button everywhere.
Cold-start routing now waits for the initial server-directory health probes so same-backend aliases can collapse to one canonical signaling endpoint before any saved rooms reconnect. When a room is reconnected on a chosen socket, its background rooms are re-joined on that same socket as well so stale per-signal memberships do not keep orphan managers alive, and reconnect replay only sends `view_server` for rooms that manager still has joined. Cold-start routing now waits for the initial server-directory health probes so same-backend aliases can collapse to one canonical signaling endpoint before any saved rooms reconnect. When a room is reconnected on a chosen socket, its background rooms are re-joined on that same socket as well so stale per-signal memberships do not keep orphan managers alive, and reconnect replay only sends `view_server` for rooms that manager still has joined.

View File

@@ -29,6 +29,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
debugging: {}, debugging: {},
currentUser: null, currentUser: null,
currentRoom: null, currentRoom: null,
savedRooms: [],
...overrides ...overrides
} as const; } as const;
} }
@@ -42,7 +43,8 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
const context = createContext({ const context = createContext({
db: { getMessages }, db: { getMessages },
webrtc: { sendToPeer }, webrtc: { sendToPeer },
currentRoom: { id: 'room-a' } currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-b' }]
}); });
await firstValueFrom( await firstValueFrom(
@@ -76,7 +78,8 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
const context = createContext({ const context = createContext({
db: { getMessages }, db: { getMessages },
webrtc: { sendToPeer }, webrtc: { sendToPeer },
currentRoom: { id: 'room-a' } currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-b' }]
}); });
await firstValueFrom( await firstValueFrom(
@@ -97,4 +100,29 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
messages: roomBMessages messages: roomBMessages
}); });
}); });
it('ignores chat messages for rooms that are not saved or currently viewed', async () => {
const saveMessage = vi.fn(async () => undefined);
const rememberMessageRoom = vi.fn();
const context = createContext({
db: { saveMessage },
attachments: { rememberMessageRoom },
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-a' }]
});
const action = await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-message',
message: createMessage({ roomId: 'room-b' })
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(action).toBeNull();
expect(saveMessage).not.toHaveBeenCalled();
expect(rememberMessageRoom).not.toHaveBeenCalled();
});
}); });

View File

@@ -96,6 +96,7 @@ export interface IncomingMessageContext {
debugging: DebuggingService; debugging: DebuggingService;
currentUser: User | null; currentUser: User | null;
currentRoom: Room | null; currentRoom: Room | null;
savedRooms?: Room[];
} }
/** Signature for an incoming-message handler function. */ /** Signature for an incoming-message handler function. */
@@ -110,11 +111,12 @@ type MessageHandler = (
*/ */
function handleInventoryRequest( function handleInventoryRequest(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { db, webrtc, attachments } = ctx;
const { roomId, fromPeerId } = event; const { roomId, fromPeerId } = event;
if (!roomId || !fromPeerId) if (!roomId || !fromPeerId || !isKnownRoomId(roomId, ctx))
return EMPTY; return EMPTY;
return from( return from(
@@ -155,11 +157,12 @@ function handleInventoryRequest(
*/ */
function handleInventory( function handleInventory(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { db, webrtc, attachments } = ctx;
const { roomId, fromPeerId, items } = event; const { roomId, fromPeerId, items } = event;
if (!roomId || !Array.isArray(items) || !fromPeerId) if (!roomId || !Array.isArray(items) || !fromPeerId || !isKnownRoomId(roomId, ctx))
return EMPTY; return EMPTY;
return from( return from(
@@ -197,11 +200,12 @@ function handleInventory(
*/ */
function handleSyncRequestIds( function handleSyncRequestIds(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { db, webrtc, attachments } = ctx;
const { roomId, ids, fromPeerId } = event; const { roomId, ids, fromPeerId } = event;
if (!Array.isArray(ids) || !fromPeerId) if (!roomId || !Array.isArray(ids) || !fromPeerId || !isKnownRoomId(roomId, ctx))
return EMPTY; return EMPTY;
return from( return from(
@@ -210,7 +214,7 @@ function handleSyncRequestIds(
(ids as string[]).map((id) => db.getMessageById(id)) (ids as string[]).map((id) => db.getMessageById(id))
); );
const messages = maybeMessages.filter( const messages = maybeMessages.filter(
(msg): msg is Message => !!msg (msg): msg is Message => !!msg && msg.roomId === roomId
); );
const hydrated = await Promise.all( const hydrated = await Promise.all(
messages.map((msg) => hydrateMessage(msg, db)) messages.map((msg) => hydrateMessage(msg, db))
@@ -250,19 +254,26 @@ function handleSyncRequestIds(
*/ */
function handleSyncBatch( function handleSyncBatch(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!hasMessageBatch(event)) if (!hasMessageBatch(event))
return EMPTY; return EMPTY;
if (hasAttachmentMetaMap(event.attachments)) { const scopedEvent = scopeMessageBatchToKnownRooms(event, ctx);
if (!scopedEvent)
return EMPTY;
const { db, attachments } = ctx;
if (hasAttachmentMetaMap(scopedEvent.attachments)) {
attachments.registerSyncedAttachments( attachments.registerSyncedAttachments(
event.attachments, scopedEvent.attachments,
Object.fromEntries(event.messages.map((message) => [message.id, message.roomId])) Object.fromEntries(scopedEvent.messages.map((message) => [message.id, message.roomId]))
); );
} }
return from(processSyncBatch(event, db, attachments)).pipe( return from(processSyncBatch(scopedEvent, db, attachments)).pipe(
mergeMap((toUpsert) => mergeMap((toUpsert) =>
toUpsert.length > 0 toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert })) ? of(MessagesActions.syncMessages({ messages: toUpsert }))
@@ -316,18 +327,22 @@ function queueWatchedAttachmentDownloads(
/** Saves an incoming chat message to DB and dispatches receiveMessage. */ /** Saves an incoming chat message to DB and dispatches receiveMessage. */
function handleChatMessage( function handleChatMessage(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ ctx: IncomingMessageContext
): Observable<Action> {
const {
db, db,
debugging, debugging,
attachments, attachments,
currentUser currentUser
}: IncomingMessageContext } = ctx;
): Observable<Action> {
const msg = event.message; const msg = event.message;
if (!msg) if (!msg)
return EMPTY; return EMPTY;
if (!isKnownRoomId(msg.roomId, ctx))
return EMPTY;
// Skip our own messages (reflected via server relay) // Skip our own messages (reflected via server relay)
const isOwnMessage = const isOwnMessage =
msg.senderId === currentUser?.id || msg.senderId === currentUser?.id ||
@@ -536,11 +551,12 @@ function handleFileNotFound(
*/ */
function handleSyncSummary( function handleSyncSummary(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { db, webrtc, currentRoom } = ctx;
const targetRoomId = event.roomId || currentRoom?.id; const targetRoomId = event.roomId || currentRoom?.id;
if (!targetRoomId) if (!targetRoomId || !isKnownRoomId(targetRoomId, ctx))
return EMPTY; return EMPTY;
return from( return from(
@@ -575,12 +591,13 @@ function handleSyncSummary(
/** Responds to a peer's full sync request by sending all local messages. */ /** Responds to a peer's full sync request by sending all local messages. */
function handleSyncRequest( function handleSyncRequest(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { db, webrtc, currentRoom } = ctx;
const targetRoomId = event.roomId || currentRoom?.id; const targetRoomId = event.roomId || currentRoom?.id;
const fromPeerId = event.fromPeerId; const fromPeerId = event.fromPeerId;
if (!targetRoomId || !fromPeerId) if (!targetRoomId || !fromPeerId || !isKnownRoomId(targetRoomId, ctx))
return EMPTY; return EMPTY;
return from( return from(
@@ -600,12 +617,17 @@ function handleSyncRequest(
/** Merges a full message dump from a peer into the local DB and store. */ /** Merges a full message dump from a peer into the local DB and store. */
function handleSyncFull( function handleSyncFull(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!hasMessageBatch(event)) if (!hasMessageBatch(event))
return EMPTY; return EMPTY;
return from(processSyncBatch(event, db, attachments)).pipe( const scopedEvent = scopeMessageBatchToKnownRooms(event, ctx);
if (!scopedEvent)
return EMPTY;
return from(processSyncBatch(scopedEvent, ctx.db, ctx.attachments)).pipe(
mergeMap((toUpsert) => mergeMap((toUpsert) =>
toUpsert.length > 0 toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert })) ? of(MessagesActions.syncMessages({ messages: toUpsert }))
@@ -657,6 +679,49 @@ export function dispatchIncomingMessage(
return handler ? handler(event, ctx) : EMPTY; return handler ? handler(event, ctx) : EMPTY;
} }
function isKnownRoomId(roomId: string | undefined, ctx: IncomingMessageContext): boolean {
if (!roomId) {
return false;
}
return ctx.currentRoom?.id === roomId || (ctx.savedRooms ?? []).some((room) => room.id === roomId);
}
function scopeMessageBatchToKnownRooms(
event: SyncBatchEvent,
ctx: IncomingMessageContext
): SyncBatchEvent | null {
if (event.roomId && !isKnownRoomId(event.roomId, ctx)) {
return null;
}
const messages = event.messages.filter((message) => isKnownRoomId(message.roomId, ctx));
if (messages.length === 0) {
return null;
}
return {
...event,
attachments: filterAttachmentMapToMessages(event.attachments, messages),
messages
};
}
function filterAttachmentMapToMessages(
attachmentMap: IncomingMessageEvent['attachments'],
messages: Message[]
): AttachmentMetaMap | undefined {
if (!hasAttachmentMetaMap(attachmentMap)) {
return undefined;
}
const messageIds = new Set(messages.map((message) => message.id));
const filteredEntries = Object.entries(attachmentMap).filter(([messageId]) => messageIds.has(messageId));
return filteredEntries.length > 0 ? Object.fromEntries(filteredEntries) : undefined;
}
function trackBackgroundOperation( function trackBackgroundOperation(
task: Promise<unknown> | unknown, task: Promise<unknown> | unknown,
debugging: DebuggingService, debugging: DebuggingService,

View File

@@ -30,7 +30,7 @@ import {
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentUser } from '../users/users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { selectMessagesEntities } from './messages.selectors'; import { selectMessagesEntities } from './messages.selectors';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
@@ -457,12 +457,14 @@ export class MessagesEffects {
this.webrtc.onMessageReceived.pipe( this.webrtc.onMessageReceived.pipe(
withLatestFrom( withLatestFrom(
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
), ),
mergeMap(([ mergeMap(([
event, event,
currentUser, currentUser,
currentRoom currentRoom,
savedRooms
]) => { ]) => {
const ctx: IncomingMessageContext = { const ctx: IncomingMessageContext = {
db: this.db, db: this.db,
@@ -470,7 +472,8 @@ export class MessagesEffects {
attachments: this.attachments, attachments: this.attachments,
debugging: this.debugging, debugging: this.debugging,
currentUser: currentUser ?? null, currentUser: currentUser ?? null,
currentRoom currentRoom,
savedRooms
}; };
return dispatchIncomingMessage(event, ctx).pipe( return dispatchIncomingMessage(event, ctx).pipe(
@@ -502,12 +505,14 @@ export class MessagesEffects {
this.webrtc.onSignalingMessage.pipe( this.webrtc.onSignalingMessage.pipe(
withLatestFrom( withLatestFrom(
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
), ),
mergeMap(([ mergeMap(([
event, event,
currentUser, currentUser,
currentRoom currentRoom,
savedRooms
]) => { ]) => {
if (event.type !== 'chat_message') { if (event.type !== 'chat_message') {
return EMPTY; return EMPTY;
@@ -519,7 +524,8 @@ export class MessagesEffects {
attachments: this.attachments, attachments: this.attachments,
debugging: this.debugging, debugging: this.debugging,
currentUser: currentUser ?? null, currentUser: currentUser ?? null,
currentRoom currentRoom,
savedRooms
}; };
return dispatchIncomingMessage({ return dispatchIncomingMessage({

View File

@@ -20,9 +20,10 @@ import type {
} from '../../shared-kernel'; } from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { UsersActions } from '../users/users.actions'; import { UsersActions } from '../users/users.actions';
import { selectCurrentUser } from '../users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../users/users.selectors';
import { RoomsActions } from './rooms.actions'; import { RoomsActions } from './rooms.actions';
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import { normalizeRoomAccessControl, resolveLegacyRole } from '../../domains/access-control';
import { import {
areRoomMembersEqual, areRoomMembersEqual,
findRoomMember, findRoomMember,
@@ -113,6 +114,40 @@ export class RoomMembersSyncEffects {
) )
); );
/** Keep active-room user roles derived from room access-control assignments. */
syncAccessControlRolesIntoUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess,
RoomsActions.updateRoom
),
withLatestFrom(
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms),
this.store.select(selectAllUsers),
this.store.select(selectCurrentUser)
),
mergeMap(([
action,
currentRoom,
savedRooms,
allUsers,
currentUser
]) => {
const room = this.resolveRoleSyncRoom(action, currentRoom, savedRooms);
if (!room)
return EMPTY;
const actions = this.createUserRoleSyncActions(room, allUsers, currentUser ?? null);
return actions.length > 0 ? actions : EMPTY;
})
)
);
/** Update persisted room rosters when signaling presence changes arrive. */ /** Update persisted room rosters when signaling presence changes arrive. */
signalingPresenceIntoRoomMembers$ = createEffect(() => signalingPresenceIntoRoomMembers$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe( this.webrtc.onSignalingMessage.pipe(
@@ -342,6 +377,88 @@ export class RoomMembersSyncEffects {
: [RoomsActions.updateRoom({ roomId: room.id, changes: { members } })]; : [RoomsActions.updateRoom({ roomId: room.id, changes: { members } })];
} }
private resolveRoleSyncRoom(
action:
| ReturnType<typeof RoomsActions.createRoomSuccess>
| ReturnType<typeof RoomsActions.joinRoomSuccess>
| ReturnType<typeof RoomsActions.viewServerSuccess>
| ReturnType<typeof RoomsActions.updateRoom>,
currentRoom: Room | null,
savedRooms: Room[]
): Room | null {
if ('room' in action) {
return normalizeRoomAccessControl(action.room);
}
if (currentRoom?.id !== action.roomId || !this.hasRoleRelevantRoomChanges(action.changes)) {
return null;
}
const room = this.resolveRoom(action.roomId, currentRoom, savedRooms);
return room ? normalizeRoomAccessControl({ ...room, ...action.changes }) : null;
}
private hasRoleRelevantRoomChanges(changes: Partial<Room>): boolean {
return (
Object.prototype.hasOwnProperty.call(changes, 'hostId') ||
Object.prototype.hasOwnProperty.call(changes, 'members') ||
Object.prototype.hasOwnProperty.call(changes, 'permissions') ||
Object.prototype.hasOwnProperty.call(changes, 'roles') ||
Object.prototype.hasOwnProperty.call(changes, 'roleAssignments') ||
Object.prototype.hasOwnProperty.call(changes, 'channelPermissions') ||
Object.prototype.hasOwnProperty.call(changes, 'slowModeInterval')
);
}
private createUserRoleSyncActions(room: Room, allUsers: User[], currentUser: User | null): Action[] {
const usersById = new Map<string, User>();
for (const user of allUsers) {
if (this.shouldSyncUserRoleForRoom(room, user, currentUser)) {
usersById.set(user.id, user);
}
}
if (currentUser) {
usersById.set(currentUser.id, currentUser);
}
return Array.from(usersById.values())
.map((user) => ({
user,
role: resolveLegacyRole(room, user)
}))
.filter(({ user, role }) => user.role !== role)
.map(({ user, role }) => UsersActions.updateUserRole({ userId: user.id,
role }));
}
private shouldSyncUserRoleForRoom(room: Room, user: User, currentUser: User | null): boolean {
if (currentUser && user.id === currentUser.id) {
return true;
}
if (room.hostId === user.id || room.hostId === user.oderId) {
return true;
}
if (Array.isArray(user.presenceServerIds) && user.presenceServerIds.includes(room.id)) {
return true;
}
if (findRoomMember(room.members ?? [], user.oderId || user.id)) {
return true;
}
return (room.roleAssignments ?? []).some((assignment) => (
assignment.userId === user.id ||
assignment.userId === user.oderId ||
assignment.oderId === user.id ||
assignment.oderId === user.oderId
));
}
private handleMemberRosterRequest( private handleMemberRosterRequest(
event: ChatEvent, event: ChatEvent,
currentRoom: Room | null, currentRoom: Room | null,