fix: improve plugins functionality with server management

This commit is contained in:
2026-04-29 20:33:54 +02:00
parent b8f6d58d99
commit fa2cca6fa4
82 changed files with 1708 additions and 303 deletions

View File

@@ -1,4 +1,4 @@
/* eslint-disable max-statements-per-line */
import {
Component,
inject,

View File

@@ -1,4 +1,4 @@
/* eslint-disable max-statements-per-line */
import {
Component,
inject,

View File

@@ -5,17 +5,13 @@ import {
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
import type {
DirectMessage,
DirectMessageParticipant
} from '../../domain/models/direct-message.model';
import type { DirectMessage, DirectMessageParticipant } from '../../domain/models/direct-message.model';
const alice: DirectMessageParticipant = {
userId: 'alice',
username: 'alice',
displayName: 'Alice'
};
const bob: DirectMessageParticipant = {
userId: 'bob',
username: 'bob',

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,

View File

@@ -133,7 +133,7 @@ export class NotificationsEffects {
this.notifications.refreshRoomUnreadFromMessages(roomId, roomMessages);
}
})
)
, { dispatch: false }
),
{ dispatch: false }
);
}

View File

@@ -6,17 +6,17 @@ The signal server stores plugin install metadata and event definitions, but it m
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server. Server plugin downloads are user-local and server-specific: a server can publish requirement metadata, but each account must consent before those plugins are downloaded or activated on join. Members who are already in a server see new required plugin requirements as a blocking prompt with Install plugins or Leave server actions; new optional or recommended requirements appear as a title-bar banner that can be installed, rejected for the current session, or hidden for that server/plugin requirement version.
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically.
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated user-scoped `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
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.

View File

@@ -25,30 +25,32 @@ export class PluginDesktopStateService {
}
private async readRaw(key: string): Promise<string | null> {
const scopedKey = getUserScopedStorageKey(key);
const api = this.electronBridge.getApi();
if (api) {
return await api.query<string | null>({
type: 'get-meta',
payload: { key }
payload: { key: scopedKey }
});
}
return localStorage.getItem(getUserScopedStorageKey(key));
return localStorage.getItem(scopedKey);
}
private async writeRaw(key: string, value: string): Promise<void> {
const scopedKey = getUserScopedStorageKey(key);
const api = this.electronBridge.getApi();
if (api) {
await api.command({
type: 'save-meta',
payload: { key, value }
payload: { key: scopedKey, value }
});
return;
}
localStorage.setItem(getUserScopedStorageKey(key), value);
localStorage.setItem(scopedKey, value);
}
}

View File

@@ -15,11 +15,16 @@ import type {
TojuPluginManifest
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginRequirementService } from './plugin-requirement.service';
const STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS = 'metoyou_optional_plugin_requirement_dismissals';
type RequirementDismissalState = Record<string, Record<string, number>>;
export type PluginRequirementComparisonStatus =
| 'blockedByServer'
| 'disabled'
@@ -48,6 +53,8 @@ export class PluginRequirementStateService {
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
readonly currentSnapshot = computed(() => {
const roomId = this.currentRoomId();
@@ -55,6 +62,22 @@ export class PluginRequirementStateService {
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
});
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
readonly missingInstallableRequirements = computed(() => {
const requirements: PluginRequirementSummary[] = [];
for (const comparison of this.comparisons()) {
if (this.isMissingInstallableRequirement(comparison) && comparison.requirement) {
requirements.push(comparison.requirement);
}
}
return requirements;
});
readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements()
.filter((requirement) => requirement.status === 'required'));
readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements()
.filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended')
.filter((requirement) => !this.isOptionalRequirementDismissed(requirement)));
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
const snapshot = this.currentSnapshot();
const installedEntries = this.registry.entries();
@@ -138,6 +161,36 @@ export class PluginRequirementStateService {
return this.comparisons().find((comparison) => comparison.pluginId === pluginId) ?? null;
}
dismissOptionalRequirement(requirement: PluginRequirementSummary, options: { persist?: boolean } = {}): void {
const roomId = this.currentRoomId();
if (!roomId) {
return;
}
if (options.persist) {
this.hiddenOptionalSignal.update((dismissals) => {
const nextDismissals = {
...dismissals,
[roomId]: {
...(dismissals[roomId] ?? {}),
[requirement.pluginId]: requirement.updatedAt
}
};
saveRequirementDismissals(nextDismissals);
return nextDismissals;
});
return;
}
this.sessionDismissedOptionalSignal.update((dismissals) => ({
...dismissals,
[roomId]: Array.from(new Set([...(dismissals[roomId] ?? []), requirement.pluginId]))
}));
}
private setSnapshot(serverId: string, snapshot: PluginRequirementsSnapshot): void {
this.snapshotsSignal.update((snapshots) => ({
...snapshots,
@@ -184,6 +237,70 @@ export class PluginRequirementStateService {
return 'enabled';
}
private isMissingInstallableRequirement(comparison: PluginRequirementComparison): boolean {
const requirement = comparison.requirement;
return comparison.status === 'missing'
&& !!requirement
&& (requirement.status === 'required' || requirement.status === 'optional' || requirement.status === 'recommended')
&& (!!requirement.manifest || !!requirement.installUrl);
}
private isOptionalRequirementDismissed(requirement: PluginRequirementSummary): boolean {
const roomId = this.currentRoomId();
if (!roomId) {
return true;
}
if ((this.sessionDismissedOptionalSignal()[roomId] ?? []).includes(requirement.pluginId)) {
return true;
}
const hiddenAt = this.hiddenOptionalSignal()[roomId]?.[requirement.pluginId];
return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt;
}
}
function loadRequirementDismissals(): RequirementDismissalState {
try {
const rawValue = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS));
if (!rawValue) {
return {};
}
return normalizeRequirementDismissals(JSON.parse(rawValue) as unknown);
} catch {
return {};
}
}
function saveRequirementDismissals(dismissals: RequirementDismissalState): void {
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS), JSON.stringify(dismissals));
} catch {}
}
function normalizeRequirementDismissals(value: unknown): RequirementDismissalState {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.map(([serverId, serverValue]) => [serverId, normalizeServerDismissals(serverValue)])
.filter(([, serverValue]) => Object.keys(serverValue).length > 0));
}
function normalizeServerDismissals(value: unknown): Record<string, number> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.filter((entry): entry is [string, number] => typeof entry[1] === 'number'));
}
function isSnapshotMessage(message: unknown): message is { serverId: string; snapshot: PluginRequirementsSnapshot } {

View File

@@ -30,6 +30,7 @@ import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.l
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import type {
InstalledStorePlugin,
PersistedServerPluginInstallState,
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
@@ -44,6 +45,7 @@ import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs';
const PLUGIN_CACHE_DIR = 'plugin-bundles';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
installedPlugins: [],
@@ -238,6 +240,10 @@ export class PluginStoreService {
installedPlugin: InstalledStorePlugin,
options: PluginStoreInstallOptions
): Promise<void> {
if (installScope === 'server' && targetServerId) {
await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins);
}
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
if (options.activate) {
await this.host.rememberActivation(installedPlugin.manifest.id);
@@ -248,9 +254,7 @@ export class PluginStoreService {
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
if (installScope === 'client' || options.optional !== true) {
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
if (options.activate) {
await this.host.activatePluginById(installedPlugin.manifest.id);
@@ -291,6 +295,63 @@ export class PluginStoreService {
return await this.installedPluginsForServer(serverId);
}
async getLocalServerInstalledPluginIds(serverId: string): Promise<Set<string>> {
const installedPlugins = await this.readLocalServerInstalledPlugins(serverId);
return new Set(installedPlugins.map((installedPlugin) => installedPlugin.manifest.id));
}
async installServerRequirementsLocally(
serverId: string,
requirements: PluginRequirementSummary[],
options: { activate?: boolean } = {}
): Promise<InstalledStorePlugin[]> {
const installedPlugins: InstalledStorePlugin[] = [];
for (const requirement of requirements) {
const installedPlugin = await this.resolveLocalInstallFromRequirement(requirement);
if (installedPlugin) {
installedPlugins.push(installedPlugin);
}
}
if (installedPlugins.length === 0) {
return await this.readLocalServerInstalledPlugins(serverId);
}
const currentInstalledPlugins = await this.readLocalServerInstalledPlugins(serverId);
const currentById = new Map(currentInstalledPlugins.map((installedPlugin) => [installedPlugin.manifest.id, installedPlugin]));
const nextById = new Map(currentById);
for (const installedPlugin of installedPlugins) {
const existing = currentById.get(installedPlugin.manifest.id);
const cachedPlugin = await this.cacheInstalledPlugin({
...installedPlugin,
installedAt: existing?.installedAt ?? installedPlugin.installedAt,
updatedAt: installedPlugin.updatedAt
});
nextById.set(cachedPlugin.manifest.id, cachedPlugin);
}
const nextInstalledPlugins = Array.from(nextById.values()).sort(sortInstalledPlugins);
if (options.activate) {
for (const installedPlugin of installedPlugins) {
await this.host.rememberActivation(installedPlugin.manifest.id);
}
}
await this.writeLocalServerInstalledPlugins(serverId, nextInstalledPlugins);
if (serverId === this.currentRoomId?.()) {
await this.applyInstalledPlugins(nextInstalledPlugins, 'server');
}
return nextInstalledPlugins;
}
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
if (!plugin.readmeUrl) {
throw new Error('Plugin does not provide a readme URL');
@@ -604,7 +665,7 @@ export class PluginStoreService {
}
try {
const installedPlugins = await this.readServerInstalledPlugins(roomId);
const installedPlugins = await this.readLocalServerInstalledPlugins(roomId);
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins(installedPlugins, 'server');
@@ -650,6 +711,56 @@ export class PluginStoreService {
.sort(sortInstalledPlugins);
}
private async resolveLocalInstallFromRequirement(requirement: PluginRequirementSummary): Promise<InstalledStorePlugin | null> {
const existingPlugin = installedPluginFromRequirement(requirement, { includeOptional: true });
if (existingPlugin) {
return existingPlugin;
}
if (!requirement.installUrl) {
return null;
}
const manifest = await this.fetchPluginManifest(requirement.installUrl);
if (getPluginInstallScope(manifest) !== 'server') {
return null;
}
return {
bundleUrl: manifest.bundle?.url,
installedAt: requirement.updatedAt,
installUrl: requirement.installUrl,
manifest,
sourceUrl: requirement.sourceUrl,
updatedAt: requirement.updatedAt
};
}
private async readLocalServerInstalledPlugins(serverId: string): Promise<InstalledStorePlugin[]> {
const state = await this.desktopState.readJson<PersistedServerPluginInstallState>(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {});
const normalized = normalizePersistedServerPluginInstallState(state);
return normalized.servers[serverId] ?? [];
}
private async writeLocalServerInstalledPlugins(serverId: string, installedPlugins: InstalledStorePlugin[]): Promise<void> {
const state = await this.desktopState.readJson<PersistedServerPluginInstallState>(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {});
const normalized = normalizePersistedServerPluginInstallState(state);
const nextServers = installedPlugins.length === 0
? Object.fromEntries(Object.entries(normalized.servers).filter(([candidateServerId]) => candidateServerId !== serverId))
: {
...normalized.servers,
[serverId]: installedPlugins
};
await this.desktopState.writeJson(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {
schemaVersion: STORE_SCHEMA_VERSION,
servers: nextServers
});
}
private async saveServerPluginRequirement(
installedPlugin: InstalledStorePlugin,
roomId: string | null,
@@ -735,10 +846,6 @@ export class PluginStoreService {
return [];
}
if (serverId === this.currentRoomId?.()) {
return this.serverInstalledPluginsSignal();
}
const actorUserId = this.currentActorUserId();
if (!actorUserId || !this.serverDirectory) {
@@ -839,8 +946,15 @@ function isPluginRequirementsChangedMessage(message: unknown): message is { serv
&& typeof message['serverId'] === 'string';
}
function installedPluginFromRequirement(requirement: PluginRequirementSummary): InstalledStorePlugin | null {
if (requirement.status === 'optional' || requirement.status === 'blocked' || requirement.status === 'incompatible') {
function installedPluginFromRequirement(
requirement: PluginRequirementSummary,
options: { includeOptional?: boolean } = {}
): InstalledStorePlugin | null {
if (requirement.status === 'blocked' || requirement.status === 'incompatible') {
return null;
}
if (requirement.status === 'optional' && options.includeOptional !== true) {
return null;
}
@@ -945,6 +1059,31 @@ function normalizePersistedState(value: unknown): PersistedPluginStoreState {
};
}
function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } {
if (!isRecord(value) || !isRecord(value['servers'])) {
return { servers: {} };
}
const servers: Record<string, InstalledStorePlugin[]> = {};
for (const [serverId, installedPlugins] of Object.entries(value['servers'])) {
if (!Array.isArray(installedPlugins)) {
continue;
}
const normalizedPlugins = installedPlugins
.filter(isInstalledStorePlugin)
.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === 'server')
.sort(sortInstalledPlugins);
if (normalizedPlugins.length > 0) {
servers[serverId] = normalizedPlugins;
}
}
return { servers };
}
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
if (!isRecord(value) || !isRecord(value['manifest'])) {
return false;

View File

@@ -50,3 +50,8 @@ export interface PersistedPluginStoreState {
installedPlugins: InstalledStorePlugin[];
sourceUrls: string[];
}
export interface PersistedServerPluginInstallState {
schemaVersion?: number;
servers?: Record<string, InstalledStorePlugin[]>;
}

View File

@@ -1,5 +1,9 @@
<main class="min-h-screen bg-background p-6 text-foreground">
<a routerLink="/search" class="text-sm text-muted-foreground hover:text-foreground">Back</a>
<a
routerLink="/search"
class="text-sm text-muted-foreground hover:text-foreground"
>Back</a
>
@if (page(); as pageRecord) {
<section class="mx-auto mt-6 max-w-5xl">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">{{ pageRecord.pluginId }}</p>

View File

@@ -153,12 +153,16 @@ The API service normalises every `ServerInfo` response, filling in `sourceId`, `
That search fan-out is discovery only. Once a room is created or joined, the room keeps an authoritative signal-server affinity via its `sourceId` / `sourceUrl`. The join response can repair stale saved metadata, and reconnect logic now retries that authoritative endpoint first before probing any other configured endpoints.
The `/search` My Servers row and the server rail both read from the active user's local room ownership. Switching accounts reloads that scoped cache so joined servers and local history do not bleed between users.
Fallback stays temporary. If the authoritative endpoint is unavailable, the client can probe other active compatible endpoints as a last resort for the current session, but it does not rewrite the room's saved affinity to that fallback endpoint.
## Server-owned room metadata
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
Server icons are uploaded through the server settings page. Static sources are drawn into a `64x64` canvas and encoded using the smallest browser-supported output among WebP, JPEG, and PNG. Small animated GIF/WebP icons are kept animated. Server icon UI surfaces render the image as a CSS background instead of an `<img>` element so the icon cannot be dragged out of the app.
The renderer may cache room data locally, but channel creation, rename, and removal must round-trip through the server-directory API instead of being treated as client-only state. Server-side normalisation deduplicates channel names within each channel type, so a text `general` channel and a voice `General` channel can coexist while duplicate voice-to-voice or text-to-text names are still rejected.
## Default endpoint management

