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

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

View File

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

View File

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

View File

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

View File

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

View File

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