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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,8 @@ Plugins can communicate over a plugin-only message bus through `api.messageBus`.
Plugins can inspect the current interaction context through `api.context.getCurrent()`. Composer action callbacks also receive this context directly, including the local user, current chat server, active text channel, and the user's current voice channel when connected. Plugins with message access can call `api.messages.setTyping(true | false, channelId?)` and can observe peer typing state with `api.messages.subscribeTyping(handler)`, where typing events include the user, server, text channel, and voice channel when those records are available locally.
Plugins can 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';