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

@@ -79,11 +79,10 @@ import {
styleUrl: './app.scss'
})
export class App implements OnInit, OnDestroy {
readonly plugins = inject(PluginBootstrapService);
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
private static readonly TITLE_BAR_HEIGHT = 40;
readonly plugins = inject(PluginBootstrapService);
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);

View File

@@ -56,4 +56,4 @@ export function clearStoredLocalAppData(): void {
localStorage.removeItem(key);
}
} catch {}
}
}

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,

View File

@@ -8,11 +8,11 @@
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center overflow-hidden rounded-md bg-secondary text-sm font-semibold text-foreground">
@if (currentRoom()?.icon) {
<img
[src]="currentRoom()!.icon"
[alt]="currentRoom()!.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(' + currentRoom()!.icon + ')'"
></div>
} @else {
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
}

View File

@@ -42,11 +42,11 @@
>
<div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (room.icon) {
<img
[src]="room.icon"
[alt]="room.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(' + room.icon + ')'"
></div>
} @else {
<div
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"

View File

@@ -1,5 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, DestroyRef, Type, computed, effect, inject, signal } from '@angular/core';
import {
Component,
DestroyRef,
Type,
computed,
effect,
inject,
signal
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -7,7 +15,17 @@ import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import { EMPTY, Subject, catchError, filter, firstValueFrom, from, map, switchMap, tap } from 'rxjs';
import {
EMPTY,
Subject,
catchError,
filter,
firstValueFrom,
from,
map,
switchMap,
tap
} from 'rxjs';
import { Room, User } from '../../../shared-kernel';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
@@ -20,7 +38,11 @@ import { NotificationsFacade } from '../../../domains/notifications';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control';
import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../../shared';
@Component({
selector: 'app-servers-rail',
@@ -143,7 +165,8 @@ export class ServersRailComponent {
}
initial(name?: string): string {
if (!name) return '?';
if (!name)
return '?';
const ch = name.trim()[0]?.toUpperCase();
@@ -195,7 +218,8 @@ export class ServersRailComponent {
confirmPasswordJoin(): void {
const room = this.passwordPromptRoom();
if (!room) return;
if (!room)
return;
this.joinPasswordError.set(null);
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
@@ -235,7 +259,8 @@ export class ServersRailComponent {
confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom();
if (!ctx) return;
if (!ctx)
return;
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
@@ -338,7 +363,8 @@ export class ServersRailComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId) return EMPTY;
if (!currentUserId)
return EMPTY;
this.joinPasswordError.set(null);

View File

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

View File

@@ -1,4 +1,12 @@
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
OnDestroy,
OnInit,
computed,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
@@ -54,7 +62,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
case 'running':
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
case 'starting':
return 'Starting';
return 'Starting...';
case 'error':
return `Error: ${snapshot.error ?? 'unknown error'}`;
case 'stopped':

View File

@@ -10,11 +10,11 @@
<div class="flex items-center gap-3">
<div class="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-base font-semibold text-foreground">
@if (serverData()?.icon) {
<img
[src]="serverData()!.icon"
[alt]="serverData()!.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(' + serverData()!.icon + ')'"
></div>
} @else {
<ng-icon
name="lucideImage"

View File

@@ -24,6 +24,7 @@ import { Room } from '../../../../shared-kernel';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
@Component({
selector: 'app-server-settings',
@@ -50,6 +51,7 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
export class ServerSettingsComponent {
private store = inject(Store);
private modal = inject(SettingsModalService);
private serverIconImages = inject(ServerIconImageService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
@@ -181,7 +183,7 @@ export class ServerSettingsComponent {
this.modal.navigate('network');
}
onServerIconSelected(event: Event): void {
async onServerIconSelected(event: Event): Promise<void> {
const inputElement = event.target as HTMLInputElement;
const file = inputElement.files?.[0];
@@ -191,37 +193,24 @@ export class ServerSettingsComponent {
return;
}
if (!file.type.startsWith('image/')) {
this.iconError.set('Choose an image file.');
return;
}
if (file.size > 512 * 1024) {
this.iconError.set('Choose an image smaller than 512 KB.');
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const room = this.server();
const icon = typeof reader.result === 'string' ? reader.result : '';
const icon = await this.serverIconImages.process(file);
if (!room || !icon) {
this.iconError.set('Could not read that image.');
if (!room) {
return;
}
this.iconError.set(null);
this.store.dispatch(RoomsActions.updateServerIcon({
roomId: room.id,
icon
icon: icon.dataUrl
}));
this.showSaveSuccess('icon');
};
reader.onerror = () => this.iconError.set('Could not read that image.');
reader.readAsDataURL(file);
this.showSaveSuccess('icon');
} catch (error) {
this.iconError.set(error instanceof Error ? error.message : 'Could not read that image.');
}
}
removeServerIcon(): void {
@@ -236,6 +225,7 @@ export class ServerSettingsComponent {
roomId: room.id,
icon: ''
}));
this.showSaveSuccess('icon');
}

View File

@@ -98,6 +98,24 @@
/>
</button>
@if (hasServerPlugins()) {
<button
type="button"
class="relative grid h-8 w-8 place-items-center rounded-md text-foreground transition-colors hover:bg-secondary"
(click)="openServerPlugins()"
title="Server plugins"
aria-label="Server plugins"
>
<ng-icon
name="lucideShield"
class="h-4 w-4 text-muted-foreground"
/>
<span class="absolute right-0 top-0 min-w-3 rounded-full bg-primary px-1 text-[9px] font-semibold leading-3 text-primary-foreground">
{{ serverPluginCount() }}
</span>
</button>
}
@if (isElectron()) {
<button
type="button"
@@ -227,6 +245,123 @@
}
</div>
</div>
@if (optionalPluginRequirement(); as requirement) {
<section
class="flex min-h-10 items-center justify-between gap-3 border-b border-border bg-primary/10 px-4 py-2 text-sm text-foreground"
role="status"
aria-live="polite"
style="-webkit-app-region: no-drag"
>
<div class="flex min-w-0 items-center gap-2">
<ng-icon
name="lucidePackage"
class="h-4 w-4 shrink-0 text-primary"
/>
<p class="truncate">
Optional server plugin available:
<span class="font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
@if (optionalPluginRequirementCount() > 1) {
<span class="text-muted-foreground">+{{ optionalPluginRequirementCount() - 1 }} more</span>
}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
@if (pluginRequirementError()) {
<span class="max-w-56 truncate text-xs text-destructive">{{ pluginRequirementError() }}</span>
}
<button
type="button"
class="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
[disabled]="pluginRequirementBusy()"
(click)="rejectOptionalServerPlugin(requirement)"
>
Reject
</button>
<button
type="button"
class="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
[disabled]="pluginRequirementBusy()"
(click)="hideOptionalServerPlugin(requirement)"
>
Don't show again
</button>
<button
type="button"
class="rounded-md border border-primary bg-primary px-2.5 py-1 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
[disabled]="pluginRequirementBusy()"
(click)="installOptionalServerPlugin(requirement)"
>
{{ pluginRequirementBusy() ? 'Installing' : 'Install' }}
</button>
</div>
</section>
}
@if (requiredPluginRequirements().length > 0 && currentRoom()) {
<div
class="fixed inset-0 z-[80] bg-black/60"
role="presentation"
></div>
<section
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(38rem,calc(100vh-2rem))] w-[min(32rem,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="required-server-plugin-title"
style="-webkit-app-region: no-drag"
>
<header class="border-b border-border p-4">
<p class="text-sm text-muted-foreground">Required server plugins</p>
<h2
id="required-server-plugin-title"
class="mt-1 text-lg font-semibold"
>
{{ currentRoom()!.name }} requires a plugin update
</h2>
</header>
<div class="min-h-0 space-y-3 overflow-auto p-4">
<p class="text-sm text-muted-foreground">
An admin added required plugins for this server. Install them to keep using the server, or leave the server.
</p>
@for (requirement of requiredPluginRequirements(); track requirement.pluginId) {
<article 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>
</article>
}
@if (pluginRequirementError()) {
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginRequirementError() }}</p>
}
</div>
<footer class="flex justify-end gap-2 border-t border-border p-4">
<button
type="button"
class="rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
[disabled]="pluginRequirementBusy()"
(click)="confirmLeave({})"
>
Leave server
</button>
<button
type="button"
class="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:opacity-60"
[disabled]="pluginRequirementBusy()"
(click)="installRequiredServerPlugins()"
>
{{ pluginRequirementBusy() ? 'Installing' : 'Install plugins' }}
</button>
</footer>
</section>
}
<!-- Click-away overlay to close dropdown -->
@if (showMenu()) {
<div

View File

@@ -18,7 +18,8 @@ import {
lucideHash,
lucideMenu,
lucidePackage,
lucideRefreshCw
lucideRefreshCw,
lucideShield
} from '@ng-icons/lucide';
import { NavigationEnd, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
@@ -42,9 +43,15 @@ import { PlatformService } from '../../../core/platform';
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { LeaveServerDialogComponent } from '../../../shared';
import { Room } from '../../../shared-kernel';
import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective } from '../../../domains/theme';
import {
PluginRegistryService,
PluginRequirementStateService,
PluginStoreService
} from '../../../domains/plugins';
import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plugin-install-scope.logic';
@Component({
selector: 'app-title-bar',
@@ -64,7 +71,8 @@ import { ThemeNodeDirective } from '../../../domains/theme';
lucideHash,
lucideMenu,
lucidePackage,
lucideRefreshCw })
lucideRefreshCw,
lucideShield })
],
templateUrl: './title-bar.component.html'
})
@@ -80,6 +88,9 @@ export class TitleBarComponent {
private platform = inject(PlatformService);
private voiceWorkspace = inject(VoiceWorkspaceService);
private settingsModal = inject(SettingsModalService);
private pluginRegistry = inject(PluginRegistryService);
private pluginRequirements = inject(PluginRequirementStateService);
private pluginStore = inject(PluginStoreService);
private getWindowControlsApi() {
return this.electronBridge.getApi();
@@ -153,11 +164,20 @@ export class TitleBarComponent {
|| this.isReconnecting()
)
);
serverPluginCount = computed(() => this.pluginRegistry.entries()
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
.length);
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
showLeaveConfirm = signal(false);
inviteStatus = signal<string | null>(null);
creatingInvite = signal(false);
pluginRequirementBusy = signal(false);
pluginRequirementError = signal<string | null>(null);
/** Minimize the Electron window. */
minimize() {
@@ -192,6 +212,17 @@ export class TitleBarComponent {
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
}
openServerPlugins(): void {
const roomId = this.currentRoom()?.id;
if (!roomId) {
return;
}
this._showMenu.set(false);
this.settingsModal.open('serverPlugins', roomId);
}
openSettings(): void {
this._showMenu.set(false);
this.settingsModal.open('general');
@@ -267,6 +298,24 @@ export class TitleBarComponent {
this.openLeaveConfirm();
}
installRequiredServerPlugins(): void {
void this.installServerRequirements(this.requiredPluginRequirements());
}
installOptionalServerPlugin(requirement: PluginRequirementSummary): void {
void this.installServerRequirements([requirement]);
}
rejectOptionalServerPlugin(requirement: PluginRequirementSummary): void {
this.pluginRequirements.dismissOptionalRequirement(requirement);
this.pluginRequirementError.set(null);
}
hideOptionalServerPlugin(requirement: PluginRequirementSummary): void {
this.pluginRequirements.dismissOptionalRequirement(requirement, { persist: true });
this.pluginRequirementError.set(null);
}
/** Confirm the unified leave action and remove the server locally. */
confirmLeave(result: { nextOwnerKey?: string }) {
const roomId = this.currentRoom()?.id;
@@ -294,6 +343,25 @@ export class TitleBarComponent {
this._showMenu.set(false);
}
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
const room = this.currentRoom();
if (!room || requirements.length === 0 || this.pluginRequirementBusy()) {
return;
}
this.pluginRequirementBusy.set(true);
this.pluginRequirementError.set(null);
try {
await this.pluginStore.installServerRequirementsLocally(room.id, requirements, { activate: true });
} catch (error) {
this.pluginRequirementError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
} finally {
this.pluginRequirementBusy.set(false);
}
}
/** Log out the current user, disconnect from signaling, and navigate to login. */
logout() {
this._showMenu.set(false);

View File

@@ -81,6 +81,8 @@ The renderer sends structured command/query objects through the Electron preload
The Electron schema now normalises reaction rows and room channel/member rosters into separate SQLite tables instead of storing those arrays inline on the parent message or room rows. The renderer-facing API is unchanged: CQRS handlers rehydrate the same `Message` and `Room` payloads before returning them over IPC.
Electron room membership is user-scoped through `room_owners`, and messages carry `ownerUserId`. Auth setup writes the current user ID to the database before room loading, so `/search`, the server rail, and local history only hydrate rooms/messages owned by the active account. A room row can still hold shared server metadata for the same server ID, but each account has its own ownership edge and message history.
```mermaid
sequenceDiagram
participant Eff as NgRx Effect

View File

@@ -140,9 +140,11 @@ export class IncomingSignalingMessageHandler {
}
for (const user of users) {
if (!user.oderId) continue;
if (!user.oderId)
continue;
if (localOderId && user.oderId === localOderId) continue;
if (localOderId && user.oderId === localOderId)
continue;
this.clearUserJoinedFallbackOffer(user.oderId);
@@ -310,9 +312,11 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp) return;
if (!fromUserId || !sdp)
return;
if (fromUserId === this.dependencies.getLocalOderId()) return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
this.clearUserJoinedFallbackOffer(fromUserId);
this.nonInitiatorWaitStart.delete(fromUserId);
@@ -332,9 +336,11 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp) return;
if (!fromUserId || !sdp)
return;
if (fromUserId === this.dependencies.getLocalOderId()) return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
this.clearUserJoinedFallbackOffer(fromUserId);
@@ -346,9 +352,11 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId;
const candidate = message.payload?.candidate;
if (!fromUserId || !candidate) return;
if (!fromUserId || !candidate)
return;
if (fromUserId === this.dependencies.getLocalOderId()) return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
this.clearUserJoinedFallbackOffer(fromUserId);
@@ -507,15 +515,18 @@ export class IncomingSignalingMessageHandler {
}
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
if (!localOderId) return false;
if (!localOderId)
return false;
if (peerId === localOderId) return false;
if (peerId === localOderId)
return false;
return localOderId < peerId;
}
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
if (!peer) return false;
if (!peer)
return false;
const connectionState = peer.connection?.connectionState;
@@ -523,11 +534,13 @@ export class IncomingSignalingMessageHandler {
}
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
if (!peer || this.hasActivePeerConnection(peer)) return false;
if (!peer || this.hasActivePeerConnection(peer))
return false;
const connectionState = peer.connection?.connectionState;
if (connectionState === 'closed' || connectionState === 'failed') return false;
if (connectionState === 'closed' || connectionState === 'failed')
return false;
const signalingState = peer.connection?.signalingState;
const ageMs = Date.now() - peer.createdAt;
@@ -535,11 +548,13 @@ export class IncomingSignalingMessageHandler {
// If a local offer (or pranswer) has already been sent, the peer is actively
// negotiating with the remote side. Use a much longer grace period so that
// a slow signaling round-trip does not trigger a premature teardown.
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer')
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
// ICE negotiation in progress (offer/answer exchange already complete, candidates being checked).
// TURN relay can take 5-15 s on high-latency networks, so use the same extended grace.
if (connectionState === 'connecting') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
if (connectionState === 'connecting')
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
return ageMs < PEER_NEGOTIATION_GRACE_MS;
}

View File

@@ -1,7 +1,4 @@
import {
defaultIfEmpty,
firstValueFrom
} from 'rxjs';
import { defaultIfEmpty, firstValueFrom } from 'rxjs';
import { type Message } from '../../shared-kernel';
import { dispatchIncomingMessage } from './messages-incoming.handlers';
@@ -69,10 +66,9 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
});
it('sends full sync for requested room even when another room is viewed', async () => {
const roomBMessages = [
createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 }),
createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 })
];
const roomBMessageOne = createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 });
const roomBMessageTwo = createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 });
const roomBMessages = [roomBMessageOne, roomBMessageTwo];
const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b'
? roomBMessages
: [createMessage({ id: 'message-a1', roomId: 'room-a', timestamp: 200 })]);

View File

@@ -347,6 +347,7 @@ export class RoomSettingsEffects {
icon,
iconUpdatedAt
});
this.webrtc.sendRawMessage({
type: 'server_icon_available',
serverId: room.id,

View File

@@ -1,17 +1,44 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store, type Action } from '@ngrx/store';
import { of, from, EMPTY } from 'rxjs';
import { map, mergeMap, withLatestFrom, tap, switchMap, catchError } from 'rxjs/operators';
import {
of,
from,
EMPTY
} from 'rxjs';
import {
map,
mergeMap,
withLatestFrom,
tap,
switchMap,
catchError
} from 'rxjs/operators';
import { RoomsActions } from './rooms.actions';
import { UsersActions } from '../users/users.actions';
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import {
selectActiveChannelId,
selectCurrentRoom,
selectSavedRooms
} from './rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { resolveRoomPermission } from '../../domains/access-control';
import type { ChatEvent, Room, RoomSettings, RoomPermissions, BanEntry, User, VoiceState } from '../../shared-kernel';
import type {
ChatEvent,
Room,
RoomSettings,
RoomPermissions,
BanEntry,
User,
VoiceState
} from '../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../domains/access-control';
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
@@ -28,7 +55,12 @@ import {
} from './rooms.helpers';
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [1_500, 3_000, 5_000, 8_000];
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
1_500,
3_000,
5_000,
8_000
];
/**
* NgRx effects for real-time state synchronisation: signaling presence
@@ -64,7 +96,12 @@ export class RoomStateSyncEffects {
signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe(
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)),
mergeMap(([message, currentUser, currentRoom, savedRooms]) => {
mergeMap(([
message,
currentUser,
currentRoom,
savedRooms
]) => {
const signalingMessage: RoomPresenceSignalingMessage = message;
const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id;
@@ -73,7 +110,8 @@ export class RoomStateSyncEffects {
switch (signalingMessage.type) {
case 'server_users': {
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) return EMPTY;
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
return EMPTY;
const syncedUsers = signalingMessage.users
.filter((user) => user.oderId !== myId)
@@ -102,9 +140,11 @@ export class RoomStateSyncEffects {
}
case 'user_joined': {
if (!signalingMessage.serverId || signalingMessage.oderId === myId) return EMPTY;
if (!signalingMessage.serverId || signalingMessage.oderId === myId)
return EMPTY;
if (!signalingMessage.oderId) return EMPTY;
if (!signalingMessage.oderId)
return EMPTY;
const joinedUser = {
oderId: signalingMessage.oderId,
@@ -132,7 +172,8 @@ export class RoomStateSyncEffects {
}
case 'user_left': {
if (!signalingMessage.oderId) return EMPTY;
if (!signalingMessage.oderId)
return EMPTY;
const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;
@@ -160,11 +201,18 @@ export class RoomStateSyncEffects {
}
case 'status_update': {
if (!signalingMessage.oderId || !signalingMessage.status) return EMPTY;
if (!signalingMessage.oderId || !signalingMessage.status)
return EMPTY;
const validStatuses = ['online', 'away', 'busy', 'offline'];
const validStatuses = [
'online',
'away',
'busy',
'offline'
];
if (!validStatuses.includes(signalingMessage.status)) return EMPTY;
if (!validStatuses.includes(signalingMessage.status))
return EMPTY;
// 'offline' from the server means the user chose Invisible;
// display them as disconnected to other users.
@@ -179,14 +227,17 @@ export class RoomStateSyncEffects {
}
case 'access_denied': {
if (isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY;
if (isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY;
if (signalingMessage.reason !== 'SERVER_NOT_FOUND') return EMPTY;
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
return EMPTY;
// When multiple signal URLs are configured, the room may already
// be successfully joined on a different signal server. Only show
// the reconnect notice when the room is not reachable at all.
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) return EMPTY;
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId))
return EMPTY;
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
}
@@ -263,7 +314,8 @@ export class RoomStateSyncEffects {
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([peerId, room]) => {
if (!room) return;
if (!room)
return;
this.webrtc.sendToPeer(peerId, {
type: 'server-state-request',
@@ -313,7 +365,14 @@ export class RoomStateSyncEffects {
this.store.select(selectCurrentUser),
this.store.select(selectActiveChannelId)
),
mergeMap(([event, currentRoom, savedRooms, allUsers, currentUser, activeChannelId]) => {
mergeMap(([
event,
currentRoom,
savedRooms,
allUsers,
currentUser,
activeChannelId
]) => {
switch (event.type) {
case 'voice-state':
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
@@ -353,7 +412,8 @@ export class RoomStateSyncEffects {
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([_peerId, room]) => {
if (!room) return;
if (!room)
return;
const iconUpdatedAt = room.iconUpdatedAt || 0;
@@ -374,7 +434,8 @@ export class RoomStateSyncEffects {
tap((peerId) => {
const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);
if (!serverIds) return;
if (!serverIds)
return;
for (const serverId of serverIds) {
this.sendServerIconSyncRequest(peerId, serverId);
@@ -389,7 +450,8 @@ export class RoomStateSyncEffects {
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId) return EMPTY;
if (!userId)
return EMPTY;
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
const userExists = !!existingUser;
@@ -397,16 +459,17 @@ export class RoomStateSyncEffects {
if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined;
if (!vs) return EMPTY;
if (!vs)
return EMPTY;
const presenceRefreshAction =
vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
? UsersActions.userJoined({
user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
{ presenceServerIds: [vs.serverId] }
)
})
user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
{ presenceServerIds: [vs.serverId] }
)
})
: null;
// Detect voice-connection transitions to play join/leave sounds.
const weAreInVoice = this.webrtc.isVoiceConnected();
@@ -471,7 +534,8 @@ export class RoomStateSyncEffects {
if (kind === 'screen') {
const isSharing = event.isScreenSharing as boolean | undefined;
if (isSharing === undefined) return EMPTY;
if (isSharing === undefined)
return EMPTY;
if (!userExists) {
return of(
@@ -491,7 +555,8 @@ export class RoomStateSyncEffects {
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
if (isCameraEnabled === undefined) return EMPTY;
if (isCameraEnabled === undefined)
return EMPTY;
if (!userExists) {
return of(
@@ -609,7 +674,8 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const fromPeerId = event.fromPeerId;
if (!room || !fromPeerId) return EMPTY;
if (!room || !fromPeerId)
return EMPTY;
return from(this.db.getBansForRoom(room.id)).pipe(
tap((bans) => {
@@ -629,7 +695,8 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || !incomingRoom) return EMPTY;
if (!room || !incomingRoom)
return EMPTY;
const roomChanges = {
...sanitizeRoomSnapshot(incomingRoom),
@@ -670,7 +737,8 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const settings = event.settings as Partial<RoomSettings> | undefined;
if (!room || !settings) return EMPTY;
if (!room || !settings)
return EMPTY;
return of(
RoomsActions.updateRoom({
@@ -699,7 +767,8 @@ export class RoomStateSyncEffects {
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || (!permissions && !incomingRoom)) return EMPTY;
if (!room || (!permissions && !incomingRoom))
return EMPTY;
return of(
RoomsActions.updateRoom({
@@ -746,7 +815,8 @@ export class RoomStateSyncEffects {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room) return EMPTY;
if (!room)
return EMPTY;
const remoteUpdated = event.iconUpdatedAt || 0;
const localUpdated = room.iconUpdatedAt || 0;
@@ -765,7 +835,8 @@ export class RoomStateSyncEffects {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room) return EMPTY;
if (!room)
return EMPTY;
if (event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, {
@@ -784,17 +855,20 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const senderId = event.fromPeerId;
if (!room || typeof event.icon !== 'string' || !senderId) return this.handleSearchResultIconData(event, roomId);
if (!room || typeof event.icon !== 'string' || !senderId)
return this.handleSearchResultIconData(event, roomId);
return this.store.select(selectAllUsers).pipe(
map((users) => users.find((user) => user.id === senderId)),
mergeMap((sender) => {
if (!sender) return EMPTY;
if (!sender)
return EMPTY;
const isOwner = room.hostId === sender.id;
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
if (!isOwner && !canByRole) return EMPTY;
if (!isOwner && !canByRole)
return EMPTY;
const updates: Partial<Room> = {
icon: event.icon,
@@ -807,6 +881,7 @@ export class RoomStateSyncEffects {
serverId: room.id,
iconUpdatedAt: updates.iconUpdatedAt
});
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
})
);

View File

@@ -1,7 +1,4 @@
import {
reconcileRoomSnapshotChannels,
sanitizeRoomSnapshot
} from './rooms.helpers';
import { reconcileRoomSnapshotChannels, sanitizeRoomSnapshot } from './rooms.helpers';
describe('room snapshot helpers', () => {
it('drops empty channel arrays from outgoing snapshots', () => {
@@ -9,10 +6,9 @@ describe('room snapshot helpers', () => {
});
it('keeps cached channels when incoming snapshot has none', () => {
const cachedChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 },
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
] as const;
const generalChannel = { id: 'general', name: 'general', type: 'text', position: 0 } as const;
const updatesChannel = { id: 'updates', name: 'updates', type: 'text', position: 1 } as const;
const cachedChannels = [generalChannel, updatesChannel] as const;
expect(reconcileRoomSnapshotChannels(cachedChannels as never, undefined)).toEqual(cachedChannels);
expect(reconcileRoomSnapshotChannels(cachedChannels as never, [] as never)).toEqual(cachedChannels);
@@ -24,21 +20,16 @@ describe('room snapshot helpers', () => {
{ id: 'updates', name: 'updates', type: 'text', position: 1 },
{ id: 'voice', name: 'General', type: 'voice', position: 0 }
] as const;
const incomingChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 }
] as const;
const incomingChannels = [{ id: 'general', name: 'general', type: 'text', position: 0 }] as const;
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(cachedChannels);
});
it('accepts incoming channels when snapshot is at least as complete', () => {
const cachedChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 }
] as const;
const incomingChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 },
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
] as const;
const generalChannel = { id: 'general', name: 'general', type: 'text', position: 0 } as const;
const updatesChannel = { id: 'updates', name: 'updates', type: 'text', position: 1 } as const;
const cachedChannels = [generalChannel] as const;
const incomingChannels = [generalChannel, updatesChannel] as const;
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(incomingChannels);
});

View File

@@ -1,5 +1,9 @@
import { v4 as uuidv4 } from 'uuid';
import { Room, BanEntry, User } from '../../shared-kernel';
import {
Room,
BanEntry,
User
} from '../../shared-kernel';
import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control';
import { findRoomMember } from './room-members.helpers';
import { ROOM_URL_PATTERN } from '../../core/constants';
@@ -7,7 +11,12 @@ import { ROOM_URL_PATTERN } from '../../core/constants';
/** Build a minimal User object from signaling payload. */
export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record<string, unknown> = {}) {
const displayName = data.displayName?.trim() || 'User';
const rawStatus = (['online', 'away', 'busy', 'offline'] as const).includes(data.status as 'online')
const rawStatus = ([
'online',
'away',
'busy',
'offline'
] as const).includes(data.status as 'online')
? (data.status as 'online' | 'away' | 'busy' | 'offline')
: 'online';
// 'offline' from the server means the user chose Invisible;
@@ -31,7 +40,8 @@ export function buildSignalingUser(data: { oderId: string; displayName?: string;
export function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> {
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
if (!knownMember) return {};
if (!knownMember)
return {};
return {
username: knownMember.username,
@@ -115,9 +125,11 @@ export function resolveTextChannelId(channels: Room['channels'] | undefined, pre
}
export function resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId) return currentRoom;
if (!roomId)
return currentRoom;
if (currentRoom?.id === roomId) return currentRoom;
if (currentRoom?.id === roomId)
return currentRoom;
return savedRooms.find((room) => room.id === roomId) ?? null;
}
@@ -148,7 +160,8 @@ export function sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
}
export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
if (!Array.isArray(bans)) return [];
if (!Array.isArray(bans))
return [];
const now = Date.now();

View File

@@ -4,7 +4,11 @@ import { normalizeRoomAccessControl } from '../../domains/access-control';
import { type ServerInfo } from '../../domains/server-directory';
import { RoomsActions } from './rooms.actions';
import { defaultChannels } from './room-channels.defaults';
import { isChannelNameTaken, normalizeChannelName, normalizeRoomChannels } from './room-channels.rules';
import {
isChannelNameTaken,
normalizeChannelName,
normalizeRoomChannels
} from './room-channels.rules';
import { pruneRoomMembers } from './room-members.helpers';
/** Deduplicate rooms by id, keeping the last occurrence */
@@ -325,7 +329,8 @@ export const roomsReducer = createReducer(
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom) return state;
if (!baseRoom)
return state;
const updatedRoom = enrichRoom({ ...baseRoom, ...changes });
@@ -342,7 +347,8 @@ export const roomsReducer = createReducer(
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom) return state;
if (!baseRoom)
return state;
const updatedRoom = enrichRoom({ ...baseRoom, icon, iconUpdatedAt });
@@ -362,7 +368,8 @@ export const roomsReducer = createReducer(
// Receive room update
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
if (!state.currentRoom) return state;
if (!state.currentRoom)
return state;
const updatedRoom = enrichRoom({ ...state.currentRoom, ...room });
@@ -403,7 +410,8 @@ export const roomsReducer = createReducer(
})),
on(RoomsActions.addChannel, (state, { channel }) => {
if (!state.currentRoom) return state;
if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(channel.name);
@@ -424,7 +432,8 @@ export const roomsReducer = createReducer(
}),
on(RoomsActions.removeChannel, (state, { channelId }) => {
if (!state.currentRoom) return state;
if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.filter((channel) => channel.id !== channelId);
@@ -439,7 +448,8 @@ export const roomsReducer = createReducer(
}),
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
if (!state.currentRoom) return state;
if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(name);

View File

@@ -47,9 +47,7 @@ import {
Room,
User
} from '../../shared-kernel';
import {
setStoredCurrentUserId
} from '../../core/storage/current-user-storage';
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
type IncomingModerationExtraAction =
@@ -152,6 +150,7 @@ export class UsersEffects {
private async prepareAuthenticatedUserStorage(userId: string): Promise<void> {
setStoredCurrentUserId(userId);
await this.db.initialize();
await this.db.setCurrentUserId(userId);
}
/** Loads all users associated with a specific room from the local database. */