feat: dashboard

This commit is contained in:
2026-06-05 01:25:16 +02:00
parent 147858de2f
commit 2f6c52e73c
73 changed files with 3490 additions and 1061 deletions

View File

@@ -0,0 +1,510 @@
<ng-template
#serverCard
let-server
>
<div
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
[class.border-border]="!isServerMarkedBanned(server)"
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
[class.border-destructive/40]="isServerMarkedBanned(server)"
[class.bg-destructive/5]="isServerMarkedBanned(server)"
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
[title]="isJoinedServer(server) ? 'Double-click to open ' + server.name : 'Double-click to join ' + server.name"
(dblclick)="openServerCard(server)"
>
<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) {
<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] || '?' }}
}
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h3
class="truncate text-sm font-semibold transition-colors"
[class.text-foreground]="!isServerMarkedBanned(server)"
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
[class.text-destructive]="isServerMarkedBanned(server)"
>
{{ server.name }}
</h3>
@if (isServerMarkedBanned(server)) {
<span class="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive">
<ng-icon
name="lucideLock"
class="h-3 w-3"
/>
Banned
</span>
} @else if (server.isPrivate) {
<span class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<ng-icon
name="lucideLock"
class="h-3 w-3"
/>
Private
</span>
} @else if (server.hasPassword) {
<span class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<ng-icon
name="lucideLock"
class="h-3 w-3"
/>
Password
</span>
} @else {
<ng-icon
name="lucideGlobe"
class="h-4 w-4 text-muted-foreground"
/>
}
</div>
@if (server.description) {
<p class="mt-1 line-clamp-1 text-xs text-muted-foreground">{{ server.description }}</p>
}
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}
</span>
@if (server.topic) {
<span class="truncate">{{ server.topic }}</span>
}
<span class="truncate">Owner: {{ getServerOwnerLabel(server) }}</span>
<span class="truncate">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
</div>
</div>
<div class="relative shrink-0">
@if (isJoinedServer(server)) {
<div class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500">
<span class="px-2.5 py-1.5">Joined</span>
<button
type="button"
class="grid h-8 w-8 place-items-center border-l border-emerald-500/20 transition-colors hover:bg-emerald-500/15"
[attr.aria-label]="'Server actions for ' + server.name"
(click)="toggleJoinedServerMenu($event, server)"
>
<ng-icon
name="lucideChevronDown"
class="h-4 w-4"
/>
</button>
</div>
@if (joinedServerMenuId() === server.id) {
<div class="absolute right-0 top-full z-20 mt-1 w-36 rounded-md border border-border bg-card py-1 shadow-lg">
<button
type="button"
class="w-full px-3 py-2 text-left text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
(click)="openLeaveDialog($event, server)"
>
Leave
</button>
</div>
}
} @else {
<button
type="button"
class="rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
[attr.aria-label]="'Join ' + server.name"
(click)="joinServer(server)"
>
<span class="sr-only">{{ server.name }}</span>
Join
</button>
}
</div>
</div>
</div>
</ng-template>
<div class="flex h-full min-h-0 flex-col">
<div class="border-b border-border px-3 py-3">
<div class="relative">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
aria-label="Search servers"
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="searchPlaceholder"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
/>
</div>
@if (showMyServers && savedRooms().length > 0) {
<div class="mt-2 flex items-center gap-2 overflow-x-auto pb-1">
<span class="shrink-0 text-xs font-medium text-muted-foreground">My Servers</span>
@for (room of savedRooms(); track room.id) {
<button
type="button"
class="shrink-0 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
(click)="joinSavedRoom(room)"
>
{{ room.name }}
</button>
}
</div>
}
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
@if (isSearchMode) {
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
<div>
<h3 class="text-sm font-semibold text-foreground">Search results</h3>
<p class="text-xs text-muted-foreground">{{ searchResults().length }} found</p>
</div>
</div>
@if (isSearching()) {
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if (searchResults().length === 0) {
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
<ng-icon
name="lucideSearch"
class="mb-3 h-10 w-10 opacity-50"
/>
<p class="text-sm font-medium">No servers found</p>
</div>
} @else {
<div class="space-y-2 p-3">
@for (server of searchResults(); track server.id) {
<ng-container *ngTemplateOutlet="serverCard; context: { $implicit: server }" />
}
</div>
}
} @else if (showEmptyState) {
<div class="flex flex-col items-center justify-center px-6 py-16 text-center text-muted-foreground">
<ng-icon
name="lucideSearch"
class="mb-4 h-12 w-12 opacity-40"
/>
<p class="text-base font-semibold text-foreground">{{ emptyStateTitle }}</p>
<p class="mt-1 max-w-sm text-sm">{{ emptyStateMessage }}</p>
</div>
} @else {
<div class="space-y-6 p-3">
@for (section of visibleSections; track section.id) {
<section>
<div class="mb-2 px-1">
<h3 class="text-sm font-semibold text-foreground">{{ section.title }}</h3>
@if (section.subtitle) {
<p class="text-xs text-muted-foreground">{{ section.subtitle }}</p>
}
</div>
<div class="space-y-2">
@for (server of section.servers; track server.id) {
<ng-container *ngTemplateOutlet="serverCard; context: { $implicit: server }" />
}
</div>
</section>
}
</div>
}
</div>
@if (joinErrorMessage() || error()) {
<div class="border-t border-destructive bg-destructive/10 p-4">
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
</div>
}
</div>
@if (leaveDialogRoom()) {
<app-leave-server-dialog
[room]="leaveDialogRoom()!"
[currentUser]="currentUser() ?? null"
(confirmed)="confirmLeaveServer($event)"
(cancelled)="closeLeaveDialog()"
/>
}
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"
confirmLabel="OK"
cancelLabel="Close"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="closeBannedDialog()"
(cancelled)="closeBannedDialog()"
>
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
</app-confirm-dialog>
}
@if (showPasswordDialog() && passwordPromptServer()) {
<app-confirm-dialog
title="Password required"
confirmLabel="Join server"
cancelLabel="Cancel"
[widthClass]="'w-[420px] max-w-[92vw]'"
(confirmed)="confirmPasswordJoin()"
(cancelled)="closePasswordDialog()"
>
<div class="space-y-3">
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
<div>
<label
for="join-server-password"
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Server password
</label>
<input
id="join-server-password"
type="password"
[(ngModel)]="joinPassword"
placeholder="Enter password"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@if (joinPasswordError()) {
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
}
</div>
</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="grid gap-3 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>
@if (requirement.manifest?.capabilities; as capabilities) {
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
<div class="mt-2 flex flex-wrap gap-1.5">
@for (capability of capabilities; track capability) {
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
}
</div>
</details>
}
<div class="flex flex-wrap items-center gap-2">
@if (getPluginSourceUrl(requirement)) {
<button
type="button"
(click)="openPluginSource(requirement)"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Source
</button>
}
@if (hasPluginReadme(requirement)) {
<button
type="button"
(click)="openPluginConsentReadme(requirement)"
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
<ng-icon
name="lucideFileText"
class="h-3.5 w-3.5"
/>
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
</button>
}
</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) {
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
<label class="flex items-start gap-3">
<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>
@if (requirement.manifest?.capabilities; as capabilities) {
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
<div class="mt-2 flex flex-wrap gap-1.5">
@for (capability of capabilities; track capability) {
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
}
</div>
</details>
}
<div class="flex flex-wrap items-center gap-2">
@if (getPluginSourceUrl(requirement)) {
<button
type="button"
(click)="openPluginSource(requirement)"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Source
</button>
}
@if (hasPluginReadme(requirement)) {
<button
type="button"
(click)="openPluginConsentReadme(requirement)"
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
<ng-icon
name="lucideFileText"
class="h-3.5 w-3.5"
/>
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
</button>
}
</div>
</div>
}
</section>
}
@if (pluginConsentReadmeError()) {
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentReadmeError() }}</p>
}
@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>
@if (pluginConsentReadme(); as readme) {
<div
class="fixed inset-0 z-[52] bg-black/60"
role="presentation"
(click)="closePluginConsentReadme()"
></div>
<section
class="fixed left-1/2 top-1/2 z-[53] flex max-h-[min(44rem,calc(100vh-2rem))] w-[min(44rem,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-readme-title"
>
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div class="min-w-0">
<p class="text-sm text-muted-foreground">Plugin readme</p>
<h2
id="join-plugin-readme-title"
class="mt-1 truncate text-lg font-semibold"
>
{{ readme.title }}
</h2>
</div>
<button
type="button"
(click)="closePluginConsentReadme()"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Close readme"
>
X
</button>
</header>
<div
class="min-h-0 overflow-auto p-4 text-sm leading-6 [&_a]:text-primary [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-background [&_code]:px-1 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:mt-3 [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-3 [&_pre]:mb-3 [&_pre]:overflow-auto [&_pre]:rounded-lg [&_pre]:bg-background [&_pre]:p-3 [&_ul]:list-disc"
>
<app-chat-message-markdown [content]="readme.markdown" />
</div>
</section>
}
}

