feat: Add slashcommand api
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { decodeBase64ToUint8Array } from './attachment-blob.rules';
|
||||
|
||||
@@ -6,6 +10,10 @@ describe('attachment blob rules', () => {
|
||||
it('decodes base64 payloads into byte arrays', () => {
|
||||
const bytes = decodeBase64ToUint8Array('QUJD');
|
||||
|
||||
expect(Array.from(bytes)).toEqual([65, 66, 67]);
|
||||
expect(Array.from(bytes)).toEqual([
|
||||
65,
|
||||
66,
|
||||
67
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { annotateLocalFilePath, resolveLocalFilePath } from './local-file-path.r
|
||||
describe('local file path rules', () => {
|
||||
it('prefers an existing path property on the file', () => {
|
||||
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
|
||||
|
||||
Object.defineProperty(file, 'path', { value: '/tmp/clip.mp4' });
|
||||
|
||||
expect(resolveLocalFilePath(file)).toBe('/tmp/clip.mp4');
|
||||
|
||||
@@ -144,6 +144,10 @@ The chat domain consumes the custom emoji picker from `domains/custom-emoji`. Me
|
||||
|
||||
Custom image emoji are stored locally through `DatabaseService` and sync peer-to-peer with `custom-emoji-summary`, `custom-emoji-request`, `custom-emoji-full`, and `custom-emoji-chunk` data-channel events. Uploads use the same image types as profile avatars (`.webp`, `.gif`, `.jpg`, `.jpeg`) and are capped at 1 MB. The composer inserts saved custom emoji as readable inline aliases such as `:party:`, so they can sit in the middle of text like `This is :party: cool`; sending rewrites known aliases to stable `:emoji[id](name)` tokens and proactively pushes the referenced assets to connected peers alongside the outgoing message, edit, or reaction. Rendering resolves stable tokens against synced known assets and shows a sized placeholder image until the asset arrives; deferred markdown placeholders use readable `:name:` aliases instead of raw tokens. A repair request is still sent if a token is seen without a local asset. Seen remote emoji do not enter the picker automatically; right-click a custom emoji in chat or on a custom emoji reaction and choose **Add to emoji library** from the context menu. Right-click a saved custom emoji inside the picker to remove it from the local library. The full picker includes search that filters Unicode emoji by common terms and saved custom emoji by name.
|
||||
|
||||
### Slash commands
|
||||
|
||||
The composer renders a Discord-style autocomplete menu when the user types `/`. Results merge first-party built-in commands with plugin-registered commands (`PluginUiRegistryService.slashCommandRecords`), filtered by surface (`commandSurface` input: `server` exposes global + server commands, `direct` exposes only global) and by query. Built-in commands live in `chat-builtin-slash-commands.rules.ts`; each defines fixed `text` that is sent as a normal chat message through the composer's `messageSubmitted` output. The default built-in is `/lenny`, which posts `( ͡° ͜ʖ ͡°)`. Plugin commands run their own `run` callback instead. Picking a command with no options runs it immediately; a command with options pre-fills `/name ` for argument entry. Slash input is intercepted and never posted verbatim; `/text` that matches no command falls through as a normal message. See the plugins domain README for the `api.commands` registration contract.
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
BUILT_IN_SLASH_COMMANDS,
|
||||
BUILT_IN_SLASH_COMMAND_SOURCE,
|
||||
buildBuiltInSlashCommandEntries
|
||||
} from './chat-builtin-slash-commands.rules';
|
||||
|
||||
describe('built-in slash commands', () => {
|
||||
it('includes a global lenny command that sends the Lenny face', () => {
|
||||
const lenny = BUILT_IN_SLASH_COMMANDS.find((command) => command.name === 'lenny');
|
||||
|
||||
expect(lenny?.text).toBe('( ͡° ͜ʖ ͡°)');
|
||||
});
|
||||
|
||||
it('adapts definitions to global slash command entries tagged as built-in', () => {
|
||||
const entries = buildBuiltInSlashCommandEntries(() => {});
|
||||
const lenny = entries.find((entry) => entry.contribution.name === 'lenny');
|
||||
|
||||
expect(lenny?.pluginId).toBe(BUILT_IN_SLASH_COMMAND_SOURCE);
|
||||
expect(lenny?.id).toBe(`${BUILT_IN_SLASH_COMMAND_SOURCE}:lenny`);
|
||||
expect(lenny?.contribution.scope).toBe('global');
|
||||
});
|
||||
|
||||
it('runs the command by sending its text', () => {
|
||||
const sendText = vi.fn();
|
||||
const entries = buildBuiltInSlashCommandEntries(sendText);
|
||||
|
||||
entries.find((entry) => entry.contribution.name === 'lenny')?.contribution.run({
|
||||
args: {},
|
||||
command: 'lenny',
|
||||
rawArgs: '',
|
||||
server: null,
|
||||
source: 'slashCommand',
|
||||
textChannel: null,
|
||||
user: null,
|
||||
voiceChannel: null
|
||||
});
|
||||
|
||||
expect(sendText).toHaveBeenCalledWith('( ͡° ͜ʖ ͡°)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SlashCommandEntry } from '../../../../../plugins';
|
||||
|
||||
/** Source label shown for built-in commands in the slash command menu. */
|
||||
export const BUILT_IN_SLASH_COMMAND_SOURCE = 'Built-in';
|
||||
|
||||
/** A first-party slash command that inserts fixed text into the chat as a message. */
|
||||
export interface BuiltInSlashCommand {
|
||||
description: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default commands available everywhere (chat servers and direct messages),
|
||||
* without requiring any plugin to be installed.
|
||||
*/
|
||||
export const BUILT_IN_SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
|
||||
{
|
||||
name: 'lenny',
|
||||
description: 'Send the Lenny face ( ͡° ͜ʖ ͡°)',
|
||||
text: '( ͡° ͜ʖ ͡°)'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Adapts the built-in command definitions to the `SlashCommandEntry` shape used
|
||||
* by the composer menu. Each entry's `run` sends the command's text through the
|
||||
* provided callback so it posts as a normal chat message.
|
||||
*/
|
||||
export function buildBuiltInSlashCommandEntries(sendText: (text: string) => void): SlashCommandEntry[] {
|
||||
return BUILT_IN_SLASH_COMMANDS.map((command) => ({
|
||||
contribution: {
|
||||
description: command.description,
|
||||
icon: command.icon,
|
||||
name: command.name,
|
||||
run: () => sendText(command.text),
|
||||
scope: 'global'
|
||||
},
|
||||
id: `${BUILT_IN_SLASH_COMMAND_SOURCE}:${command.name}`,
|
||||
pluginId: BUILT_IN_SLASH_COMMAND_SOURCE
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<div
|
||||
#composerRoot
|
||||
class="min-w-0 w-full"
|
||||
class="relative min-w-0 w-full"
|
||||
>
|
||||
@if (slashMenuOpen()) {
|
||||
<div class="pointer-events-auto absolute bottom-full left-0 right-0 z-30 mb-2 px-3 sm:px-4">
|
||||
<app-chat-slash-command-menu
|
||||
[commands]="slashCommandResults()"
|
||||
[activeIndex]="slashActiveIndex()"
|
||||
(commandPicked)="pickSlashCommand($event)"
|
||||
(activeIndexChanged)="onSlashActiveIndexChanged($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (replyTo()) {
|
||||
<div
|
||||
appThemeNode="chatComposerReplyBar"
|
||||
@@ -320,8 +331,9 @@
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
(keydown)="onComposerKeydown($event)"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
(input)="onInputChange(); autoResizeTextarea()"
|
||||
(input)="onInputChange(); autoResizeTextarea(); updateSlashCommandMenu()"
|
||||
(paste)="onPaste($event)"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
|
||||
@@ -30,7 +30,15 @@ import { Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
PluginApiActionContribution,
|
||||
PluginClientApiService,
|
||||
PluginUiRegistryService
|
||||
PluginUiRegistryService,
|
||||
filterSlashCommands,
|
||||
findSlashCommand,
|
||||
parseSlashCommandArguments,
|
||||
parseSlashCommandInput,
|
||||
parseSlashCommandQuery,
|
||||
selectAvailableSlashCommands,
|
||||
type SlashCommandEntry,
|
||||
type SlashCommandSurface
|
||||
} from '../../../../../plugins';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||
@@ -54,6 +62,8 @@ import {
|
||||
shouldMergeComposerMediaActions,
|
||||
type ComposerMediaMenuAction
|
||||
} from './composer-media-menu.rules';
|
||||
import { ChatSlashCommandMenuComponent } from './chat-slash-command-menu.component';
|
||||
import { buildBuiltInSlashCommandEntries } from './chat-builtin-slash-commands.rules';
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
@@ -72,7 +82,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
||||
CustomEmojiPickerComponent,
|
||||
TypingIndicatorComponent,
|
||||
ThemeNodeDirective,
|
||||
BottomSheetComponent
|
||||
BottomSheetComponent,
|
||||
ChatSlashCommandMenuComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -104,6 +115,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly klipyEnabled = input(false);
|
||||
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
||||
readonly textareaTestId = input<string | null>(null);
|
||||
readonly commandSurface = input<SlashCommandSurface>('server');
|
||||
|
||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||
readonly typingStarted = output();
|
||||
@@ -138,6 +150,21 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly showEmojiPicker = signal(false);
|
||||
readonly emojiButton = signal('🙂');
|
||||
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
||||
readonly slashQuery = signal<string | null>(null);
|
||||
readonly slashActiveIndex = signal(0);
|
||||
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries((text) => this.sendBuiltInSlashText(text));
|
||||
readonly availableSlashCommands = computed(() =>
|
||||
selectAvailableSlashCommands(
|
||||
[...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()],
|
||||
this.commandSurface()
|
||||
)
|
||||
);
|
||||
readonly slashCommandResults = computed(() => {
|
||||
const query = this.slashQuery();
|
||||
|
||||
return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query);
|
||||
});
|
||||
readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0);
|
||||
readonly toolbarVisible = signal(false);
|
||||
readonly dragActive = signal(false);
|
||||
readonly inputHovered = signal(false);
|
||||
@@ -166,6 +193,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
|
||||
if (this.maybeRunSlashCommand(raw))
|
||||
return;
|
||||
|
||||
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
||||
return;
|
||||
|
||||
@@ -206,6 +236,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
if (keyEvent.shiftKey)
|
||||
return;
|
||||
|
||||
if (this.slashMenuOpen())
|
||||
return;
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
@@ -348,6 +381,146 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
.then(() => action.run(this.pluginApi.createActionContext('composerAction')));
|
||||
}
|
||||
|
||||
updateSlashCommandMenu(): void {
|
||||
const query = parseSlashCommandQuery(this.messageContent);
|
||||
|
||||
this.slashQuery.set(query);
|
||||
this.slashActiveIndex.set(0);
|
||||
}
|
||||
|
||||
closeSlashCommandMenu(): void {
|
||||
this.slashQuery.set(null);
|
||||
}
|
||||
|
||||
onComposerKeydown(event: KeyboardEvent): void {
|
||||
if (!this.slashMenuOpen())
|
||||
return;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.moveSlashActive(1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.moveSlashActive(-1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.closeSlashCommandMenu();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
const active = this.activeSlashCommand();
|
||||
|
||||
if (active) {
|
||||
event.preventDefault();
|
||||
this.pickSlashCommand(active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSlashActiveIndexChanged(index: number): void {
|
||||
this.slashActiveIndex.set(index);
|
||||
}
|
||||
|
||||
pickSlashCommand(entry: SlashCommandEntry): void {
|
||||
const hasOptions = (entry.contribution.options?.length ?? 0) > 0;
|
||||
|
||||
if (hasOptions) {
|
||||
this.messageContent = `/${entry.contribution.name} `;
|
||||
this.closeSlashCommandMenu();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (element) {
|
||||
const caret = this.messageContent.length;
|
||||
|
||||
element.focus();
|
||||
element.selectionStart = caret;
|
||||
element.selectionEnd = caret;
|
||||
}
|
||||
|
||||
this.autoResizeTextarea();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.executeSlashCommand(entry, '');
|
||||
this.resetComposerAfterCommand();
|
||||
}
|
||||
|
||||
private maybeRunSlashCommand(raw: string): boolean {
|
||||
const parsed = parseSlashCommandInput(raw);
|
||||
|
||||
if (!parsed)
|
||||
return false;
|
||||
|
||||
const entry = findSlashCommand(this.availableSlashCommands(), parsed.name);
|
||||
|
||||
if (!entry)
|
||||
return false;
|
||||
|
||||
this.executeSlashCommand(entry, parsed.rawArgs);
|
||||
this.resetComposerAfterCommand();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sendBuiltInSlashText(text: string): void {
|
||||
this.messageSubmitted.emit({
|
||||
content: text,
|
||||
pendingFiles: []
|
||||
});
|
||||
|
||||
this.replyCleared.emit();
|
||||
}
|
||||
|
||||
private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void {
|
||||
const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []);
|
||||
const context = this.pluginApi.createSlashCommandContext({
|
||||
args,
|
||||
command: entry.contribution.name,
|
||||
rawArgs
|
||||
});
|
||||
|
||||
void Promise.resolve().then(() => entry.contribution.run(context));
|
||||
}
|
||||
|
||||
private resetComposerAfterCommand(): void {
|
||||
this.messageContent = '';
|
||||
this.closeSlashCommandMenu();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.autoResizeTextarea();
|
||||
this.messageInputRef?.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private moveSlashActive(delta: number): void {
|
||||
const total = this.slashCommandResults().length;
|
||||
|
||||
if (total === 0)
|
||||
return;
|
||||
|
||||
this.slashActiveIndex.update((current) => (current + delta + total) % total);
|
||||
}
|
||||
|
||||
private activeSlashCommand(): SlashCommandEntry | null {
|
||||
const results = this.slashCommandResults();
|
||||
|
||||
return results[this.slashActiveIndex()] ?? results[0] ?? null;
|
||||
}
|
||||
|
||||
getKlipyTriggerRect(): DOMRect | null {
|
||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||
}
|
||||
@@ -484,6 +657,8 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
if (!this.toolbarHovering) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
|
||||
this.closeSlashCommandMenu();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
@@ -702,10 +877,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
lastModified: payload.lastModified,
|
||||
type: payload.mime
|
||||
});
|
||||
const payloadPath = payload.path;
|
||||
|
||||
return annotateLocalFilePath(file, {
|
||||
getPathForFile: payload.path
|
||||
? () => payload.path!
|
||||
getPathForFile: payloadPath
|
||||
? () => payloadPath
|
||||
: this.electronBridge.getApi()?.getPathForFile
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
@if (commands().length > 0) {
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-border bg-card/95 shadow-2xl shadow-black/30 backdrop-blur-xl"
|
||||
role="listbox"
|
||||
aria-label="Slash commands"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border/60 px-4 py-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Commands</span>
|
||||
<span class="text-[11px] text-muted-foreground">{{ commands().length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-72 overflow-y-auto py-1">
|
||||
@for (entry of commands(); track entry.id; let index = $index) {
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
[attr.aria-selected]="index === activeIndex()"
|
||||
[attr.data-slash-active]="index === activeIndex()"
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors"
|
||||
[class.bg-secondary]="index === activeIndex()"
|
||||
(mouseenter)="hover(index)"
|
||||
(mousedown)="$event.preventDefault(); pick(entry)"
|
||||
>
|
||||
<span
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-border/70 bg-secondary/60 text-sm font-semibold text-foreground"
|
||||
>
|
||||
{{ badgeLabel(entry) }}
|
||||
</span>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-baseline gap-2">
|
||||
<span class="truncate font-semibold text-foreground">/{{ entry.contribution.name }}</span>
|
||||
@if (usage(entry)) {
|
||||
<span class="truncate font-mono text-xs text-muted-foreground">{{ usage(entry) }}</span>
|
||||
}
|
||||
</span>
|
||||
@if (entry.contribution.description) {
|
||||
<span class="mt-0.5 block truncate text-xs text-muted-foreground">{{ entry.contribution.description }}</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="hidden shrink-0 rounded-full border border-border/60 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-muted-foreground sm:inline"
|
||||
>
|
||||
{{ entry.pluginId }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import type { PluginApiSlashCommandOption } from '../../../../../plugins';
|
||||
import type { SlashCommandEntry } from '../../../../../plugins';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-slash-command-menu',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './chat-slash-command-menu.component.html',
|
||||
host: {
|
||||
class: 'block'
|
||||
}
|
||||
})
|
||||
export class ChatSlashCommandMenuComponent {
|
||||
readonly commands = input<SlashCommandEntry[]>([]);
|
||||
readonly activeIndex = input<number>(0);
|
||||
|
||||
readonly commandPicked = output<SlashCommandEntry>();
|
||||
readonly activeIndexChanged = output<number>();
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Re-run whenever the active row changes so it stays visible while
|
||||
// navigating the list with the keyboard.
|
||||
this.activeIndex();
|
||||
queueMicrotask(() => this.scrollActiveIntoView());
|
||||
});
|
||||
}
|
||||
|
||||
badgeLabel(entry: SlashCommandEntry): string {
|
||||
return entry.contribution.icon?.trim() || entry.contribution.name.charAt(0).toUpperCase() || '/';
|
||||
}
|
||||
|
||||
usage(entry: SlashCommandEntry): string {
|
||||
return (entry.contribution.options ?? [])
|
||||
.map((option) => this.formatOption(option))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
pick(entry: SlashCommandEntry): void {
|
||||
this.commandPicked.emit(entry);
|
||||
}
|
||||
|
||||
hover(index: number): void {
|
||||
this.activeIndexChanged.emit(index);
|
||||
}
|
||||
|
||||
private formatOption(option: PluginApiSlashCommandOption): string {
|
||||
return option.required ? `<${option.name}>` : `[${option.name}]`;
|
||||
}
|
||||
|
||||
private scrollActiveIntoView(): void {
|
||||
const active = this.host.nativeElement.querySelector<HTMLElement>('[data-slash-active="true"]');
|
||||
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ChatMarkdownService } from './chat-markdown.service';
|
||||
|
||||
describe('ChatMarkdownService', () => {
|
||||
let service: ChatMarkdownService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ChatMarkdownService();
|
||||
});
|
||||
|
||||
describe('applyInline', () => {
|
||||
it('wraps selected text with the token', () => {
|
||||
const result = service.applyInline('hello world', { start: 6, end: 11 }, '**');
|
||||
|
||||
expect(result.text).toBe('hello **world**');
|
||||
expect(result.selectionStart).toBe(result.selectionEnd);
|
||||
expect(result.selectionStart).toBe(result.text.length);
|
||||
});
|
||||
|
||||
it('inserts empty token pair with the cursor between markers when nothing is selected', () => {
|
||||
const result = service.applyInline('', { start: 0, end: 0 }, '**');
|
||||
|
||||
expect(result.text).toBe('****');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPrefix', () => {
|
||||
it('prefixes each selected line', () => {
|
||||
const result = service.applyPrefix('line one\nline two', { start: 0, end: 17 }, '> ');
|
||||
|
||||
expect(result.text).toBe('> line one\n> line two');
|
||||
expect(result.selectionStart).toBe(result.text.length);
|
||||
});
|
||||
|
||||
it('inserts only the prefix with the cursor after it when nothing is selected', () => {
|
||||
const result = service.applyPrefix('', { start: 0, end: 0 }, '> ');
|
||||
|
||||
expect(result.text).toBe('> ');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHeading', () => {
|
||||
it('wraps selected text as a heading', () => {
|
||||
const result = service.applyHeading('intro\nTitle here\noutro', { start: 6, end: 16 }, 2);
|
||||
|
||||
expect(result.text).toBe('intro\n## Title here\noutro');
|
||||
});
|
||||
|
||||
it('inserts only the heading marker and space when nothing is selected', () => {
|
||||
const result = service.applyHeading('', { start: 0, end: 0 }, 1);
|
||||
|
||||
expect(result.text).toBe('# ');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOrderedList', () => {
|
||||
it('numbers each selected line', () => {
|
||||
const result = service.applyOrderedList('alpha\nbeta', { start: 0, end: 10 });
|
||||
|
||||
expect(result.text).toBe('1. alpha\n2. beta');
|
||||
});
|
||||
|
||||
it('inserts only the first list marker when nothing is selected', () => {
|
||||
const result = service.applyOrderedList('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('1. ');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCodeBlock', () => {
|
||||
it('wraps selected text in a fenced code block', () => {
|
||||
const result = service.applyCodeBlock('before\nconst x = 1;\nafter', { start: 7, end: 19 });
|
||||
|
||||
expect(result.text).toBe('before\n```\nconst x = 1;\n```\n\n\nafter');
|
||||
});
|
||||
|
||||
it('inserts an empty fenced block with the cursor inside when nothing is selected', () => {
|
||||
const result = service.applyCodeBlock('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('```\n\n```\n\n');
|
||||
expect(result.selectionStart).toBe(4);
|
||||
expect(result.selectionEnd).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLink', () => {
|
||||
it('wraps selected text as link label and places the cursor in the url slot', () => {
|
||||
const result = service.applyLink('Visit docs', { start: 6, end: 10 });
|
||||
|
||||
expect(result.text).toBe('Visit [docs]()');
|
||||
expect(result.selectionStart).toBe(13);
|
||||
expect(result.selectionEnd).toBe(13);
|
||||
});
|
||||
|
||||
it('inserts empty link syntax with the cursor inside the label when nothing is selected', () => {
|
||||
const result = service.applyLink('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('[]()');
|
||||
expect(result.selectionStart).toBe(1);
|
||||
expect(result.selectionEnd).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyImage', () => {
|
||||
it('wraps selected text as image alt text and places the cursor in the url slot', () => {
|
||||
const result = service.applyImage('logo', { start: 0, end: 4 });
|
||||
|
||||
expect(result.text).toBe('![logo]()');
|
||||
expect(result.selectionStart).toBe(8);
|
||||
expect(result.selectionEnd).toBe(8);
|
||||
});
|
||||
|
||||
it('inserts empty image syntax with the cursor inside the alt text when nothing is selected', () => {
|
||||
const result = service.applyImage('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('![]()');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHorizontalRule', () => {
|
||||
it('inserts a horizontal rule without placeholder text', () => {
|
||||
const result = service.applyHorizontalRule('hello', { start: 5, end: 5 });
|
||||
|
||||
expect(result.text).toBe('hello\n\n---\n\n');
|
||||
expect(result.selectionStart).toBe(result.text.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,12 @@ export class ChatMarkdownService {
|
||||
applyInline(content: string, selection: SelectionRange, token: string): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'text';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + token.length
|
||||
: before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText,
|
||||
selectionStart: cursor,
|
||||
@@ -29,7 +31,7 @@ export class ChatMarkdownService {
|
||||
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'text';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const lines = selected.split('\n').map(line => `${prefix}${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
@@ -45,13 +47,13 @@ export class ChatMarkdownService {
|
||||
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'Heading';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
||||
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
|
||||
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
const cursor = before.length + block.length - (needsTrailingNewline ? 1 : 0);
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
@@ -61,7 +63,7 @@ export class ChatMarkdownService {
|
||||
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'item\nitem';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
@@ -76,11 +78,15 @@ export class ChatMarkdownService {
|
||||
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'code';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const fenced = selected.length === 0
|
||||
? '```\n\n```\n\n'
|
||||
: `\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + 4
|
||||
: before.length + fenced.length;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
@@ -90,30 +96,33 @@ export class ChatMarkdownService {
|
||||
applyLink(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'link';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const link = `[${selected}](https://)`;
|
||||
const link = `[${selected}]()`;
|
||||
const text = `${before}${link}${after}`;
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + 1
|
||||
: before.length + 1 + selected.length + 2;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyImage(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'alt';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const img = ``;
|
||||
const img = `![${selected}]()`;
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + 2
|
||||
: before.length + 2 + selected.length + 2;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
|
||||
|
||||
@@ -81,12 +81,13 @@ export const UNICODE_EMOJI_PICKER_ENTRIES: readonly UnicodeEmojiPickerEntry[] =
|
||||
|
||||
export const DEFAULT_UNICODE_EMOJIS = UNICODE_EMOJI_PICKER_ENTRIES.map((entry) => entry.emoji);
|
||||
|
||||
export type ChatTextSegment = {
|
||||
export interface ChatTextSegment {
|
||||
kind: 'text' | 'emoji';
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
const UNICODE_EMOJI_IN_TEXT_PATTERN = /\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)*|[#*0-9]\uFE0F?\u20E3/gu;
|
||||
const UNICODE_EMOJI_IN_TEXT_PATTERN =
|
||||
/\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)*|[#*0-9]\uFE0F?\u20E3/gu;
|
||||
|
||||
export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
if (!text) {
|
||||
@@ -94,6 +95,7 @@ export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
}
|
||||
|
||||
const segments: ChatTextSegment[] = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(UNICODE_EMOJI_IN_TEXT_PATTERN)) {
|
||||
@@ -106,6 +108,7 @@ export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
|
||||
segments.push({ kind: 'emoji',
|
||||
value: match[0] });
|
||||
|
||||
lastIndex = index + match[0].length;
|
||||
}
|
||||
|
||||
@@ -114,8 +117,10 @@ export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ kind: 'text',
|
||||
value: text }];
|
||||
return segments.length > 0 ? segments : [
|
||||
{ kind: 'text',
|
||||
value: text }
|
||||
];
|
||||
}
|
||||
|
||||
export interface CustomEmojiFileLike {
|
||||
|
||||
@@ -9,10 +9,7 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import {
|
||||
MobileCallSessionService,
|
||||
MobileNotificationsService
|
||||
} from '../../../../infrastructure/mobile';
|
||||
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
|
||||
@@ -10,7 +10,11 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
MobileCallSessionService,
|
||||
MobileMediaService,
|
||||
MobileNotificationsService
|
||||
} from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
@@ -43,6 +47,7 @@ export class DirectCallService {
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||
private readonly mobileCallSession = inject(MobileCallSessionService);
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
||||
@@ -324,6 +329,12 @@ export class DirectCallService {
|
||||
return;
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
[klipyEnabled]="klipyEnabled()"
|
||||
[klipySignalSource]="null"
|
||||
[textareaTestId]="'dm-input'"
|
||||
[commandSurface]="'direct'"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(typingStarted)="handleTypingStarted()"
|
||||
(replyCleared)="clearReply()"
|
||||
|
||||
@@ -19,7 +19,7 @@ import { DmChatComponent } from '../dm-chat/dm-chat.component';
|
||||
templateUrl: './dm-chat-panel.component.html'
|
||||
})
|
||||
export class DmChatPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
|
||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||
|
||||
private readonly theme = inject(ThemeService);
|
||||
}
|
||||
|
||||
@@ -40,10 +40,7 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
dispatch: vi.fn()
|
||||
} as unknown as Store;
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
FindPeopleComponent,
|
||||
{ provide: Store, useValue: store }
|
||||
]
|
||||
providers: [FindPeopleComponent, { provide: Store, useValue: store }]
|
||||
});
|
||||
const component = runInInjectionContext(injector, () => injector.get(FindPeopleComponent));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
|
||||
import type { Friend } from '../domain/models/direct-message.model';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
|
||||
|
||||
const STORAGE_PREFIX = 'metoyou_direct_message_queue';
|
||||
|
||||
@@ -17,13 +17,13 @@ const DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS: ExperimentalMediaSettings = {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExperimentalMediaSettingsService {
|
||||
private readonly vlcRuntime = inject(ExperimentalVlcRuntimeService);
|
||||
private readonly storedSettings = loadExperimentalMediaSettings();
|
||||
|
||||
readonly vlcJsPlaybackEnabled = signal(false);
|
||||
readonly vlcJsRuntimeAvailable = signal(false);
|
||||
readonly vlcJsRuntimeStatus = signal<'checking' | 'available' | 'missing'>('checking');
|
||||
|
||||
private readonly vlcRuntime = inject(ExperimentalVlcRuntimeService);
|
||||
private readonly storedSettings = loadExperimentalMediaSettings();
|
||||
|
||||
constructor() {
|
||||
void this.refreshVlcRuntimeStatus();
|
||||
}
|
||||
|
||||
@@ -40,15 +40,15 @@ export class ExperimentalVlcPlayerComponent implements AfterViewInit, OnDestroy
|
||||
mime = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
|
||||
closed = output<void>();
|
||||
downloadRequested = output<void>();
|
||||
|
||||
private readonly runtime = inject(ExperimentalVlcRuntimeService);
|
||||
private playerHandle: ExperimentalVlcPlayerHandle | null = null;
|
||||
closed = output();
|
||||
downloadRequested = output();
|
||||
|
||||
readonly status = signal<'loading' | 'ready' | 'error'>('loading');
|
||||
readonly errorMessage = signal('');
|
||||
|
||||
private readonly runtime = inject(ExperimentalVlcRuntimeService);
|
||||
private playerHandle: ExperimentalVlcPlayerHandle | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
void this.loadPlayer();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../../core/constants';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
|
||||
|
||||
@@ -24,6 +24,8 @@ Plugins can inspect the current interaction context through `api.context.getCurr
|
||||
|
||||
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.
|
||||
|
||||
Plugins can register `/` slash commands with `api.commands.register(id, { name, description, icon, options, scope, run })` (capability `ui.commands`). A command's `scope` is `global` (default — available in chat servers and direct messages) or `server` (only while a chat server is the active surface). The chat composer renders a Discord-style autocomplete menu when the user types `/`: results come from `PluginUiRegistryService.slashCommandRecords` filtered by surface via `selectAvailableSlashCommands` and by query via `filterSlashCommands` (both in `domain/logic/slash-command.rules.ts`). Picking a command (click, Enter, or Tab) either runs it immediately when it declares no options, or fills `/name ` so the user can type arguments before sending. On submit, `parseSlashCommandInput` + `findSlashCommand` resolve the command, `parseSlashCommandArguments` maps positional tokens (or a single `rest` option) to `args`, and `PluginClientApiService.createSlashCommandContext` builds a `slashCommand`-source context. Slash command input is intercepted in the composer and never sent as a chat message; unmatched `/text` falls through to a normal message. `api.commands.list()` returns every registered command across plugins.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
import { Injector, signal } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
||||
import type {
|
||||
Channel,
|
||||
Message,
|
||||
Room,
|
||||
TojuPluginManifest,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
selectCurrentRoom,
|
||||
selectCurrentRoomChannels,
|
||||
selectCurrentRoomId
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import {
|
||||
PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS,
|
||||
assertPluginApiSurfaceImplemented,
|
||||
collectPluginApiMethodPaths,
|
||||
type PluginClientApiMethodPath
|
||||
} from '../../domain/logic/plugin-client-api-surface.rules';
|
||||
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginClientApiService } from './plugin-client-api.service';
|
||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||
import { PluginLoggerService } from './plugin-logger.service';
|
||||
import { PluginMessageBusService } from './plugin-message-bus.service';
|
||||
import { PluginStorageService } from './plugin-storage.service';
|
||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
|
||||
const TEST_MANIFEST = createTestManifest();
|
||||
|
||||
describe('PluginClientApiService', () => {
|
||||
let context: ServiceTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createServiceTestContext();
|
||||
context.capabilities.grantAll(TEST_MANIFEST);
|
||||
});
|
||||
|
||||
it('implements the full public plugin API surface', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(() => assertPluginApiSurfaceImplemented(api as Record<string, Record<string, unknown>>)).not.toThrow();
|
||||
});
|
||||
|
||||
it('freezes the API object returned to plugins', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(Object.isFrozen(api)).toBe(true);
|
||||
expect(Object.isFrozen(api.commands)).toBe(true);
|
||||
expect(Object.isFrozen(api.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes current interaction context without capability checks', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(api.context.getCurrent()).toEqual({
|
||||
server: context.room(),
|
||||
source: 'manual',
|
||||
textChannel: context.channels().find((channel) => channel.id === 'general') ?? null,
|
||||
user: context.currentUser(),
|
||||
voiceChannel: null
|
||||
});
|
||||
});
|
||||
|
||||
it('routes logger calls to the plugin logger service', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.logger.debug('debug');
|
||||
api.logger.error('error');
|
||||
api.logger.info('info');
|
||||
api.logger.warn('warn');
|
||||
|
||||
expect(context.logger.debug).toHaveBeenCalledWith(TEST_MANIFEST.id, 'debug', undefined);
|
||||
expect(context.logger.error).toHaveBeenCalledWith(TEST_MANIFEST.id, 'error', undefined);
|
||||
expect(context.logger.info).toHaveBeenCalledWith(TEST_MANIFEST.id, 'info', undefined);
|
||||
expect(context.logger.warn).toHaveBeenCalledWith(TEST_MANIFEST.id, 'warn', undefined);
|
||||
});
|
||||
|
||||
it('dispatches profile updates through the users store', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.profile.update({ displayName: 'Plugin User' });
|
||||
api.profile.updateAvatar({
|
||||
avatarHash: 'hash',
|
||||
avatarMime: 'image/png',
|
||||
avatarUrl: '/avatar.png'
|
||||
});
|
||||
|
||||
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserProfile({
|
||||
profile: expect.objectContaining({ displayName: 'Plugin User' })
|
||||
}));
|
||||
|
||||
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserAvatar({
|
||||
avatar: expect.objectContaining({ avatarUrl: '/avatar.png' })
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends plugin messages and broadcasts them to peers', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
const message = api.messages.send('hello plugin');
|
||||
|
||||
expect(message.content).toBe('hello plugin');
|
||||
expect(message.roomId).toBe('room-1');
|
||||
expect(context.store.dispatch).toHaveBeenCalledWith(MessagesActions.sendMessageSuccess({ message }));
|
||||
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'chat-message',
|
||||
message
|
||||
}));
|
||||
});
|
||||
|
||||
it('publishes typing state through the realtime facade', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.messages.setTyping(true, 'general');
|
||||
|
||||
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith({
|
||||
type: 'typing',
|
||||
serverId: 'room-1',
|
||||
channelId: 'general',
|
||||
isTyping: true
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards slash command registration to the UI registry', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
const contribution = {
|
||||
name: 'echo',
|
||||
run: () => {}
|
||||
};
|
||||
|
||||
api.commands.register('echo', contribution);
|
||||
|
||||
expect(context.uiRegistry.registerSlashCommand).toHaveBeenCalledWith(
|
||||
TEST_MANIFEST.id,
|
||||
'echo',
|
||||
contribution
|
||||
);
|
||||
});
|
||||
|
||||
it('lists slash commands from the UI registry', () => {
|
||||
const commands = [{ name: 'echo', run: () => {} }];
|
||||
|
||||
context.uiRegistry.slashCommands.mockReturnValue(commands);
|
||||
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(api.commands.list()).toBe(commands);
|
||||
});
|
||||
|
||||
it('routes storage APIs through the plugin storage service', async () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.storage.set('key', { ok: true });
|
||||
api.storage.get('key');
|
||||
api.storage.remove('key');
|
||||
await api.clientData.write('client-key', { ok: true });
|
||||
await api.clientData.read('client-key');
|
||||
await api.clientData.remove('client-key');
|
||||
await api.serverData.write('server-key', { ok: true });
|
||||
await api.serverData.read('server-key');
|
||||
await api.serverData.remove('server-key');
|
||||
|
||||
expect(context.storage.setLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key', { ok: true });
|
||||
expect(context.storage.getLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
|
||||
expect(context.storage.removeLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
|
||||
expect(context.storage.writeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key', { ok: true });
|
||||
expect(context.storage.readClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
|
||||
expect(context.storage.removeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
|
||||
expect(context.storage.writeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key', { ok: true });
|
||||
expect(context.storage.readServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
|
||||
expect(context.storage.removeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
|
||||
});
|
||||
|
||||
it('publishes declared server events through the realtime facade', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.events.publishServer('e2e:server', { ok: true });
|
||||
|
||||
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'plugin_event',
|
||||
eventName: 'e2e:server',
|
||||
payload: { ok: true },
|
||||
pluginId: TEST_MANIFEST.id,
|
||||
serverId: 'room-1'
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects undeclared plugin events', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(() => api.events.publishServer('missing:event', {})).toThrow(/did not declare event/);
|
||||
});
|
||||
|
||||
it('enforces capability grants for privileged API methods', async () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
context.capabilities.revokeAll(TEST_MANIFEST.id);
|
||||
|
||||
for (const path of collectPluginApiMethodPaths()) {
|
||||
const capability = PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS[path as PluginClientApiMethodPath];
|
||||
|
||||
if (!capability) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [namespace, method] = path.split('.') as [keyof typeof api, string];
|
||||
const target = api[namespace] as Record<string, (...args: unknown[]) => unknown>;
|
||||
|
||||
await expect(invokeApiMethod(target[method], path)).rejects.toThrow(PluginCapabilityError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceTestContext {
|
||||
capabilities: PluginCapabilityService;
|
||||
channels: ReturnType<typeof signal<Channel[]>>;
|
||||
currentUser: ReturnType<typeof signal<User | null>>;
|
||||
logger: {
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
messages: ReturnType<typeof signal<Message[]>>;
|
||||
realtime: {
|
||||
onSignalingMessage: Subject<unknown>;
|
||||
sendRawMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
room: ReturnType<typeof signal<Room | null>>;
|
||||
service: PluginClientApiService;
|
||||
storage: {
|
||||
getLocal: ReturnType<typeof vi.fn>;
|
||||
readClientData: ReturnType<typeof vi.fn>;
|
||||
readServerData: ReturnType<typeof vi.fn>;
|
||||
removeClientData: ReturnType<typeof vi.fn>;
|
||||
removeLocal: ReturnType<typeof vi.fn>;
|
||||
removeServerData: ReturnType<typeof vi.fn>;
|
||||
setLocal: ReturnType<typeof vi.fn>;
|
||||
writeClientData: ReturnType<typeof vi.fn>;
|
||||
writeServerData: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
store: {
|
||||
dispatch: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
uiRegistry: {
|
||||
registerSlashCommand: ReturnType<typeof vi.fn>;
|
||||
slashCommands: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
voice: {
|
||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
setInputVolume: ReturnType<typeof vi.fn>;
|
||||
setLocalStream: ReturnType<typeof vi.fn>;
|
||||
setOutputVolume: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceTestContext(): ServiceTestContext {
|
||||
installLocalStorageMock();
|
||||
installBrowserMediaMocks();
|
||||
|
||||
const currentUser = signal<User | null>(createUser());
|
||||
const users = signal<User[]>(currentUser() ? [currentUser() as User] : []);
|
||||
const room = signal<Room | null>(createRoom());
|
||||
const channels = signal<Channel[]>(room()?.channels ?? []);
|
||||
const messages = signal<Message[]>([]);
|
||||
const activeChannelId = signal('general');
|
||||
const roomId = signal('room-1');
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
};
|
||||
const storage = {
|
||||
getLocal: vi.fn(),
|
||||
setLocal: vi.fn(),
|
||||
removeLocal: vi.fn(),
|
||||
readClientData: vi.fn(async () => null),
|
||||
writeClientData: vi.fn(async () => undefined),
|
||||
removeClientData: vi.fn(async () => undefined),
|
||||
readServerData: vi.fn(async () => null),
|
||||
writeServerData: vi.fn(async () => undefined),
|
||||
removeServerData: vi.fn(async () => undefined)
|
||||
};
|
||||
const uiRegistry = {
|
||||
registerSlashCommand: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
slashCommands: vi.fn(() => [])
|
||||
};
|
||||
const voice = {
|
||||
broadcastMessage: vi.fn(),
|
||||
getConnectedPeers: vi.fn(() => []),
|
||||
setInputVolume: vi.fn(),
|
||||
setLocalStream: vi.fn(async () => undefined),
|
||||
setOutputVolume: vi.fn()
|
||||
};
|
||||
const realtime = {
|
||||
onSignalingMessage: new Subject<unknown>(),
|
||||
sendRawMessage: vi.fn()
|
||||
};
|
||||
const store = {
|
||||
dispatch: vi.fn(),
|
||||
selectSignal: vi.fn((selector: unknown) => {
|
||||
if (selector === selectCurrentUser) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
if (selector === selectAllUsers) {
|
||||
return users;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoom) {
|
||||
return room;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoomChannels) {
|
||||
return channels;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoomMessages) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (selector === selectActiveChannelId) {
|
||||
return activeChannelId;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoomId) {
|
||||
return roomId;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected selector in PluginClientApiService test: ${String(selector)}`);
|
||||
})
|
||||
};
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
PluginClientApiService,
|
||||
PluginCapabilityService,
|
||||
{
|
||||
provide: AttachmentFacade,
|
||||
useValue: {
|
||||
publishAttachments: vi.fn(async () => undefined),
|
||||
rememberMessageRoom: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: {
|
||||
saveMessage: vi.fn(async () => undefined),
|
||||
updateMessage: vi.fn(async () => undefined),
|
||||
updateRoom: vi.fn(async () => undefined)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginDesktopStateService,
|
||||
useValue: {
|
||||
readJson: vi.fn(async () => null),
|
||||
writeJson: vi.fn(async () => undefined)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginLoggerService,
|
||||
useValue: logger
|
||||
},
|
||||
{
|
||||
provide: PluginMessageBusService,
|
||||
useValue: {
|
||||
publish: vi.fn(() => ({ topic: 'test' })),
|
||||
sendLatestMessages: vi.fn(() => ({ topic: 'test' })),
|
||||
subscribe: vi.fn(() => ({ dispose: vi.fn() }))
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginStorageService,
|
||||
useValue: storage
|
||||
},
|
||||
{
|
||||
provide: PluginUiRegistryService,
|
||||
useValue: uiRegistry
|
||||
},
|
||||
{
|
||||
provide: RealtimeSessionFacade,
|
||||
useValue: {
|
||||
broadcastMessage: vi.fn(),
|
||||
onSignalingMessage: realtime.onSignalingMessage.asObservable(),
|
||||
sendRawMessage: realtime.sendRawMessage
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: ServerDirectoryFacade,
|
||||
useValue: {
|
||||
updateServer: vi.fn(() => ({ subscribe: vi.fn() }))
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store
|
||||
},
|
||||
{
|
||||
provide: VoiceConnectionFacade,
|
||||
useValue: voice
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
capabilities: injector.get(PluginCapabilityService),
|
||||
channels,
|
||||
currentUser,
|
||||
logger,
|
||||
messages,
|
||||
realtime,
|
||||
room,
|
||||
service: injector.get(PluginClientApiService),
|
||||
storage,
|
||||
store,
|
||||
uiRegistry,
|
||||
voice
|
||||
};
|
||||
}
|
||||
|
||||
function createTestManifest(): TojuPluginManifest {
|
||||
return {
|
||||
apiVersion: '1.0.0',
|
||||
capabilities: [
|
||||
'profile.read',
|
||||
'profile.write',
|
||||
'users.read',
|
||||
'users.manage',
|
||||
'roles.read',
|
||||
'roles.manage',
|
||||
'messages.read',
|
||||
'messages.send',
|
||||
'messages.editOwn',
|
||||
'messages.deleteOwn',
|
||||
'messages.moderate',
|
||||
'messages.sync',
|
||||
'channels.read',
|
||||
'channels.manage',
|
||||
'server.read',
|
||||
'server.manage',
|
||||
'p2p.data',
|
||||
'media.playAudio',
|
||||
'media.addAudioStream',
|
||||
'media.addVideoStream',
|
||||
'audio.volume',
|
||||
'ui.settings',
|
||||
'ui.pages',
|
||||
'ui.sidePanel',
|
||||
'ui.channelsSection',
|
||||
'ui.embeds',
|
||||
'ui.dom',
|
||||
'ui.commands',
|
||||
'storage.local',
|
||||
'storage.serverData.read',
|
||||
'storage.serverData.write',
|
||||
'events.server.publish',
|
||||
'events.server.subscribe',
|
||||
'events.p2p.publish',
|
||||
'events.p2p.subscribe'
|
||||
],
|
||||
compatibility: {
|
||||
minimumTojuVersion: '1.0.0'
|
||||
},
|
||||
description: 'Plugin API service test fixture',
|
||||
events: [
|
||||
{
|
||||
direction: 'serverRelay',
|
||||
eventName: 'e2e:server',
|
||||
scope: 'server'
|
||||
}
|
||||
],
|
||||
id: 'test.plugin-api',
|
||||
kind: 'client',
|
||||
schemaVersion: 1,
|
||||
title: 'Plugin API Service Fixture',
|
||||
version: '1.0.0'
|
||||
};
|
||||
}
|
||||
|
||||
function createUser(): User {
|
||||
return {
|
||||
displayName: 'Alice',
|
||||
id: 'user-1',
|
||||
isOnline: true,
|
||||
joinedAt: Date.now(),
|
||||
oderId: 'user-1',
|
||||
role: 'host',
|
||||
status: 'online',
|
||||
username: 'alice'
|
||||
};
|
||||
}
|
||||
|
||||
function createRoom(): Room {
|
||||
return {
|
||||
channels: [{ id: 'general', name: 'general', position: 0, type: 'text' }, { id: 'voice', name: 'voice', position: 1, type: 'voice' }],
|
||||
description: 'Plugin API room',
|
||||
hostId: 'user-1',
|
||||
id: 'room-1',
|
||||
isPrivate: false,
|
||||
members: [],
|
||||
name: 'Plugin API Room',
|
||||
roles: []
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeApiMethod(method: (...args: unknown[]) => unknown, path: string): Promise<unknown> {
|
||||
switch (path) {
|
||||
case 'attachments.import':
|
||||
return method({ files: [], messageId: 'message-1' });
|
||||
case 'channels.addAudioChannel':
|
||||
return method({ name: 'Audio' });
|
||||
case 'channels.addTextChannel':
|
||||
return method({ name: 'Text' });
|
||||
case 'channels.addVideoChannel':
|
||||
return method({ name: 'Video' });
|
||||
case 'channels.remove':
|
||||
return method('general');
|
||||
case 'channels.rename':
|
||||
return method('general', 'renamed');
|
||||
case 'channels.select':
|
||||
return method('general');
|
||||
case 'clientData.read':
|
||||
case 'serverData.read':
|
||||
return method('key');
|
||||
case 'clientData.remove':
|
||||
case 'serverData.remove':
|
||||
return method('key');
|
||||
case 'clientData.write':
|
||||
case 'serverData.write':
|
||||
return method('key', { ok: true });
|
||||
case 'commands.register':
|
||||
return method('echo', { name: 'echo', run: () => {} });
|
||||
case 'events.publishP2p':
|
||||
case 'events.publishServer':
|
||||
return method('e2e:server', {});
|
||||
case 'events.subscribeP2p':
|
||||
case 'events.subscribeServer':
|
||||
return method({ eventName: 'e2e:server', handler: () => {} });
|
||||
case 'media.addCustomAudioStream':
|
||||
case 'media.addCustomVideoStream':
|
||||
return method({ stream: new MediaStream() });
|
||||
case 'media.playAudioClip':
|
||||
return method({ url: 'data:audio/wav;base64,' });
|
||||
case 'media.setInputVolume':
|
||||
case 'media.setOutputVolume':
|
||||
return method(0.5);
|
||||
case 'messageBus.publish':
|
||||
return method({ topic: 'test' });
|
||||
case 'messageBus.sendLatestMessages':
|
||||
return method({});
|
||||
case 'messageBus.subscribe':
|
||||
return method({ handler: () => {} });
|
||||
case 'messages.delete':
|
||||
return method('message-1');
|
||||
case 'messages.edit':
|
||||
return method('message-1', 'updated');
|
||||
case 'messages.moderateDelete':
|
||||
return method('message-1');
|
||||
case 'messages.import':
|
||||
return method([]);
|
||||
case 'messages.send':
|
||||
return method('hello');
|
||||
case 'messages.sendAsPluginUser':
|
||||
return method({ content: 'hello', pluginUserId: 'bot' });
|
||||
case 'messages.setTyping':
|
||||
return method(true);
|
||||
case 'messages.subscribeTyping':
|
||||
return method(() => {});
|
||||
case 'messages.sync':
|
||||
return method([]);
|
||||
case 'p2p.broadcastData':
|
||||
return method('e2e:server', {});
|
||||
case 'p2p.sendData':
|
||||
return method('peer-1', 'e2e:server', {});
|
||||
case 'profile.update':
|
||||
return method({ displayName: 'Updated' });
|
||||
case 'profile.updateAvatar':
|
||||
return method({ avatarHash: 'hash', avatarMime: 'image/png', avatarUrl: '/avatar.png' });
|
||||
case 'roles.setAssignments':
|
||||
return method([]);
|
||||
case 'server.registerPluginUser':
|
||||
return method({ displayName: 'Bot' });
|
||||
case 'server.updateIcon':
|
||||
return method('icon-hash');
|
||||
case 'server.updatePermissions':
|
||||
return method({ allowVoice: true });
|
||||
case 'server.updateSettings':
|
||||
return method({ name: 'Room' });
|
||||
case 'storage.get':
|
||||
return method('key');
|
||||
case 'storage.remove':
|
||||
return method('key');
|
||||
case 'storage.set':
|
||||
return method('key', { ok: true });
|
||||
case 'ui.mountElement':
|
||||
return method('mount', { element: { tagName: 'DIV' }, target: 'body' });
|
||||
case 'ui.registerAppPage':
|
||||
case 'ui.registerChannelSection':
|
||||
case 'ui.registerComposerAction':
|
||||
case 'ui.registerEmbedRenderer':
|
||||
case 'ui.registerProfileAction':
|
||||
case 'ui.registerSettingsPage':
|
||||
case 'ui.registerSidePanel':
|
||||
case 'ui.registerToolbarAction':
|
||||
return method('id', { label: 'Test', render: () => 'ok', run: () => {} });
|
||||
case 'users.ban':
|
||||
return method('user-2', 'reason');
|
||||
case 'users.kick':
|
||||
return method('user-2');
|
||||
case 'users.setRole':
|
||||
return method('user-2', 'member');
|
||||
default:
|
||||
return Promise.resolve(method());
|
||||
}
|
||||
}
|
||||
|
||||
function installBrowserMediaMocks(): void {
|
||||
vi.stubGlobal('MediaStream', class MediaStream {});
|
||||
vi.stubGlobal('Audio', class Audio {
|
||||
volume = 1;
|
||||
|
||||
async play(): Promise<void> {}
|
||||
});
|
||||
}
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
PluginApiCustomStreamRequest,
|
||||
PluginApiMessageAsPluginUserRequest,
|
||||
PluginApiServerSettingsUpdate,
|
||||
PluginApiSlashCommandContext,
|
||||
PluginApiTypingEvent,
|
||||
TojuClientPluginApi,
|
||||
TojuPluginDisposable
|
||||
@@ -77,6 +78,16 @@ export class PluginClientApiService {
|
||||
const assertEvent = (eventName: string): void => this.assertDeclaredEvent(manifest, eventName);
|
||||
|
||||
return deepFreeze<TojuClientPluginApi>({
|
||||
commands: {
|
||||
list: () => {
|
||||
requireCapability('ui.commands');
|
||||
return this.uiRegistry.slashCommands();
|
||||
},
|
||||
register: (id, contribution) => {
|
||||
requireCapability('ui.commands');
|
||||
return this.uiRegistry.registerSlashCommand(pluginId, id, contribution);
|
||||
}
|
||||
},
|
||||
channels: {
|
||||
addAudioChannel: (request) => {
|
||||
requireCapability('channels.manage');
|
||||
@@ -513,6 +524,15 @@ export class PluginClientApiService {
|
||||
};
|
||||
}
|
||||
|
||||
createSlashCommandContext(request: { args: Record<string, string>; command: string; rawArgs: string }): PluginApiSlashCommandContext {
|
||||
return {
|
||||
...this.createActionContext('slashCommand'),
|
||||
args: request.args,
|
||||
command: request.command,
|
||||
rawArgs: request.rawArgs
|
||||
};
|
||||
}
|
||||
|
||||
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
|
||||
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PluginApiPageContribution,
|
||||
PluginApiPanelContribution,
|
||||
PluginApiSettingsPageContribution,
|
||||
PluginApiSlashCommandContribution,
|
||||
PluginApiUiContributionMap,
|
||||
TojuPluginDisposable
|
||||
} from '../../domain/models/plugin-api.models';
|
||||
@@ -53,6 +54,8 @@ export class PluginUiRegistryService {
|
||||
readonly settingsPageRecords = this.createContributionRecordSignal('settingsPages');
|
||||
readonly sidePanels = this.createContributionSignal('sidePanels');
|
||||
readonly sidePanelRecords = this.createContributionRecordSignal('sidePanels');
|
||||
readonly slashCommands = this.createContributionSignal('slashCommands');
|
||||
readonly slashCommandRecords = this.createContributionRecordSignal('slashCommands');
|
||||
readonly toolbarActions = this.createContributionSignal('toolbarActions');
|
||||
readonly toolbarActionRecords = this.createContributionRecordSignal('toolbarActions');
|
||||
readonly conflicts = computed(() => this.collectConflicts());
|
||||
@@ -66,6 +69,7 @@ export class PluginUiRegistryService {
|
||||
profileActions: PluginUiContributionRecord<PluginApiActionContribution>[];
|
||||
settingsPages: PluginUiContributionRecord<PluginApiSettingsPageContribution>[];
|
||||
sidePanels: PluginUiContributionRecord<PluginApiPanelContribution>[];
|
||||
slashCommands: PluginUiContributionRecord<PluginApiSlashCommandContribution>[];
|
||||
toolbarActions: PluginUiContributionRecord<PluginApiActionContribution>[];
|
||||
}>({
|
||||
appPages: [],
|
||||
@@ -75,6 +79,7 @@ export class PluginUiRegistryService {
|
||||
profileActions: [],
|
||||
settingsPages: [],
|
||||
sidePanels: [],
|
||||
slashCommands: [],
|
||||
toolbarActions: []
|
||||
});
|
||||
|
||||
@@ -125,6 +130,10 @@ export class PluginUiRegistryService {
|
||||
return this.register('sidePanels', pluginId, id, contribution);
|
||||
}
|
||||
|
||||
registerSlashCommand(pluginId: string, id: string, contribution: PluginApiSlashCommandContribution): TojuPluginDisposable {
|
||||
return this.register('slashCommands', pluginId, id, contribution);
|
||||
}
|
||||
|
||||
registerToolbarAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
|
||||
return this.register('toolbarActions', pluginId, id, contribution);
|
||||
}
|
||||
@@ -144,6 +153,7 @@ export class PluginUiRegistryService {
|
||||
profileActions: current.profileActions.filter((entry) => entry.pluginId !== pluginId),
|
||||
settingsPages: current.settingsPages.filter((entry) => entry.pluginId !== pluginId),
|
||||
sidePanels: current.sidePanels.filter((entry) => entry.pluginId !== pluginId),
|
||||
slashCommands: current.slashCommands.filter((entry) => entry.pluginId !== pluginId),
|
||||
toolbarActions: current.toolbarActions.filter((entry) => entry.pluginId !== pluginId)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
||||
import { PLUGIN_CAPABILITIES } from '../../../../shared-kernel';
|
||||
import {
|
||||
PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS,
|
||||
PLUGIN_CLIENT_API_SURFACE,
|
||||
assertPluginApiSurfaceImplemented,
|
||||
collectPluginApiMethodPaths,
|
||||
collectRequiredPluginApiCapabilities
|
||||
} from './plugin-client-api-surface.rules';
|
||||
|
||||
const E2E_ALL_API_MANIFEST_PATH = resolve(
|
||||
process.cwd(),
|
||||
'public/plugins/e2e-all-api/toju.plugin.json'
|
||||
);
|
||||
|
||||
describe('plugin client API surface rules', () => {
|
||||
it('lists every documented namespace and method', () => {
|
||||
expect(collectPluginApiMethodPaths()).toEqual([
|
||||
'attachments.import',
|
||||
'channels.addAudioChannel',
|
||||
'channels.addTextChannel',
|
||||
'channels.addVideoChannel',
|
||||
'channels.list',
|
||||
'channels.remove',
|
||||
'channels.rename',
|
||||
'channels.select',
|
||||
'clientData.read',
|
||||
'clientData.remove',
|
||||
'clientData.write',
|
||||
'commands.list',
|
||||
'commands.register',
|
||||
'context.getCurrent',
|
||||
'events.publishP2p',
|
||||
'events.publishServer',
|
||||
'events.subscribeP2p',
|
||||
'events.subscribeServer',
|
||||
'logger.debug',
|
||||
'logger.error',
|
||||
'logger.info',
|
||||
'logger.warn',
|
||||
'media.addCustomAudioStream',
|
||||
'media.addCustomVideoStream',
|
||||
'media.playAudioClip',
|
||||
'media.setInputVolume',
|
||||
'media.setOutputVolume',
|
||||
'messageBus.publish',
|
||||
'messageBus.sendLatestMessages',
|
||||
'messageBus.subscribe',
|
||||
'messages.delete',
|
||||
'messages.edit',
|
||||
'messages.import',
|
||||
'messages.moderateDelete',
|
||||
'messages.readCurrent',
|
||||
'messages.send',
|
||||
'messages.sendAsPluginUser',
|
||||
'messages.setTyping',
|
||||
'messages.subscribeTyping',
|
||||
'messages.sync',
|
||||
'p2p.broadcastData',
|
||||
'p2p.connectedPeers',
|
||||
'p2p.sendData',
|
||||
'profile.getCurrent',
|
||||
'profile.update',
|
||||
'profile.updateAvatar',
|
||||
'roles.list',
|
||||
'roles.setAssignments',
|
||||
'server.getCurrent',
|
||||
'server.registerPluginUser',
|
||||
'server.updateIcon',
|
||||
'server.updatePermissions',
|
||||
'server.updateSettings',
|
||||
'serverData.read',
|
||||
'serverData.remove',
|
||||
'serverData.write',
|
||||
'storage.get',
|
||||
'storage.remove',
|
||||
'storage.set',
|
||||
'ui.mountElement',
|
||||
'ui.registerAppPage',
|
||||
'ui.registerChannelSection',
|
||||
'ui.registerComposerAction',
|
||||
'ui.registerEmbedRenderer',
|
||||
'ui.registerProfileAction',
|
||||
'ui.registerSettingsPage',
|
||||
'ui.registerSidePanel',
|
||||
'ui.registerToolbarAction',
|
||||
'users.ban',
|
||||
'users.getCurrent',
|
||||
'users.kick',
|
||||
'users.list',
|
||||
'users.readMembers',
|
||||
'users.setRole'
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps privileged methods to known plugin capabilities', () => {
|
||||
for (const capability of Object.values(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS)) {
|
||||
expect(PLUGIN_CAPABILITIES).toContain(capability);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a capability for every privileged namespace method', () => {
|
||||
const privilegedNamespaces = Object.entries(PLUGIN_CLIENT_API_SURFACE)
|
||||
.filter(([namespace]) => !['context', 'logger'].includes(namespace));
|
||||
|
||||
for (const [namespace, methods] of privilegedNamespaces) {
|
||||
for (const method of methods) {
|
||||
const path = `${namespace}.${method}`;
|
||||
|
||||
expect(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS).toHaveProperty(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('detects missing API methods', () => {
|
||||
expect(() => assertPluginApiSurfaceImplemented({
|
||||
commands: { list: () => [] }
|
||||
})).toThrow(/Plugin API surface is incomplete/);
|
||||
});
|
||||
|
||||
it('accepts a fully implemented API object', () => {
|
||||
const api = Object.fromEntries(
|
||||
Object.entries(PLUGIN_CLIENT_API_SURFACE).map(([namespace, methods]) => {
|
||||
const namespaceApi = Object.fromEntries(methods.map((method) => [method, () => undefined]));
|
||||
|
||||
return [namespace, namespaceApi];
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => assertPluginApiSurfaceImplemented(api)).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the E2E all-api fixture manifest granted for full API coverage', () => {
|
||||
const manifest = JSON.parse(readFileSync(E2E_ALL_API_MANIFEST_PATH, 'utf8')) as TojuPluginManifest;
|
||||
|
||||
expect(manifest.capabilities ?? []).toEqual(expect.arrayContaining(collectRequiredPluginApiCapabilities()));
|
||||
});
|
||||
|
||||
it('collects the full capability set needed for API coverage', () => {
|
||||
expect(collectRequiredPluginApiCapabilities()).toEqual([
|
||||
'audio.volume',
|
||||
'channels.manage',
|
||||
'channels.read',
|
||||
'events.p2p.publish',
|
||||
'events.p2p.subscribe',
|
||||
'events.server.publish',
|
||||
'events.server.subscribe',
|
||||
'media.addAudioStream',
|
||||
'media.addVideoStream',
|
||||
'media.playAudio',
|
||||
'messages.deleteOwn',
|
||||
'messages.editOwn',
|
||||
'messages.moderate',
|
||||
'messages.read',
|
||||
'messages.send',
|
||||
'messages.sync',
|
||||
'p2p.data',
|
||||
'profile.read',
|
||||
'profile.write',
|
||||
'roles.manage',
|
||||
'roles.read',
|
||||
'server.manage',
|
||||
'server.read',
|
||||
'storage.local',
|
||||
'storage.serverData.read',
|
||||
'storage.serverData.write',
|
||||
'ui.channelsSection',
|
||||
'ui.commands',
|
||||
'ui.dom',
|
||||
'ui.embeds',
|
||||
'ui.pages',
|
||||
'ui.settings',
|
||||
'ui.sidePanel',
|
||||
'users.manage',
|
||||
'users.read'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { PluginCapabilityId } from '../../../../shared-kernel';
|
||||
|
||||
/**
|
||||
* Canonical registry of every public `TojuClientPluginApi` namespace and method.
|
||||
* Keep this list aligned with `plugin-api.models.ts` when the contract changes.
|
||||
*/
|
||||
export const PLUGIN_CLIENT_API_SURFACE = {
|
||||
attachments: ['import'],
|
||||
channels: [
|
||||
'addAudioChannel',
|
||||
'addTextChannel',
|
||||
'addVideoChannel',
|
||||
'list',
|
||||
'remove',
|
||||
'rename',
|
||||
'select'
|
||||
],
|
||||
clientData: [
|
||||
'read',
|
||||
'remove',
|
||||
'write'
|
||||
],
|
||||
commands: ['list', 'register'],
|
||||
context: ['getCurrent'],
|
||||
events: [
|
||||
'publishP2p',
|
||||
'publishServer',
|
||||
'subscribeP2p',
|
||||
'subscribeServer'
|
||||
],
|
||||
logger: [
|
||||
'debug',
|
||||
'error',
|
||||
'info',
|
||||
'warn'
|
||||
],
|
||||
media: [
|
||||
'addCustomAudioStream',
|
||||
'addCustomVideoStream',
|
||||
'playAudioClip',
|
||||
'setInputVolume',
|
||||
'setOutputVolume'
|
||||
],
|
||||
messageBus: [
|
||||
'publish',
|
||||
'sendLatestMessages',
|
||||
'subscribe'
|
||||
],
|
||||
messages: [
|
||||
'delete',
|
||||
'edit',
|
||||
'import',
|
||||
'moderateDelete',
|
||||
'readCurrent',
|
||||
'send',
|
||||
'sendAsPluginUser',
|
||||
'setTyping',
|
||||
'subscribeTyping',
|
||||
'sync'
|
||||
],
|
||||
p2p: [
|
||||
'broadcastData',
|
||||
'connectedPeers',
|
||||
'sendData'
|
||||
],
|
||||
profile: [
|
||||
'getCurrent',
|
||||
'update',
|
||||
'updateAvatar'
|
||||
],
|
||||
roles: ['list', 'setAssignments'],
|
||||
server: [
|
||||
'getCurrent',
|
||||
'registerPluginUser',
|
||||
'updateIcon',
|
||||
'updatePermissions',
|
||||
'updateSettings'
|
||||
],
|
||||
serverData: [
|
||||
'read',
|
||||
'remove',
|
||||
'write'
|
||||
],
|
||||
storage: [
|
||||
'get',
|
||||
'remove',
|
||||
'set'
|
||||
],
|
||||
ui: [
|
||||
'mountElement',
|
||||
'registerAppPage',
|
||||
'registerChannelSection',
|
||||
'registerComposerAction',
|
||||
'registerEmbedRenderer',
|
||||
'registerProfileAction',
|
||||
'registerSettingsPage',
|
||||
'registerSidePanel',
|
||||
'registerToolbarAction'
|
||||
],
|
||||
users: [
|
||||
'ban',
|
||||
'getCurrent',
|
||||
'kick',
|
||||
'list',
|
||||
'readMembers',
|
||||
'setRole'
|
||||
]
|
||||
} as const;
|
||||
|
||||
export type PluginClientApiNamespace = keyof typeof PLUGIN_CLIENT_API_SURFACE;
|
||||
export type PluginClientApiMethodPath = {
|
||||
[Namespace in PluginClientApiNamespace]: `${Namespace & string}.${typeof PLUGIN_CLIENT_API_SURFACE[Namespace][number]}`;
|
||||
}[PluginClientApiNamespace];
|
||||
|
||||
/**
|
||||
* Capability required before a method may run. Methods omitted here are always available.
|
||||
*/
|
||||
export const PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS: Partial<Record<PluginClientApiMethodPath, PluginCapabilityId>> = {
|
||||
'attachments.import': 'messages.sync',
|
||||
'channels.addAudioChannel': 'channels.manage',
|
||||
'channels.addTextChannel': 'channels.manage',
|
||||
'channels.addVideoChannel': 'channels.manage',
|
||||
'channels.list': 'channels.read',
|
||||
'channels.remove': 'channels.manage',
|
||||
'channels.rename': 'channels.manage',
|
||||
'channels.select': 'channels.read',
|
||||
'clientData.read': 'storage.local',
|
||||
'clientData.remove': 'storage.local',
|
||||
'clientData.write': 'storage.local',
|
||||
'commands.list': 'ui.commands',
|
||||
'commands.register': 'ui.commands',
|
||||
'events.publishP2p': 'events.p2p.publish',
|
||||
'events.publishServer': 'events.server.publish',
|
||||
'events.subscribeP2p': 'events.p2p.subscribe',
|
||||
'events.subscribeServer': 'events.server.subscribe',
|
||||
'media.addCustomAudioStream': 'media.addAudioStream',
|
||||
'media.addCustomVideoStream': 'media.addVideoStream',
|
||||
'media.playAudioClip': 'media.playAudio',
|
||||
'media.setInputVolume': 'audio.volume',
|
||||
'media.setOutputVolume': 'audio.volume',
|
||||
'messageBus.publish': 'events.p2p.publish',
|
||||
'messageBus.sendLatestMessages': 'events.p2p.publish',
|
||||
'messageBus.subscribe': 'events.p2p.subscribe',
|
||||
'messages.delete': 'messages.deleteOwn',
|
||||
'messages.edit': 'messages.editOwn',
|
||||
'messages.import': 'messages.sync',
|
||||
'messages.moderateDelete': 'messages.moderate',
|
||||
'messages.readCurrent': 'messages.read',
|
||||
'messages.send': 'messages.send',
|
||||
'messages.sendAsPluginUser': 'messages.send',
|
||||
'messages.setTyping': 'messages.send',
|
||||
'messages.subscribeTyping': 'messages.read',
|
||||
'messages.sync': 'messages.sync',
|
||||
'p2p.broadcastData': 'p2p.data',
|
||||
'p2p.connectedPeers': 'p2p.data',
|
||||
'p2p.sendData': 'p2p.data',
|
||||
'profile.getCurrent': 'profile.read',
|
||||
'profile.update': 'profile.write',
|
||||
'profile.updateAvatar': 'profile.write',
|
||||
'roles.list': 'roles.read',
|
||||
'roles.setAssignments': 'roles.manage',
|
||||
'server.getCurrent': 'server.read',
|
||||
'server.registerPluginUser': 'users.manage',
|
||||
'server.updateIcon': 'server.manage',
|
||||
'server.updatePermissions': 'server.manage',
|
||||
'server.updateSettings': 'server.manage',
|
||||
'serverData.read': 'storage.serverData.read',
|
||||
'serverData.remove': 'storage.serverData.write',
|
||||
'serverData.write': 'storage.serverData.write',
|
||||
'storage.get': 'storage.local',
|
||||
'storage.remove': 'storage.local',
|
||||
'storage.set': 'storage.local',
|
||||
'ui.mountElement': 'ui.dom',
|
||||
'ui.registerAppPage': 'ui.pages',
|
||||
'ui.registerChannelSection': 'ui.channelsSection',
|
||||
'ui.registerComposerAction': 'ui.pages',
|
||||
'ui.registerEmbedRenderer': 'ui.embeds',
|
||||
'ui.registerProfileAction': 'ui.pages',
|
||||
'ui.registerSettingsPage': 'ui.settings',
|
||||
'ui.registerSidePanel': 'ui.sidePanel',
|
||||
'ui.registerToolbarAction': 'ui.pages',
|
||||
'users.ban': 'users.manage',
|
||||
'users.getCurrent': 'users.read',
|
||||
'users.kick': 'users.manage',
|
||||
'users.list': 'users.read',
|
||||
'users.readMembers': 'users.read',
|
||||
'users.setRole': 'roles.manage'
|
||||
};
|
||||
|
||||
export function collectPluginApiMethodPaths(
|
||||
surface: typeof PLUGIN_CLIENT_API_SURFACE = PLUGIN_CLIENT_API_SURFACE
|
||||
): PluginClientApiMethodPath[] {
|
||||
return Object.entries(surface).flatMap(([namespace, methods]) =>
|
||||
methods.map((method) => `${namespace}.${method}` as PluginClientApiMethodPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPluginApiMethod(
|
||||
api: Record<string, Record<string, unknown>>,
|
||||
path: PluginClientApiMethodPath
|
||||
): unknown {
|
||||
const [namespace, method] = path.split('.') as [PluginClientApiNamespace, string];
|
||||
|
||||
return api[namespace]?.[method];
|
||||
}
|
||||
|
||||
export function assertPluginApiSurfaceImplemented(
|
||||
api: Record<string, Record<string, unknown>>,
|
||||
surface: typeof PLUGIN_CLIENT_API_SURFACE = PLUGIN_CLIENT_API_SURFACE
|
||||
): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const path of collectPluginApiMethodPaths(surface)) {
|
||||
const method = getPluginApiMethod(api, path);
|
||||
|
||||
if (typeof method !== 'function') {
|
||||
missing.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Plugin API surface is incomplete: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectRequiredPluginApiCapabilities(): PluginCapabilityId[] {
|
||||
return Array.from(new Set(Object.values(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS))).sort();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import type { PluginApiSlashCommandContribution } from '../models/plugin-api.models';
|
||||
import {
|
||||
filterSlashCommands,
|
||||
findSlashCommand,
|
||||
parseSlashCommandArguments,
|
||||
parseSlashCommandInput,
|
||||
parseSlashCommandQuery,
|
||||
selectAvailableSlashCommands,
|
||||
type SlashCommandEntry
|
||||
} from './slash-command.rules';
|
||||
|
||||
function entry(name: string, overrides: Partial<PluginApiSlashCommandContribution> = {}, pluginId = 'plugin.test'): SlashCommandEntry {
|
||||
return {
|
||||
contribution: {
|
||||
name,
|
||||
run: () => {},
|
||||
...overrides
|
||||
},
|
||||
id: `${pluginId}:${name}`,
|
||||
pluginId
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseSlashCommandQuery', () => {
|
||||
it('returns the empty query for a lone slash', () => {
|
||||
expect(parseSlashCommandQuery('/')).toBe('');
|
||||
});
|
||||
|
||||
it('returns the partial name while typing', () => {
|
||||
expect(parseSlashCommandQuery('/gi')).toBe('gi');
|
||||
});
|
||||
|
||||
it('returns null once whitespace (arguments) are typed', () => {
|
||||
expect(parseSlashCommandQuery('/giphy cat')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-command text', () => {
|
||||
expect(parseSlashCommandQuery('hello')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSlashCommandInput', () => {
|
||||
it('parses a command without arguments', () => {
|
||||
expect(parseSlashCommandInput('/ping')).toEqual({ name: 'ping', rawArgs: '' });
|
||||
});
|
||||
|
||||
it('parses a command with arguments', () => {
|
||||
expect(parseSlashCommandInput('/giphy funny cat ')).toEqual({ name: 'giphy', rawArgs: 'funny cat' });
|
||||
});
|
||||
|
||||
it('returns null for a lone slash', () => {
|
||||
expect(parseSlashCommandInput('/')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-command text', () => {
|
||||
expect(parseSlashCommandInput('not a command')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAvailableSlashCommands', () => {
|
||||
const entries = [
|
||||
entry('alpha', { scope: 'global' }),
|
||||
entry('zeta', { scope: 'server' }),
|
||||
entry('beta', { scope: 'global' })
|
||||
];
|
||||
|
||||
it('includes global and server commands on a server surface, sorted by name', () => {
|
||||
expect(selectAvailableSlashCommands(entries, 'server').map((item) => item.contribution.name)).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'zeta'
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes server-scoped commands on a direct surface', () => {
|
||||
expect(selectAvailableSlashCommands(entries, 'direct').map((item) => item.contribution.name)).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('treats a missing scope as global', () => {
|
||||
expect(selectAvailableSlashCommands([entry('plain')], 'direct')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSlashCommands', () => {
|
||||
const entries = [
|
||||
entry('giphy'),
|
||||
entry('gif-search'),
|
||||
entry('roll')
|
||||
];
|
||||
|
||||
it('returns all entries for an empty query', () => {
|
||||
expect(filterSlashCommands(entries, '')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('ranks prefix matches above contains matches', () => {
|
||||
const names = filterSlashCommands([entry('a-gif'), entry('gif')], 'gif').map((item) => item.contribution.name);
|
||||
|
||||
expect(names).toEqual(['gif', 'a-gif']);
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
expect(filterSlashCommands(entries, 'GIF').map((item) => item.contribution.name)).toContain('gif-search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSlashCommand', () => {
|
||||
const entries = [entry('Ping'), entry('roll')];
|
||||
|
||||
it('matches the command name case-insensitively', () => {
|
||||
expect(findSlashCommand(entries, 'ping')?.contribution.name).toBe('Ping');
|
||||
});
|
||||
|
||||
it('returns null when no command matches', () => {
|
||||
expect(findSlashCommand(entries, 'unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSlashCommandArguments', () => {
|
||||
it('returns an empty record when the command has no options', () => {
|
||||
expect(parseSlashCommandArguments('anything here', [])).toEqual({});
|
||||
});
|
||||
|
||||
it('maps positional tokens to option names', () => {
|
||||
const args = parseSlashCommandArguments('6 advantage', [{ name: 'sides' }, { name: 'mode' }]);
|
||||
|
||||
expect(args).toEqual({ sides: '6', mode: 'advantage' });
|
||||
});
|
||||
|
||||
it('captures the remaining text for a rest option', () => {
|
||||
const args = parseSlashCommandArguments('happy birthday to you', [{ name: 'tone' }, { name: 'message', type: 'rest' }]);
|
||||
|
||||
expect(args).toEqual({ tone: 'happy', message: 'birthday to you' });
|
||||
});
|
||||
|
||||
it('fills missing positional options with empty strings', () => {
|
||||
expect(parseSlashCommandArguments('', [{ name: 'first' }])).toEqual({ first: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
PluginApiSlashCommandContribution,
|
||||
PluginApiSlashCommandOption,
|
||||
PluginApiSlashCommandScope
|
||||
} from '../models/plugin-api.models';
|
||||
|
||||
/**
|
||||
* The chat surface a composer is rendered on. `server` surfaces expose both
|
||||
* global and server-scoped commands; `direct` surfaces (DMs/group chats) only
|
||||
* expose global commands.
|
||||
*/
|
||||
export type SlashCommandSurface = 'server' | 'direct';
|
||||
|
||||
/** A registered slash command together with the owning plugin. */
|
||||
export interface SlashCommandEntry {
|
||||
contribution: PluginApiSlashCommandContribution;
|
||||
id: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface ParsedSlashCommandInput {
|
||||
name: string;
|
||||
rawArgs: string;
|
||||
}
|
||||
|
||||
function resolveScope(contribution: PluginApiSlashCommandContribution): PluginApiSlashCommandScope {
|
||||
return contribution.scope === 'server' ? 'server' : 'global';
|
||||
}
|
||||
|
||||
function normalizeName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the live query while the user is still typing a command name, e.g.
|
||||
* `/gi` -> `gi` and `/` -> ``. Returns `null` once the input no longer looks
|
||||
* like an in-progress command (contains whitespace or does not start with `/`).
|
||||
*/
|
||||
export function parseSlashCommandQuery(text: string): string | null {
|
||||
const match = /^\/(\S*)$/.exec(text);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a fully typed command for execution. Returns the command name and the
|
||||
* raw remaining argument string, or `null` when the text is not a command.
|
||||
*/
|
||||
export function parseSlashCommandInput(text: string): ParsedSlashCommandInput | null {
|
||||
if (!text.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = text.slice(1);
|
||||
const whitespaceIndex = body.search(/\s/);
|
||||
|
||||
if (whitespaceIndex === -1) {
|
||||
return body.length > 0 ? { name: body, rawArgs: '' } : null;
|
||||
}
|
||||
|
||||
const name = body.slice(0, whitespaceIndex);
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, rawArgs: body.slice(whitespaceIndex + 1).trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the registered commands down to the ones available on the given chat
|
||||
* surface, sorted alphabetically by name.
|
||||
*/
|
||||
export function selectAvailableSlashCommands(entries: readonly SlashCommandEntry[], surface: SlashCommandSurface): SlashCommandEntry[] {
|
||||
return entries
|
||||
.filter((entry) => resolveScope(entry.contribution) === 'global' || surface === 'server')
|
||||
.slice()
|
||||
.sort((left, right) => left.contribution.name.localeCompare(right.contribution.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows available commands by the typed query. Commands whose name starts
|
||||
* with the query rank above commands that merely contain it.
|
||||
*/
|
||||
export function filterSlashCommands(entries: readonly SlashCommandEntry[], query: string): SlashCommandEntry[] {
|
||||
const normalizedQuery = normalizeName(query);
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return entries.slice();
|
||||
}
|
||||
|
||||
const matches = entries.filter((entry) => normalizeName(entry.contribution.name).includes(normalizedQuery));
|
||||
|
||||
return matches.sort((left, right) => {
|
||||
const leftStarts = normalizeName(left.contribution.name).startsWith(normalizedQuery);
|
||||
const rightStarts = normalizeName(right.contribution.name).startsWith(normalizedQuery);
|
||||
|
||||
if (leftStarts !== rightStarts) {
|
||||
return leftStarts ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.contribution.name.localeCompare(right.contribution.name);
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds the command to execute for an exact (case-insensitive) name match. */
|
||||
export function findSlashCommand(entries: readonly SlashCommandEntry[], name: string): SlashCommandEntry | null {
|
||||
const normalizedName = normalizeName(name);
|
||||
|
||||
return entries.find((entry) => normalizeName(entry.contribution.name) === normalizedName) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a raw argument string into named values according to the command's
|
||||
* declared options. A `rest` option captures the remaining text verbatim;
|
||||
* commands without options receive an empty record.
|
||||
*/
|
||||
export function parseSlashCommandArguments(rawArgs: string, options: readonly PluginApiSlashCommandOption[] = []): Record<string, string> {
|
||||
const args: Record<string, string> = {};
|
||||
|
||||
if (options.length === 0) {
|
||||
return args;
|
||||
}
|
||||
|
||||
let remaining = rawArgs.trim();
|
||||
|
||||
for (const option of options) {
|
||||
if (option.type === 'rest') {
|
||||
args[option.name] = remaining;
|
||||
remaining = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^(\S+)\s*/.exec(remaining);
|
||||
|
||||
args[option.name] = match ? match[1] : '';
|
||||
remaining = match ? remaining.slice(match[0].length) : '';
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export interface PluginApiEventSubscription {
|
||||
handler: (event: PluginEventEnvelope) => void;
|
||||
}
|
||||
|
||||
export type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
|
||||
export type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'slashCommand' | 'manual';
|
||||
|
||||
export interface PluginApiActionContext {
|
||||
server: Room | null;
|
||||
@@ -99,6 +99,40 @@ export interface PluginApiActionContext {
|
||||
voiceChannel: Channel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a slash command is allowed to appear. `global` commands are available
|
||||
* everywhere (chat servers and direct messages); `server` commands only appear
|
||||
* while a chat server is the active surface.
|
||||
*/
|
||||
export type PluginApiSlashCommandScope = 'global' | 'server';
|
||||
|
||||
export type PluginApiSlashCommandOptionType = 'string' | 'number' | 'boolean' | 'rest';
|
||||
|
||||
export interface PluginApiSlashCommandOption {
|
||||
description?: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
type?: PluginApiSlashCommandOptionType;
|
||||
}
|
||||
|
||||
export interface PluginApiSlashCommandContext extends PluginApiActionContext {
|
||||
/** Parsed positional/named argument values keyed by option name. */
|
||||
args: Record<string, string>;
|
||||
/** The invoked command name without the leading slash. */
|
||||
command: string;
|
||||
/** The raw, unparsed argument string typed after the command name. */
|
||||
rawArgs: string;
|
||||
}
|
||||
|
||||
export interface PluginApiSlashCommandContribution {
|
||||
description?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
options?: PluginApiSlashCommandOption[];
|
||||
run: (context: PluginApiSlashCommandContext) => Promise<void> | void;
|
||||
scope?: PluginApiSlashCommandScope;
|
||||
}
|
||||
|
||||
export interface PluginApiTypingEvent extends Omit<PluginApiActionContext, 'source'> {
|
||||
channelId: string;
|
||||
displayName: string;
|
||||
@@ -194,10 +228,15 @@ export interface PluginApiUiContributionMap {
|
||||
profileActions: PluginApiActionContribution[];
|
||||
settingsPages: PluginApiSettingsPageContribution[];
|
||||
sidePanels: PluginApiPanelContribution[];
|
||||
slashCommands: PluginApiSlashCommandContribution[];
|
||||
toolbarActions: PluginApiActionContribution[];
|
||||
}
|
||||
|
||||
export interface TojuClientPluginApi {
|
||||
readonly commands: {
|
||||
list: () => PluginApiSlashCommandContribution[];
|
||||
register: (id: string, contribution: PluginApiSlashCommandContribution) => TojuPluginDisposable;
|
||||
};
|
||||
readonly channels: {
|
||||
addAudioChannel: (request: PluginApiChannelRequest) => void;
|
||||
addTextChannel: (request: PluginApiChannelRequest) => void;
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
{ label: 'Composer actions', value: extensionCounts().composerActions },
|
||||
{ label: 'Profile actions', value: extensionCounts().profileActions },
|
||||
{ label: 'Toolbar actions', value: extensionCounts().toolbarActions },
|
||||
{ label: 'Slash commands', value: extensionCounts().slashCommands },
|
||||
{ label: 'Embed renderers', value: extensionCounts().embeds }
|
||||
];
|
||||
track item.label
|
||||
|
||||
@@ -106,6 +106,7 @@ export class PluginManagerComponent {
|
||||
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
|
||||
}));
|
||||
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
|
||||
|
||||
@@ -563,10 +563,6 @@ export class PluginStoreComponent implements OnInit {
|
||||
return this.brokenImageKeys().has(this.imageKey(plugin));
|
||||
}
|
||||
|
||||
private imageKey(plugin: PluginStoreEntry): string {
|
||||
return `${plugin.sourceUrl}:${plugin.id}:${plugin.imageUrl ?? ''}`;
|
||||
}
|
||||
|
||||
trackServer(index: number, server: Room): string {
|
||||
return server.id;
|
||||
}
|
||||
@@ -585,6 +581,10 @@ export class PluginStoreComponent implements OnInit {
|
||||
: this.getPrimaryActionLabel(plugin);
|
||||
}
|
||||
|
||||
private imageKey(plugin: PluginStoreEntry): string {
|
||||
return `${plugin.sourceUrl}:${plugin.id}:${plugin.imageUrl ?? ''}`;
|
||||
}
|
||||
|
||||
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
|
||||
return [
|
||||
plugin.author,
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './application/services/plugin-store.service';
|
||||
export * from './application/services/plugin-ui-registry.service';
|
||||
export * from './domain/logic/plugin-dependency-resolver.logic';
|
||||
export * from './domain/logic/plugin-manifest-validation.logic';
|
||||
export * from './domain/logic/slash-command.rules';
|
||||
export * from './domain/models/plugin-api.models';
|
||||
export * from './domain/models/plugin-runtime.models';
|
||||
export * from './domain/models/plugin-store.models';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/** Hostnames known to run older signal servers without featured/trending discovery routes. */
|
||||
const DISCOVERY_UNSUPPORTED_HOSTS = new Set([
|
||||
'signal.toju.app',
|
||||
'signal-sweden.toju.app'
|
||||
]);
|
||||
const DISCOVERY_UNSUPPORTED_HOSTS = new Set(['signal.toju.app', 'signal-sweden.toju.app']);
|
||||
|
||||
/** Returns false when discovery endpoints are known to 404 on the active signal server. */
|
||||
export function endpointSupportsServerDiscovery(baseUrl: string): boolean {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
isSignalServerTagUrl,
|
||||
presentSignalServerTag,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import {
|
||||
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
|
||||
|
||||
@@ -122,6 +122,7 @@ describe('ThemeService theme application', () => {
|
||||
'Toju Website Dark',
|
||||
'Toju Default Dark'
|
||||
]);
|
||||
|
||||
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
|
||||
|
||||
const applied = service.applyBuiltInPreset('Toju Default Dark');
|
||||
|
||||
@@ -33,6 +33,7 @@ import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { MobileMediaService } from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
@@ -81,6 +82,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
private readonly hostEl = inject(ElementRef);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -169,6 +171,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
|
||||
Reference in New Issue
Block a user