feat: expose more apis
This commit is contained in:
@@ -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 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -26,11 +26,15 @@ import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import type {
|
||||
PluginApiAvatarUpdate,
|
||||
PluginApiActionContext,
|
||||
PluginApiActionSource,
|
||||
PluginApiChannelRequest,
|
||||
PluginApiCustomStreamRequest,
|
||||
PluginApiMessageAsPluginUserRequest,
|
||||
PluginApiServerSettingsUpdate,
|
||||
TojuClientPluginApi
|
||||
PluginApiTypingEvent,
|
||||
TojuClientPluginApi,
|
||||
TojuPluginDisposable
|
||||
} from '../../domain/models/plugin-api.models';
|
||||
import { PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginLoggerService } from './plugin-logger.service';
|
||||
@@ -122,6 +126,9 @@ export class PluginClientApiService {
|
||||
info: (message, data) => this.logger.info(pluginId, message, data),
|
||||
warn: (message, data) => this.logger.warn(pluginId, message, data)
|
||||
},
|
||||
context: {
|
||||
getCurrent: () => this.createActionContext('manual')
|
||||
},
|
||||
clientData: {
|
||||
read: async (key) => {
|
||||
requireCapability('storage.local');
|
||||
@@ -183,6 +190,14 @@ export class PluginClientApiService {
|
||||
requireCapability('messages.send');
|
||||
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) => {
|
||||
requireCapability('messages.sync');
|
||||
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 {
|
||||
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
|
||||
|
||||
@@ -544,6 +577,71 @@ export class PluginClientApiService {
|
||||
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 {
|
||||
void this.db.saveMessage(message).catch((error: unknown) => {
|
||||
this.logger.warn(pluginId, 'Failed to persist plugin message', error);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/
|
||||
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
|
||||
import { PluginRegistryService } from './plugin-registry.service';
|
||||
import { PluginRequirementService } from './plugin-requirement.service';
|
||||
import { PluginStoreService } from './plugin-store.service';
|
||||
|
||||
const STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS = 'metoyou_optional_plugin_requirement_dismissals';
|
||||
|
||||
@@ -44,6 +45,7 @@ export interface PluginRequirementComparison {
|
||||
export class PluginRequirementStateService {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly pluginRequirements = inject(PluginRequirementService);
|
||||
private readonly pluginStore = inject(PluginStoreService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly registry = inject(PluginRegistryService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
@@ -63,6 +65,10 @@ export class PluginRequirementStateService {
|
||||
});
|
||||
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
|
||||
readonly missingInstallableRequirements = computed(() => {
|
||||
if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requirements: PluginRequirementSummary[] = [];
|
||||
|
||||
for (const comparison of this.comparisons()) {
|
||||
|
||||
@@ -59,6 +59,13 @@ export interface PluginStoreInstallOptions {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface ServerInstalledPluginsLoadState {
|
||||
actorUserId: string | null;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
roomId: string | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginStoreService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
@@ -79,6 +86,12 @@ export class PluginStoreService {
|
||||
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
||||
private readonly clientInstalledPluginsSignal = 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 refreshAbortController: AbortController | null = null;
|
||||
private refreshVersion = 0;
|
||||
@@ -98,6 +111,21 @@ export class PluginStoreService {
|
||||
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
|
||||
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
|
||||
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() {
|
||||
const state = this.loadState();
|
||||
@@ -653,12 +681,24 @@ export class PluginStoreService {
|
||||
const currentLoad = this.installedLoadVersion + 1;
|
||||
|
||||
this.installedLoadVersion = currentLoad;
|
||||
this.serverInstalledPluginsLoadStateSignal.set({
|
||||
actorUserId,
|
||||
loaded: false,
|
||||
loading: true,
|
||||
roomId
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
if (!roomId || !actorUserId || !this.serverDirectory) {
|
||||
if (this.installedLoadVersion === currentLoad) {
|
||||
await this.applyInstalledPlugins([], 'server');
|
||||
this.serverInstalledPluginsLoadStateSignal.set({
|
||||
actorUserId,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
roomId
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -669,10 +709,22 @@ export class PluginStoreService {
|
||||
|
||||
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
|
||||
await this.applyInstalledPlugins(installedPlugins, 'server');
|
||||
this.serverInstalledPluginsLoadStateSignal.set({
|
||||
actorUserId,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
roomId
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
|
||||
await this.applyInstalledPlugins([], 'server');
|
||||
this.serverInstalledPluginsLoadStateSignal.set({
|
||||
actorUserId,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
roomId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,24 @@ export interface PluginApiEventSubscription {
|
||||
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 {
|
||||
channelId?: string;
|
||||
eventId: string;
|
||||
@@ -149,7 +167,7 @@ export interface PluginApiChannelSectionContribution {
|
||||
export interface PluginApiActionContribution {
|
||||
icon?: string;
|
||||
label: string;
|
||||
run: () => Promise<void> | void;
|
||||
run: (context: PluginApiActionContext) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PluginApiEmbedRendererContribution {
|
||||
@@ -195,6 +213,9 @@ export interface TojuClientPluginApi {
|
||||
info: (message: string, data?: unknown) => void;
|
||||
warn: (message: string, data?: unknown) => void;
|
||||
};
|
||||
readonly context: {
|
||||
getCurrent: () => PluginApiActionContext;
|
||||
};
|
||||
readonly clientData: {
|
||||
read: (key: string) => Promise<unknown>;
|
||||
remove: (key: string) => Promise<void>;
|
||||
@@ -214,6 +235,8 @@ export interface TojuClientPluginApi {
|
||||
readCurrent: () => Message[];
|
||||
send: (content: string, channelId?: string) => Message;
|
||||
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
|
||||
setTyping: (isTyping: boolean, channelId?: string) => void;
|
||||
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
|
||||
sync: (messages: Message[]) => void;
|
||||
};
|
||||
readonly messageBus: {
|
||||
|
||||
@@ -189,6 +189,12 @@ export class PluginStoreComponent implements OnInit {
|
||||
}
|
||||
|
||||
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) {
|
||||
void this.refreshSources();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user