View File

@@ -0,0 +1,219 @@
import {
describe,
it,
expect,
vi,
beforeEach
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { of, throwError } from 'rxjs';
import { ServerBrowserComponent } from './server-browser.component';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { selectSearchResults, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ExternalLinkService } from '../../../../core/platform';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
import type { ServerInfo } from '../../domain/models/server-directory.model';
import type { User } from '../../../../shared-kernel';
interface HarnessOptions {
joinResult?: unknown;
joinError?: unknown;
installedPluginIds?: Set<string>;
snapshotRequirements?: unknown[];
}
const TEST_USER: User = {
id: 'user-1',
oderId: 'oder-1',
displayName: 'Tester'
} as unknown as User;
const TEST_SERVER: ServerInfo = {
id: 'server-1',
name: 'Alpha',
maxUsers: 50,
userCount: 3,
isPrivate: false
} as unknown as ServerInfo;
function createHarness(options: HarnessOptions = {}) {
const dispatch = vi.fn();
const searchResultsSig = signal<ServerInfo[]>([]);
const savedRoomsSig = signal<unknown[]>([]);
const currentUserSig = signal<User | null>(TEST_USER);
const store = {
selectSignal: (selector: unknown) => {
if (selector === selectSearchResults) {
return searchResultsSig;
}
if (selector === selectSavedRooms) {
return savedRoomsSig;
}
if (selector === selectCurrentUser) {
return currentUserSig;
}
return signal(null);
},
dispatch
} as unknown as Store;
const router = { navigate: vi.fn() } as unknown as Router;
const requestJoin = vi.fn(() =>
options.joinError ? throwError(() => options.joinError) : of(options.joinResult ?? { server: { id: 'server-1' }, signalingUrl: 'wss://x' })
);
const serverDirectory = {
activeServers: () => [],
requestJoin,
normaliseRoomSignalSource: () => ({}),
getApiBaseUrl: () => 'https://api.test',
buildRoomSignalSelector: () => null,
getWebSocketUrl: () => 'wss://x'
} as unknown as ServerDirectoryFacade;
const pluginRequirements = {
getSnapshot: vi.fn(() => of({ requirements: options.snapshotRequirements ?? [] }))
} as unknown as PluginRequirementService;
const installServerRequirementsLocally = vi.fn(() => Promise.resolve());
const pluginStore = {
getLocalServerInstalledPluginIds: vi.fn(() => Promise.resolve(options.installedPluginIds ?? new Set<string>())),
installServerRequirementsLocally,
loadRequirementReadme: vi.fn(() => Promise.resolve({ title: 'x', markdown: '' }))
} as unknown as PluginStoreService;
const db = {
getBansForRoom: vi.fn(() => Promise.resolve([]))
} as unknown as DatabaseService;
const webrtc = {
connectToSignalingServer: vi.fn(() => of(undefined)),
identify: vi.fn(),
sendRawMessageToSignalUrl: vi.fn()
} as unknown as RealtimeSessionFacade;
const externalLinks = { open: vi.fn() } as unknown as ExternalLinkService;
const injector = Injector.create({
providers: [
ServerBrowserComponent,
{ provide: Store, useValue: store },
{ provide: Router, useValue: router },
{ provide: DatabaseService, useValue: db },
{ provide: ExternalLinkService, useValue: externalLinks },
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
{ provide: RealtimeSessionFacade, useValue: webrtc },
{ provide: PluginRequirementService, useValue: pluginRequirements },
{ provide: PluginStoreService, useValue: pluginStore }
]
});
const component = runInInjectionContext(injector, () => injector.get(ServerBrowserComponent));
return { component, dispatch, requestJoin, router, installServerRequirementsLocally };
}
function installLocalStorageMock(): void {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
}
});
}
describe('ServerBrowserComponent join flow', () => {
beforeEach(() => {
installLocalStorageMock();
localStorage.setItem('metoyou_currentUserId', 'user-1');
});
it('dispatches joinRoom after a successful join with no plugin requirements', async () => {
const { component, dispatch } = createHarness({
joinResult: { server: { id: 'server-1', name: 'Alpha' }, signalingUrl: 'wss://x' }
});
await component.joinServer(TEST_SERVER);
const joinDispatch = dispatch.mock.calls.find(([action]) => action.type === RoomsActions.joinRoom.type);
expect(joinDispatch).toBeTruthy();
expect(joinDispatch?.[0].roomId).toBe('server-1');
});
it('opens the password dialog when the server requires a password', async () => {
const { component, dispatch } = createHarness({
joinError: { error: { errorCode: 'PASSWORD_REQUIRED', error: 'Password required' } }
});
await component.joinServer(TEST_SERVER);
expect(component.showPasswordDialog()).toBe(true);
expect(component.passwordPromptServer()?.id).toBe('server-1');
expect(dispatch.mock.calls.some(([action]) => action.type === RoomsActions.joinRoom.type)).toBe(false);
});
it('shows the banned dialog when the server reports the user is banned', async () => {
const { component } = createHarness({
joinError: { error: { errorCode: 'BANNED', error: 'Banned' } }
});
await component.joinServer(TEST_SERVER);
expect(component.showBannedDialog()).toBe(true);
expect(component.bannedServerName()).toBe('Alpha');
});
it('presents a plugin-consent dialog before joining when requirements exist', async () => {
const { component, dispatch } = createHarness({
snapshotRequirements: [{ pluginId: 'p1', status: 'required', manifest: { title: 'P1' } }]
});
await component.joinServer(TEST_SERVER);
expect(component.pluginConsentDialog()).toBeTruthy();
expect(component.pluginConsentDialog()?.required).toHaveLength(1);
expect(dispatch.mock.calls.some(([action]) => action.type === RoomsActions.joinRoom.type)).toBe(false);
});
it('installs accepted requirements then joins on consent confirmation', async () => {
const { component, dispatch, installServerRequirementsLocally } = createHarness({
snapshotRequirements: [{ pluginId: 'p1', status: 'required', manifest: { title: 'P1' } }],
joinResult: { server: { id: 'server-1', name: 'Alpha' }, signalingUrl: 'wss://x' }
});
await component.joinServer(TEST_SERVER);
await component.confirmPluginConsent();
expect(installServerRequirementsLocally).toHaveBeenCalled();
expect(dispatch.mock.calls.some(([action]) => action.type === RoomsActions.joinRoom.type)).toBe(true);
});
it('hides discovery sections that have no servers', () => {
const { component } = createHarness();
component.discoverySections = [{ id: 'a', title: 'A', servers: [] }, { id: 'b', title: 'B', servers: [TEST_SERVER] }];
expect(component.visibleSections).toHaveLength(1);
expect(component.visibleSections[0].id).toBe('b');
expect(component.showEmptyState).toBe(false);
});
it('reports an empty state when no sections have servers and not searching', () => {
const { component } = createHarness();
component.discoverySections = [{ id: 'a', title: 'A', servers: [] }];
expect(component.showEmptyState).toBe(true);
});
});