View File

@@ -103,11 +103,11 @@
<div class="flex min-w-0 items-start gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
@if (server.icon) {
<img
[src]="server.icon"
[alt]="server.name + ' icon'"
class="h-full w-full object-cover"
/>
<div
aria-hidden="true"
class="h-full w-full bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + server.icon + ')'"
></div>
} @else {
{{ server.name[0] || '?' }}
}
@@ -297,6 +297,96 @@
</app-confirm-dialog>
}
@if (pluginConsentDialog(); as dialog) {
<div
class="fixed inset-0 z-50 bg-black/50"
role="presentation"
></div>
<section
class="fixed left-1/2 top-1/2 z-[51] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="join-plugin-consent-title"
>
<header class="border-b border-border p-4">
<p class="text-sm text-muted-foreground">Plugin downloads</p>
<h2
id="join-plugin-consent-title"
class="mt-1 text-lg font-semibold"
>
{{ dialog.server.name }} uses plugins
</h2>
</header>
<div class="grid min-h-0 gap-4 overflow-auto p-4">
@if (dialog.required.length > 0) {
<section class="grid gap-2">
<h3 class="text-sm font-semibold">Required before joining</h3>
@for (requirement of dialog.required; track requirement.pluginId) {
<div class="rounded-lg border border-border bg-background/50 px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</p>
@if (requirement.reason) {
<p class="mt-1 text-xs text-muted-foreground">{{ requirement.reason }}</p>
}
</div>
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
</div>
</div>
}
</section>
}
@if (dialog.optional.length > 0) {
<section class="grid gap-2">
<h3 class="text-sm font-semibold">Optional plugins</h3>
@for (requirement of dialog.optional; track requirement.pluginId) {
<label class="flex items-start gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
[disabled]="pluginConsentBusy()"
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
/>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
@if (requirement.reason) {
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</span>
}
</span>
</label>
}
</section>
}
@if (pluginConsentError()) {
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentError() }}</p>
}
</div>
<footer class="flex justify-end gap-2 border-t border-border p-4">
<button
type="button"
(click)="closePluginConsentDialog()"
[disabled]="pluginConsentBusy()"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
Cancel join
</button>
<button
type="button"
(click)="confirmPluginConsent()"
[disabled]="pluginConsentBusy()"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
>
{{ pluginConsentBusy() ? 'Downloading' : dialog.required.length > 0 ? 'Accept and join' : 'Join' }}
</button>
</footer>
</section>
}
<!-- Create Server Dialog -->
@if (showCreateDialog()) {
<div

View File

@@ -1,30 +1,77 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, effect, inject, OnInit, signal } from '@angular/core';
import {
Component,
effect,
inject,
OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { debounceTime, distinctUntilChanged, firstValueFrom, Subject } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
firstValueFrom,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown } from '@ng-icons/lucide';
import {
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
lucideChevronDown
} from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { Room, User } from '../../../../shared-kernel';
import {
selectSearchResults,
selectIsSearching,
selectRoomsError,
selectSavedRooms
} from '../../../../store/rooms/rooms.selectors';
import {
Room,
User,
type PluginRequirementSummary
} from '../../../../shared-kernel';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ConfirmDialogComponent, LeaveServerDialogComponent, type LeaveServerDialogResult } from '../../../../shared';
import {
ConfirmDialogComponent,
LeaveServerDialogComponent,
type LeaveServerDialogResult
} from '../../../../shared';
import { hasRoomBanForUser } from '../../../access-control';
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
interface JoinPluginConsentDialog {
optional: PluginRequirementSummary[];
password?: string;
required: PluginRequirementSummary[];
server: ServerInfo;
}
@Component({
selector: 'app-server-search',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent
],
viewProviders: [
provideIcons({
lucideSearch,
@@ -49,6 +96,8 @@ export class ServerSearchComponent implements OnInit {
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private pluginRequirements = inject(PluginRequirementService);
private pluginStore = inject(PluginStoreService);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
@@ -69,6 +118,10 @@ export class ServerSearchComponent implements OnInit {
joinErrorMessage = signal<string | null>(null);
joinedServerMenuId = signal<string | null>(null);
leaveDialogRoom = signal<Room | null>(null);
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
selectedOptionalPluginIds = signal<Set<string>>(new Set());
pluginConsentBusy = signal(false);
pluginConsentError = signal<string | null>(null);
// Create dialog state
showCreateDialog = signal(false);
@@ -138,7 +191,8 @@ export class ServerSearchComponent implements OnInit {
/** Submit the new server creation form and dispatch the create action. */
createServer(): void {
if (!this.newServerName()) return;
if (!this.newServerName())
return;
const currentUserId = localStorage.getItem('metoyou_currentUserId');
@@ -244,10 +298,65 @@ export class ServerSearchComponent implements OnInit {
this.joinPasswordError.set(null);
}
closePluginConsentDialog(): void {
if (this.pluginConsentBusy()) {
return;
}
this.pluginConsentDialog.set(null);
this.selectedOptionalPluginIds.set(new Set());
this.pluginConsentError.set(null);
}
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
this.selectedOptionalPluginIds.update((selectedIds) => {
const nextIds = new Set(selectedIds);
if (checked) {
nextIds.add(pluginId);
} else {
nextIds.delete(pluginId);
}
return nextIds;
});
}
async confirmPluginConsent(): Promise<void> {
const dialog = this.pluginConsentDialog();
if (!dialog) {
return;
}
const selectedOptionalIds = this.selectedOptionalPluginIds();
const acceptedRequirements = dialog.required.concat(
dialog.optional.filter((requirement) => selectedOptionalIds.has(requirement.pluginId))
);
this.pluginConsentBusy.set(true);
this.pluginConsentError.set(null);
try {
await this.attemptJoinServer(dialog.server, dialog.password, {
acceptedRequirements,
skipPluginConsent: true
});
this.pluginConsentDialog.set(null);
this.selectedOptionalPluginIds.set(new Set());
} catch (error) {
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
} finally {
this.pluginConsentBusy.set(false);
}
}
async confirmPasswordJoin(): Promise<void> {
const server = this.passwordPromptServer();
if (!server) return;
if (!server)
return;
await this.attemptJoinServer(server, this.joinPassword());
}
@@ -259,7 +368,8 @@ export class ServerSearchComponent implements OnInit {
getServerUserCount(server: ServerInfo): number {
const candidate = server as ServerInfo & { currentUsers?: number };
if (typeof server.userCount === 'number') return server.userCount;
if (typeof server.userCount === 'number')
return server.userCount;
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
}
@@ -302,7 +412,11 @@ export class ServerSearchComponent implements OnInit {
};
}
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
private async attemptJoinServer(
server: ServerInfo,
password?: string,
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
@@ -315,6 +429,16 @@ export class ServerSearchComponent implements OnInit {
this.joinPasswordError.set(null);
try {
if (options.skipPluginConsent !== true) {
const consentDialog = await this.buildPluginConsentDialog(server, password);
if (consentDialog) {
this.pluginConsentDialog.set(consentDialog);
this.selectedOptionalPluginIds.set(new Set(consentDialog.optional.map((requirement) => requirement.pluginId)));
return;
}
}
const response = await firstValueFrom(
this.serverDirectory.requestJoin(
{
@@ -351,6 +475,11 @@ export class ServerSearchComponent implements OnInit {
};
this.closePasswordDialog();
if (options.acceptedRequirements?.length) {
await this.pluginStore.installServerRequirementsLocally(resolvedServer.id, options.acceptedRequirements, { activate: true });
}
this.store.dispatch(
RoomsActions.joinRoom({
roomId: resolvedServer.id,
@@ -378,9 +507,40 @@ export class ServerSearchComponent implements OnInit {
}
this.joinErrorMessage.set(message);
if (options.skipPluginConsent) {
throw new Error(message);
}
}
}
private async buildPluginConsentDialog(server: ServerInfo, password?: string): Promise<JoinPluginConsentDialog | null> {
const apiBaseUrl = this.serverDirectory.getApiBaseUrl({
sourceId: server.sourceId,
sourceUrl: server.sourceUrl
});
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(apiBaseUrl, server.id));
const installedPluginIds = await this.pluginStore.getLocalServerInstalledPluginIds(server.id);
const installableRequirements = snapshot.requirements
.filter((requirement) => !installedPluginIds.has(requirement.pluginId))
.filter((requirement) => !!requirement.manifest || !!requirement.installUrl);
const required = installableRequirements.filter((requirement) => requirement.status === 'required');
const optional = installableRequirements.filter(
(requirement) => requirement.status === 'optional' || requirement.status === 'recommended'
);
if (required.length === 0 && optional.length === 0) {
return null;
}
return {
optional,
password,
required,
server
};
}
private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise<void> {
if (!currentUser) {
return;
@@ -415,6 +575,7 @@ export class ServerSearchComponent implements OnInit {
description: currentUser.description,
profileUpdatedAt: currentUser.profileUpdatedAt
});
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
type: 'server_icon_sync_request',
serverId: server.id,
@@ -444,7 +605,8 @@ export class ServerSearchComponent implements OnInit {
})
);
if (requestVersion !== this.banLookupRequestVersion) return;
if (requestVersion !== this.banLookupRequestVersion)
return;
this.bannedServerLookup.set(Object.fromEntries(entries));
}
@@ -453,7 +615,8 @@ export class ServerSearchComponent implements OnInit {
const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !currentUserId) return false;
if (!currentUser && !currentUserId)
return false;
const bans = await this.db.getBansForRoom(server.id);

View File

@@ -0,0 +1,146 @@
import { Injectable } from '@angular/core';
import { isAnimatedGif, isAnimatedWebp } from '../../../profile-avatar/infrastructure/services/profile-avatar-image.service';
export interface ProcessedServerIcon {
dataUrl: string;
mime: string;
size: number;
}
const SERVER_ICON_SIZE = 64;
const STATIC_ICON_CANDIDATES = [
{ mime: 'image/webp', quality: 0.82 },
{ mime: 'image/jpeg', quality: 0.82 },
{ mime: 'image/png' }
];
@Injectable({ providedIn: 'root' })
export class ServerIconImageService {
async process(file: File): Promise<ProcessedServerIcon> {
if (!file.type.startsWith('image/')) {
throw new Error('Choose an image file.');
}
const objectUrl = URL.createObjectURL(file);
try {
const image = await this.loadImage(objectUrl);
const isAnimated = await this.isAnimated(file);
if (isAnimated && image.naturalWidth <= SERVER_ICON_SIZE && image.naturalHeight <= SERVER_ICON_SIZE) {
const dataUrl = await this.readBlobAsDataUrl(file);
return {
dataUrl,
mime: file.type || this.resolveMimeFromDataUrl(dataUrl),
size: file.size
};
}
return await this.renderStaticIcon(image);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
private async renderStaticIcon(image: HTMLImageElement): Promise<ProcessedServerIcon> {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas not supported.');
}
canvas.width = SERVER_ICON_SIZE;
canvas.height = SERVER_ICON_SIZE;
const scale = Math.max(SERVER_ICON_SIZE / image.naturalWidth, SERVER_ICON_SIZE / image.naturalHeight);
const drawWidth = image.naturalWidth * scale;
const drawHeight = image.naturalHeight * scale;
const drawX = (SERVER_ICON_SIZE - drawWidth) / 2;
const drawY = (SERVER_ICON_SIZE - drawHeight) / 2;
context.clearRect(0, 0, SERVER_ICON_SIZE, SERVER_ICON_SIZE);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
const candidates = await Promise.all(
STATIC_ICON_CANDIDATES.map(async (candidate) => {
const blob = await this.canvasToBlob(canvas, candidate.mime, candidate.quality);
const dataUrl = await this.readBlobAsDataUrl(blob);
return {
dataUrl,
mime: blob.type || candidate.mime,
size: blob.size
};
})
);
return candidates.reduce((smallest, candidate) => (candidate.size < smallest.size ? candidate : smallest));
}
private async isAnimated(file: File): Promise<boolean> {
const mime = file.type.toLowerCase();
if (mime !== 'image/gif' && mime !== 'image/webp') {
return false;
}
const buffer = await file.arrayBuffer();
return mime === 'image/gif' ? isAnimatedGif(buffer) : isAnimatedWebp(buffer);
}
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Failed to render server image.'));
},
type,
quality
);
});
}
private readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('Failed to encode server image.'));
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read server image.'));
reader.readAsDataURL(blob);
});
}
private loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load server image.'));
image.src = url;
});
}
private resolveMimeFromDataUrl(dataUrl: string): string {
const match = /^data:([^;,]+)/.exec(dataUrl);
return match?.[1] || 'image/webp';
}
}

View File

@@ -1,7 +1,4 @@
import {
applyThemeStyleDeclaration,
toCssStylePropertyName
} from './theme-style-application.logic';
import { applyThemeStyleDeclaration, toCssStylePropertyName } from './theme-style-application.logic';
describe('theme style application', () => {
it('applies camelCase theme properties as real CSS declarations', () => {

View File

@@ -16,13 +16,11 @@
class="w-3.5 h-3.5"
/>
@if (voiceSession()?.serverIcon) {
<img
[ngSrc]="voiceSession()?.serverIcon || ''"
class="w-5 h-5 rounded object-cover"
alt=""
width="20"
height="20"
/>
<span
aria-hidden="true"
class="h-5 w-5 rounded bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + voiceSession()!.serverIcon + ')'"
></span>
} @else {
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}

View File

@@ -6,7 +6,7 @@ import {
computed,
OnInit
} from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
@@ -34,7 +34,6 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
standalone: true,
imports: [
CommonModule,
NgOptimizedImage,
NgIcon,
DebugConsoleComponent,
ScreenShareQualityDialogComponent,