Plugins #14

Merged
myxelium merged 9 commits from Plugins into main 2026-04-29 23:18:22 +00:00
14 changed files with 297 additions and 23 deletions
Showing only changes of commit 3f92e74350 - Show all commits

View File

@@ -196,9 +196,8 @@ export async function startLocalApiServer(settings: LocalApiSettings): Promise<S
currentError = null; currentError = null;
currentBindHost = pickBindHost(settings); currentBindHost = pickBindHost(settings);
currentBindPort = settings.port; currentBindPort = settings.port;
const requestSettings = activeSettings;
const httpServer = createServer((req, res) => { const httpServer = createServer((req, res) => {
void handleRequest(req, res, requestSettings).catch((error) => { void handleRequest(req, res, activeSettings ?? settings).catch((error) => {
console.error('[LocalApi] Unhandled request error:', error); console.error('[LocalApi] Unhandled request error:', error);
try { try {

View File

@@ -289,6 +289,7 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void { function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general'; const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
const isTyping = message['isTyping'] !== false;
if (typingSid && user.serverIds.has(typingSid)) { if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer( broadcastToServer(
@@ -297,6 +298,7 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
type: 'user_typing', type: 'user_typing',
serverId: typingSid, serverId: typingSid,
channelId, channelId,
isTyping,
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName displayName: user.displayName
}, },

View File

@@ -150,4 +150,4 @@ graph LR
## Typing indicator ## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing". `TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each positive event resets a 3-second TTL timer for that channel; an explicit `isTyping: false` event clears that user immediately. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -144,7 +144,7 @@
@for (record of pluginComposerActions(); track record.id) { @for (record of pluginComposerActions(); track record.id) {
<button <button
type="button" type="button"
(click)="runPluginComposerAction(record.contribution.run)" (click)="runPluginComposerAction(record.contribution)"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground" class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.opacity-100]="inputHovered()" [class.opacity-100]="inputHovered()"
[class.opacity-70]="!inputHovered()" [class.opacity-70]="!inputHovered()"

View File

@@ -23,7 +23,11 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel'; import { Message } from '../../../../../../shared-kernel';
import { PluginUiRegistryService } from '../../../../../plugins'; import {
PluginApiActionContribution,
PluginClientApiService,
PluginUiRegistryService
} from '../../../../../plugins';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
import type { RoomSignalSourceInput } from '../../../../../server-directory'; import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
@@ -83,6 +87,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService); private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly pluginApi = inject(PluginClientApiService);
private readonly pluginUi = inject(PluginUiRegistryService); private readonly pluginUi = inject(PluginUiRegistryService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null); readonly pendingKlipyGif = signal<KlipyGif | null>(null);
@@ -222,9 +227,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.klipyGifPickerToggleRequested.emit(); this.klipyGifPickerToggleRequested.emit();
} }
runPluginComposerAction(action: () => Promise<void> | void): void { runPluginComposerAction(action: PluginApiActionContribution): void {
void Promise.resolve() void Promise.resolve()
.then(() => action()); .then(() => action.run(this.pluginApi.createActionContext('composerAction')));
} }
getKlipyTriggerRect(): DOMRect | null { getKlipyTriggerRect(): DOMRect | null {

View File

@@ -91,7 +91,7 @@
@if (msg.isDeleted) { @if (msg.isDeleted) {
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div> <div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
} @else { } @else {
@if (pluginEmbeds().length === 0) { @if (!pluginEmbedToken()) {
@if (requiresRichMarkdown(msg.content)) { @if (requiresRichMarkdown(msg.content)) {
@defer { @defer {
<div class="chat-markdown mt-1 break-words"> <div class="chat-markdown mt-1 break-words">
@@ -105,6 +105,18 @@
} }
} }
@if (missingPluginEmbed(); as missingEmbed) {
<article class="mt-2 max-w-lg rounded-md border border-border bg-secondary/30 p-3 text-sm text-muted-foreground">
Required plugin is not installed to view this content, visit the
<button
type="button"
class="font-semibold text-primary underline-offset-4 hover:underline"
(click)="openMissingPluginStore(missingEmbed)"
>store</button
>.
</article>
}
@if (msg.linkMetadata?.length) { @if (msg.linkMetadata?.length) {
@for (meta of msg.linkMetadata; track meta.url) { @for (meta of msg.linkMetadata; track meta.url) {
@if (shouldShowLinkEmbed(meta.url)) { @if (shouldShowLinkEmbed(meta.url)) {

View File

@@ -12,6 +12,7 @@ import {
signal, signal,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
@@ -39,7 +40,7 @@ import {
} from '../../../../../../shared-kernel'; } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component'; import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginUiRegistryService } from '../../../../../plugins'; import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
@@ -88,6 +89,16 @@ interface ChatMessageAttachmentViewModel extends Attachment {
progressPercent: number; progressPercent: number;
} }
interface PluginEmbedToken {
embedType: string;
payloadText: string;
}
interface MissingPluginEmbedFallback {
pluginName: string;
searchTerm: string;
}
@Component({ @Component({
selector: 'app-chat-message-item', selector: 'app-chat-message-item',
standalone: true, standalone: true,
@@ -127,8 +138,10 @@ export class ChatMessageItemComponent {
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService);
private readonly pluginUi = inject(PluginUiRegistryService); private readonly pluginUi = inject(PluginUiRegistryService);
private readonly profileCard = inject(ProfileCardService); private readonly profileCard = inject(ProfileCardService);
private readonly router = inject(Router);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
readonly message = input.required<Message>(); readonly message = input.required<Message>();
@@ -150,7 +163,9 @@ export class ChatMessageItemComponent {
readonly commonEmojis = COMMON_EMOJIS; readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT; readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content)); readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
readonly isEditing = signal(false); readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => { readonly senderUser = computed<User>(() => {
@@ -196,28 +211,52 @@ export class ChatMessageItemComponent {
}); });
}); });
private findPluginEmbeds(content: string) { openMissingPluginStore(fallback: MissingPluginEmbedFallback): void {
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim()); const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
if (!match) { void this.router.navigate(['/plugin-store'], {
queryParams: {
returnUrl,
search: fallback.searchTerm
}
});
}
private findPluginEmbeds(token: PluginEmbedToken | null) {
if (!token) {
return []; return [];
} }
const [ const payload = parseEmbedPayload(token.payloadText);
,
embedType,
payloadText
] = match;
const payload = parseEmbedPayload(payloadText);
return this.pluginUi.embedRecords() return this.pluginUi.embedRecords()
.filter((record) => record.contribution.embedType === embedType) .filter((record) => record.contribution.embedType === token.embedType)
.map((record) => ({ .map((record) => ({
...record, ...record,
render: () => record.contribution.render(payload) render: () => record.contribution.render(payload)
})); }));
} }
private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null {
const token = this.pluginEmbedToken();
if (!token || this.pluginEmbeds().length > 0) {
return null;
}
const missingRequirement = this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType)
?? this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds'))
?? this.pluginRequirements.missingRequiredRequirements()[0];
const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType);
return {
pluginName,
searchTerm: pluginName
};
}
startEdit(): void { startEdit(): void {
this.editContent = this.message().content; this.editContent = this.message().content;
this.isEditing.set(true); this.isEditing.set(true);
@@ -535,6 +574,29 @@ export class ChatMessageItemComponent {
} }
} }
function parsePluginEmbedToken(content: string): PluginEmbedToken | null {
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
if (!match) {
return null;
}
return {
embedType: match[1],
payloadText: match[2]
};
}
function pluginNameFromEmbedType(embedType: string): string {
const parts = embedType.split(/[.:_-]/).filter(Boolean);
const pluginParts = parts.length > 2 ? parts.slice(0, -1) : parts;
const label = pluginParts.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
.trim();
return label || embedType;
}
function parseEmbedPayload(payloadText: string | undefined): unknown { function parseEmbedPayload(payloadText: string | undefined): unknown {
if (!payloadText?.trim()) { if (!payloadText?.trim()) {
return null; return null;

View File

@@ -25,6 +25,7 @@ const MAX_SHOWN = 4;
interface TypingSignalingMessage { interface TypingSignalingMessage {
type: string; type: string;
displayName: string; displayName: string;
isTyping?: boolean;
oderId: string; oderId: string;
serverId: string; serverId: string;
channelId?: string; channelId?: string;
@@ -66,8 +67,14 @@ export class TypingIndicatorComponent {
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim() const channelId = typeof msg.channelId === 'string' && msg.channelId.trim()
? msg.channelId.trim() ? msg.channelId.trim()
: 'general'; : 'general';
const typingKey = `${channelId}:${msg.oderId}`;
this.typingMap.set(`${channelId}:${msg.oderId}`, { if (msg.isTyping === false) {
this.typingMap.delete(typingKey);
return;
}
this.typingMap.set(typingKey, {
name: msg.displayName, name: msg.displayName,
channelId, channelId,
expiresAt: now + TYPING_TTL expiresAt: now + TYPING_TTL

View File

@@ -20,6 +20,8 @@ Plugin data that belongs to the current client uses the Electron database when t
Plugins can communicate over a plugin-only message bus through `api.messageBus`. It sends `plugin-message-bus` data-channel events that are ignored by the normal chat message reducers/effects, can target a peer or broadcast to connected users, and can include a bounded latest-message snapshot filtered by channel, timestamp, and deletion state. Plugins can communicate over a plugin-only message bus through `api.messageBus`. It sends `plugin-message-bus` data-channel events that are ignored by the normal chat message reducers/effects, can target a peer or broadcast to connected users, and can include a bounded latest-message snapshot filtered by channel, timestamp, and deletion state.
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.
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback. Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id. Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. 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

@@ -26,11 +26,15 @@ import { UsersActions } from '../../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type { import type {
PluginApiAvatarUpdate, PluginApiAvatarUpdate,
PluginApiActionContext,
PluginApiActionSource,
PluginApiChannelRequest, PluginApiChannelRequest,
PluginApiCustomStreamRequest, PluginApiCustomStreamRequest,
PluginApiMessageAsPluginUserRequest, PluginApiMessageAsPluginUserRequest,
PluginApiServerSettingsUpdate, PluginApiServerSettingsUpdate,
TojuClientPluginApi PluginApiTypingEvent,
TojuClientPluginApi,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models'; } from '../../domain/models/plugin-api.models';
import { PluginCapabilityService } from './plugin-capability.service'; import { PluginCapabilityService } from './plugin-capability.service';
import { PluginLoggerService } from './plugin-logger.service'; import { PluginLoggerService } from './plugin-logger.service';
@@ -122,6 +126,9 @@ export class PluginClientApiService {
info: (message, data) => this.logger.info(pluginId, message, data), info: (message, data) => this.logger.info(pluginId, message, data),
warn: (message, data) => this.logger.warn(pluginId, message, data) warn: (message, data) => this.logger.warn(pluginId, message, data)
}, },
context: {
getCurrent: () => this.createActionContext('manual')
},
clientData: { clientData: {
read: async (key) => { read: async (key) => {
requireCapability('storage.local'); requireCapability('storage.local');
@@ -183,6 +190,14 @@ export class PluginClientApiService {
requireCapability('messages.send'); requireCapability('messages.send');
this.receivePluginUserMessage(pluginId, request); this.receivePluginUserMessage(pluginId, request);
}, },
setTyping: (isTyping, channelId) => {
requireCapability('messages.send');
this.setTyping(pluginId, isTyping, channelId);
},
subscribeTyping: (handler) => {
requireCapability('messages.read');
return this.subscribeTyping(pluginId, handler);
},
sync: (messages) => { sync: (messages) => {
requireCapability('messages.sync'); requireCapability('messages.sync');
this.store.dispatch(MessagesActions.syncMessages({ messages })); this.store.dispatch(MessagesActions.syncMessages({ messages }));
@@ -402,6 +417,24 @@ export class PluginClientApiService {
}); });
} }
createActionContext(source: PluginApiActionSource): PluginApiActionContext {
const user = this.currentUser() ?? null;
const server = this.currentRoom();
const channels = this.currentRoomChannels();
const activeChannelId = this.activeChannelId() ?? 'general';
const voiceChannelId = user?.voiceState?.roomId ?? null;
return {
server,
source,
textChannel: channels.find((channel) => channel.type === 'text' && channel.id === activeChannelId) ?? null,
user,
voiceChannel: voiceChannelId
? channels.find((channel) => channel.type === 'voice' && channel.id === voiceChannelId) ?? null
: null
};
}
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void { private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false; const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
@@ -544,6 +577,71 @@ export class PluginClientApiService {
return message; return message;
} }
private setTyping(pluginId: string, isTyping: boolean, channelId?: string): void {
const roomId = this.requireRoomId();
try {
this.realtime.sendRawMessage({
type: 'typing',
serverId: roomId,
channelId: channelId ?? this.activeChannelId() ?? 'general',
isTyping
});
} catch (error: unknown) {
this.logger.warn(pluginId, 'Failed to publish typing state', error);
}
}
private subscribeTyping(pluginId: string, handler: (event: PluginApiTypingEvent) => void): TojuPluginDisposable {
const subscription = new Subscription();
subscription.add(this.realtime.onSignalingMessage.subscribe((message) => {
const record = message as Record<string, unknown>;
if (record['type'] !== 'user_typing') {
return;
}
const serverId = typeof record['serverId'] === 'string' ? record['serverId'] : '';
const currentServer = this.currentRoom();
if (!serverId || serverId !== currentServer?.id) {
return;
}
const userId = typeof record['oderId'] === 'string' ? record['oderId'] : '';
const displayName = typeof record['displayName'] === 'string' ? record['displayName'] : userId;
const channelId = typeof record['channelId'] === 'string' && record['channelId'].trim()
? record['channelId'].trim()
: 'general';
const user = this.users().find((entry) => entry.oderId === userId || entry.id === userId) ?? null;
const channels = this.currentRoomChannels();
handler({
channelId,
displayName,
isTyping: record['isTyping'] !== false,
server: currentServer,
serverId,
textChannel: channels.find((channel) => channel.type === 'text' && channel.id === channelId) ?? null,
user,
userId,
voiceChannel: user?.voiceState?.roomId
? channels.find((channel) => channel.type === 'voice' && channel.id === user.voiceState?.roomId) ?? null
: null
});
}));
this.logger.info(pluginId, 'Subscribed to typing events');
return {
dispose: () => {
subscription.unsubscribe();
this.logger.info(pluginId, 'Unsubscribed from typing events');
}
};
}
private persistPluginMessage(pluginId: string, message: Message): void { private persistPluginMessage(pluginId: string, message: Message): void {
void this.db.saveMessage(message).catch((error: unknown) => { void this.db.saveMessage(message).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin message', error); this.logger.warn(pluginId, 'Failed to persist plugin message', error);

View File

@@ -20,6 +20,7 @@ import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory'; import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
import { PluginRegistryService } from './plugin-registry.service'; import { PluginRegistryService } from './plugin-registry.service';
import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRequirementService } from './plugin-requirement.service';
import { PluginStoreService } from './plugin-store.service';
const STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS = 'metoyou_optional_plugin_requirement_dismissals'; const STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS = 'metoyou_optional_plugin_requirement_dismissals';
@@ -44,6 +45,7 @@ export interface PluginRequirementComparison {
export class PluginRequirementStateService { export class PluginRequirementStateService {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly pluginRequirements = inject(PluginRequirementService); private readonly pluginRequirements = inject(PluginRequirementService);
private readonly pluginStore = inject(PluginStoreService);
private readonly realtime = inject(RealtimeSessionFacade); private readonly realtime = inject(RealtimeSessionFacade);
private readonly registry = inject(PluginRegistryService); private readonly registry = inject(PluginRegistryService);
private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly serverDirectory = inject(ServerDirectoryFacade);
@@ -63,6 +65,10 @@ export class PluginRequirementStateService {
}); });
readonly refreshErrors = this.refreshErrorsSignal.asReadonly(); readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
readonly missingInstallableRequirements = computed(() => { readonly missingInstallableRequirements = computed(() => {
if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) {
return [];
}
const requirements: PluginRequirementSummary[] = []; const requirements: PluginRequirementSummary[] = [];
for (const comparison of this.comparisons()) { for (const comparison of this.comparisons()) {

View File

@@ -59,6 +59,13 @@ export interface PluginStoreInstallOptions {
serverId?: string; serverId?: string;
} }
interface ServerInstalledPluginsLoadState {
actorUserId: string | null;
loaded: boolean;
loading: boolean;
roomId: string | null;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PluginStoreService { export class PluginStoreService {
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
@@ -79,6 +86,12 @@ export class PluginStoreService {
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]); private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]); private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]); private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsLoadStateSignal = signal<ServerInstalledPluginsLoadState>({
actorUserId: null,
loaded: false,
loading: false,
roomId: null
});
private readonly loadingSignal = signal(false); private readonly loadingSignal = signal(false);
private refreshAbortController: AbortController | null = null; private refreshAbortController: AbortController | null = null;
private refreshVersion = 0; private refreshVersion = 0;
@@ -98,6 +111,21 @@ export class PluginStoreService {
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.()); readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin]))); readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device'); readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
readonly serverInstalledPluginsReadyForCurrentRoom = computed(() => {
const roomId = this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
if (!roomId || !actorUserId || !this.serverDirectory) {
return true;
}
const loadState = this.serverInstalledPluginsLoadStateSignal();
return loadState.loaded
&& !loadState.loading
&& loadState.roomId === roomId
&& loadState.actorUserId === actorUserId;
});
constructor() { constructor() {
const state = this.loadState(); const state = this.loadState();
@@ -653,12 +681,24 @@ export class PluginStoreService {
const currentLoad = this.installedLoadVersion + 1; const currentLoad = this.installedLoadVersion + 1;
this.installedLoadVersion = currentLoad; this.installedLoadVersion = currentLoad;
this.serverInstalledPluginsLoadStateSignal.set({
actorUserId,
loaded: false,
loading: true,
roomId
});
await Promise.resolve(); await Promise.resolve();
if (!roomId || !actorUserId || !this.serverDirectory) { if (!roomId || !actorUserId || !this.serverDirectory) {
if (this.installedLoadVersion === currentLoad) { if (this.installedLoadVersion === currentLoad) {
await this.applyInstalledPlugins([], 'server'); await this.applyInstalledPlugins([], 'server');
this.serverInstalledPluginsLoadStateSignal.set({
actorUserId,
loaded: true,
loading: false,
roomId
});
} }
return; return;
@@ -669,10 +709,22 @@ export class PluginStoreService {
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) { if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins(installedPlugins, 'server'); await this.applyInstalledPlugins(installedPlugins, 'server');
this.serverInstalledPluginsLoadStateSignal.set({
actorUserId,
loaded: true,
loading: false,
roomId
});
} }
} catch { } catch {
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) { if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins([], 'server'); await this.applyInstalledPlugins([], 'server');
this.serverInstalledPluginsLoadStateSignal.set({
actorUserId,
loaded: true,
loading: false,
roomId
});
} }
} }
} }

View File

@@ -84,6 +84,24 @@ export interface PluginApiEventSubscription {
handler: (event: PluginEventEnvelope) => void; handler: (event: PluginEventEnvelope) => void;
} }
export type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
export interface PluginApiActionContext {
server: Room | null;
source: PluginApiActionSource;
textChannel: Channel | null;
user: User | null;
voiceChannel: Channel | null;
}
export interface PluginApiTypingEvent extends Omit<PluginApiActionContext, 'source'> {
channelId: string;
displayName: string;
isTyping: boolean;
serverId: string;
userId: string;
}
export interface PluginApiMessageBusEnvelope { export interface PluginApiMessageBusEnvelope {
channelId?: string; channelId?: string;
eventId: string; eventId: string;
@@ -149,7 +167,7 @@ export interface PluginApiChannelSectionContribution {
export interface PluginApiActionContribution { export interface PluginApiActionContribution {
icon?: string; icon?: string;
label: string; label: string;
run: () => Promise<void> | void; run: (context: PluginApiActionContext) => Promise<void> | void;
} }
export interface PluginApiEmbedRendererContribution { export interface PluginApiEmbedRendererContribution {
@@ -195,6 +213,9 @@ export interface TojuClientPluginApi {
info: (message: string, data?: unknown) => void; info: (message: string, data?: unknown) => void;
warn: (message: string, data?: unknown) => void; warn: (message: string, data?: unknown) => void;
}; };
readonly context: {
getCurrent: () => PluginApiActionContext;
};
readonly clientData: { readonly clientData: {
read: (key: string) => Promise<unknown>; read: (key: string) => Promise<unknown>;
remove: (key: string) => Promise<void>; remove: (key: string) => Promise<void>;
@@ -214,6 +235,8 @@ export interface TojuClientPluginApi {
readCurrent: () => Message[]; readCurrent: () => Message[];
send: (content: string, channelId?: string) => Message; send: (content: string, channelId?: string) => Message;
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void; sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
setTyping: (isTyping: boolean, channelId?: string) => void;
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
sync: (messages: Message[]) => void; sync: (messages: Message[]) => void;
}; };
readonly messageBus: { readonly messageBus: {

View File

@@ -189,6 +189,12 @@ export class PluginStoreComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
const searchQuery = this.route.snapshot.queryParamMap.get('search')?.trim();
if (searchQuery) {
this.searchTerm.set(searchQuery);
}
if (this.store.sourceUrls().length > 0 && this.store.sources().length === 0) { if (this.store.sourceUrls().length > 0 && this.store.sources().length === 0) {
void this.refreshSources(); void this.refreshSources();
} }