View File

@@ -0,0 +1,635 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
Injector,
Input,
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 { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideExternalLink,
lucideFileText,
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
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,
type PluginRequirementSummary
} from '../../../../shared-kernel';
import { ExternalLinkService } from '../../../../core/platform';
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 { ChatMessageMarkdownComponent } from '../../../chat';
import { hasRoomBanForUser } from '../../../access-control';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import {
PluginRequirementService,
PluginStoreService,
type PluginStoreReadme
} from '../../../plugins';
interface JoinPluginConsentDialog {
optional: PluginRequirementSummary[];
password?: string;
required: PluginRequirementSummary[];
server: ServerInfo;
}
/** A named group of servers rendered when the browser is not in active search mode. */
export interface ServerDiscoverySection {
id: string;
title: string;
subtitle?: string;
servers: ServerInfo[];
}
/**
* Reusable server discovery + join surface. Owns the full join flow (password prompt,
* plugin-consent, banned, plugin readme) and the leave-server dialog, and renders both
* live search results and any caller-supplied discovery sections with the same card UI.
*/
@Component({
selector: 'app-server-browser',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ChatMessageMarkdownComponent,
ConfirmDialogComponent,
LeaveServerDialogComponent
],
viewProviders: [
provideIcons({
lucideExternalLink,
lucideFileText,
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucideChevronDown
})
],
templateUrl: './server-browser.component.html'
})
export class ServerBrowserComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
private db = inject(DatabaseService);
private externalLinks = inject(ExternalLinkService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private pluginRequirements = inject(PluginRequirementService);
private pluginStore = inject(PluginStoreService);
private injector = inject(Injector);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
/** Discovery sections shown when the search query is empty. */
@Input() discoverySections: ServerDiscoverySection[] = [];
/** Title for the onboarding empty state when there is nothing to show. */
@Input() emptyStateTitle = 'No servers yet';
/** Supporting copy for the onboarding empty state. */
@Input() emptyStateMessage = 'Search to find a server to join.';
/** Placeholder for the search input. */
@Input() searchPlaceholder = 'Search servers...';
/** Whether the My Servers quick bar is shown. */
@Input() showMyServers = true;
searchQuery = '';
searchResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching);
error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentUser = this.store.selectSignal(selectCurrentUser);
activeEndpoints = this.serverDirectory.activeServers;
bannedServerLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptServer = signal<ServerInfo | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
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);
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
pluginConsentReadmeLoadingId = signal<string | null>(null);
pluginConsentReadmeError = signal<string | null>(null);
// The reactive effect is created in ngOnInit with an explicit injector so the
// component can be instantiated outside a change-detection context (e.g. unit tests).
ngOnInit(): void {
effect(
() => {
const servers = this.searchResults();
const currentUser = this.currentUser();
void this.refreshBannedLookup(servers, currentUser ?? null);
void this.requestMissingServerIcons(servers, currentUser ?? null);
},
{ injector: this.injector }
);
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
this.store.dispatch(RoomsActions.loadRooms());
this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => {
this.store.dispatch(RoomsActions.searchServers({ query }));
});
}
/** True while the user is actively searching (non-empty query). */
get isSearchMode(): boolean {
return this.searchQuery.trim().length > 0;
}
/** Discovery sections that actually contain servers. */
get visibleSections(): ServerDiscoverySection[] {
return this.discoverySections.filter((section) => section.servers.length > 0);
}
/** True when there is nothing to render outside of search mode. */
get showEmptyState(): boolean {
return !this.isSearchMode && this.visibleSections.length === 0;
}
onSearchChange(query: string): void {
this.searchSubject.next(query);
}
async joinServer(server: ServerInfo): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
if (await this.isServerBanned(server)) {
this.bannedServerName.set(server.name);
this.showBannedDialog.set(true);
return;
}
await this.attemptJoinServer(server);
}
joinSavedRoom(room: Room): void {
this.openJoinedRoom(room);
}
openServerCard(server: ServerInfo): void {
const joinedRoom = this.joinedRoomForServer(server);
if (joinedRoom) {
this.openJoinedRoom(joinedRoom);
return;
}
void this.joinServer(server);
}
joinedRoomForServer(server: ServerInfo): Room | null {
return this.savedRooms().find((room) => room.id === server.id) ?? null;
}
isJoinedServer(server: ServerInfo): boolean {
return !!this.joinedRoomForServer(server);
}
toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
event.stopPropagation();
this.joinedServerMenuId.update((currentId) => (currentId === server.id ? null : server.id));
}
closeJoinedServerMenu(): void {
this.joinedServerMenuId.set(null);
}
openLeaveDialog(event: Event, server: ServerInfo): void {
event.stopPropagation();
const room = this.joinedRoomForServer(server);
if (!room) {
return;
}
this.joinedServerMenuId.set(null);
this.leaveDialogRoom.set(room);
}
closeLeaveDialog(): void {
this.leaveDialogRoom.set(null);
}
confirmLeaveServer(result: LeaveServerDialogResult): void {
const room = this.leaveDialogRoom();
if (!room) {
return;
}
this.store.dispatch(
RoomsActions.forgetRoom({
roomId: room.id,
nextOwnerKey: result.nextOwnerKey
})
);
this.leaveDialogRoom.set(null);
}
closeBannedDialog(): void {
this.showBannedDialog.set(false);
this.bannedServerName.set('');
}
closePasswordDialog(): void {
this.showPasswordDialog.set(false);
this.passwordPromptServer.set(null);
this.joinPassword.set('');
this.joinPasswordError.set(null);
}
closePluginConsentDialog(): void {
if (this.pluginConsentBusy()) {
return;
}
this.pluginConsentDialog.set(null);
this.selectedOptionalPluginIds.set(new Set());
this.pluginConsentError.set(null);
this.closePluginConsentReadme();
}
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());
this.closePluginConsentReadme();
} catch (error) {
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
} finally {
this.pluginConsentBusy.set(false);
}
}
async openPluginConsentReadme(requirement: PluginRequirementSummary): Promise<void> {
this.pluginConsentReadmeError.set(null);
this.pluginConsentReadmeLoadingId.set(requirement.pluginId);
try {
const readme = await this.pluginStore.loadRequirementReadme(requirement);
this.pluginConsentReadme.set(readme);
} catch (error) {
this.pluginConsentReadmeError.set(error instanceof Error ? error.message : 'Unable to load plugin readme');
} finally {
this.pluginConsentReadmeLoadingId.set(null);
}
}
closePluginConsentReadme(): void {
this.pluginConsentReadme.set(null);
this.pluginConsentReadmeError.set(null);
this.pluginConsentReadmeLoadingId.set(null);
}
openPluginSource(requirement: PluginRequirementSummary): void {
const sourceUrl = this.getPluginSourceUrl(requirement);
if (sourceUrl) {
this.externalLinks.open(sourceUrl);
}
}
getPluginSourceUrl(requirement: PluginRequirementSummary): string | null {
const candidate = requirement.manifest?.homepage ?? requirement.sourceUrl ?? requirement.installUrl ?? requirement.manifest?.bugs ?? null;
return candidate?.startsWith('http://') || candidate?.startsWith('https://') ? candidate : null;
}
hasPluginReadme(requirement: PluginRequirementSummary): boolean {
return !!requirement.manifest?.readme;
}
async confirmPasswordJoin(): Promise<void> {
const server = this.passwordPromptServer();
if (!server)
return;
await this.attemptJoinServer(server, this.joinPassword());
}
isServerMarkedBanned(server: ServerInfo): boolean {
return !!this.bannedServerLookup()[server.id];
}
getServerUserCount(server: ServerInfo): number {
const candidate = server as ServerInfo & { currentUsers?: number };
if (typeof server.userCount === 'number')
return server.userCount;
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
}
getServerCapacityLabel(server: ServerInfo): string {
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
}
getServerOwnerLabel(server: ServerInfo): string {
const joinedRoom = this.joinedRoomForServer(server);
const ownerKey = server.ownerId || joinedRoom?.hostId || '';
const ownerMember = joinedRoom?.members?.find((member) => member.id === ownerKey || member.oderId === ownerKey);
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner';
}
private openJoinedRoom(room: Room): void {
this.joinedServerMenuId.set(null);
this.store.dispatch(RoomsActions.viewServer({ room }));
}
private async attemptJoinServer(
server: ServerInfo,
password?: string,
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.joinErrorMessage.set(null);
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(
{
roomId: server.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
},
{
sourceId: server.sourceId,
sourceUrl: server.sourceUrl
}
)
);
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: response.server.sourceId ?? server.sourceId,
sourceName: response.server.sourceName ?? server.sourceName,
sourceUrl: response.server.sourceUrl ?? server.sourceUrl,
signalingUrl: response.signalingUrl,
fallbackName: response.server.sourceName ?? server.sourceName ?? server.name
},
{
ensureEndpoint: true
}
);
const resolvedServer = {
...server,
...response.server,
channels: Array.isArray(response.server.channels) && response.server.channels.length > 0 ? response.server.channels : server.channels,
...resolvedSource,
signalingUrl: response.signalingUrl
};
this.closePasswordDialog();
if (options.acceptedRequirements?.length) {
await this.pluginStore.installServerRequirementsLocally(resolvedServer.id, options.acceptedRequirements, { activate: true });
}
this.store.dispatch(
RoomsActions.joinRoom({
roomId: resolvedServer.id,
serverInfo: resolvedServer
})
);
} catch (error: unknown) {
const serverError = error as {
error?: { error?: string; errorCode?: string };
};
const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
if (errorCode === 'PASSWORD_REQUIRED') {
this.passwordPromptServer.set(server);
this.showPasswordDialog.set(true);
this.joinPasswordError.set(message);
return;
}
if (errorCode === 'BANNED') {
this.bannedServerName.set(server.name);
this.showBannedDialog.set(true);
return;
}
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;
}
for (const server of servers) {
if (server.icon) {
continue;
}
const selector = this.serverDirectory.buildRoomSignalSelector(
{
sourceId: server.sourceId,
sourceName: server.sourceName,
sourceUrl: server.sourceUrl,
fallbackName: server.sourceName ?? server.name
},
{
ensureEndpoint: !!server.sourceUrl
}
);
if (!selector) {
continue;
}
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
try {
await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl));
this.webrtc.identify(currentUser.oderId || currentUser.id, currentUser.displayName || 'User', wsUrl, {
description: currentUser.description,
profileUpdatedAt: currentUser.profileUpdatedAt
});
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
type: 'server_icon_sync_request',
serverId: server.id,
iconUpdatedAt: 0
});
} catch {
/* discovery icons are best-effort */
}
}
}
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;
if (!currentUser || servers.length === 0) {
this.bannedServerLookup.set({});
return;
}
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const entries = await Promise.all(
servers.map(async (server) => {
const bans = await this.db.getBansForRoom(server.id);
const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId);
return [server.id, isBanned] as const;
})
);
if (requestVersion !== this.banLookupRequestVersion)
return;
this.bannedServerLookup.set(Object.fromEntries(entries));
}
private async isServerBanned(server: ServerInfo): Promise<boolean> {
const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !currentUserId)
return false;
const bans = await this.db.getBansForRoom(server.id);
return hasRoomBanForUser(bans, currentUser, currentUserId);
}
}