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

@@ -78,7 +78,7 @@ export class LoginComponent {
return;
}
this.router.navigate(['/search']);
this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Login failed');

View File

@@ -80,7 +80,7 @@ export class RegisterComponent {
return;
}
this.router.navigate(['/search']);
this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Registration failed');

View File

@@ -250,7 +250,7 @@ export class ChatMessageItemComponent implements OnDestroy {
});
openMissingPluginStore(fallback: MissingPluginEmbedFallback): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url;
void this.router.navigate(['/plugin-store'], {
queryParams: {

View File

@@ -11,9 +11,13 @@ direct-message/
├── application/services/ DirectMessageService, OfflineMessageQueueService, FriendService, PeerDeliveryService
├── domain/ Direct message models and status-transition rules
├── infrastructure/ User-scoped local repositories
└── feature/ DM rail, chat view, message rows, user search, friend button
└── feature/ DM rail, chat view, message rows, user search, find-people page, friend button
```
## People discovery (`/people`)
`FindPeopleComponent` (`feature/find-people/`) backs the `/people` route reached from the `/dashboard` "Find people" card and the server rail. When the directory has discoverable people it renders `app-user-search-list`; otherwise it shows an onboarding empty state. The page is a thin wrapper around the existing user-search feature, so friend requests and DM starts flow through the same `FriendService` / `DirectMessageService` paths.
## Flow
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.

View File

@@ -0,0 +1,83 @@
<ng-template #pageContent>
<div class="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<header class="flex items-center gap-3 border-b border-border px-4 py-3">
<a
routerLink="/dashboard"
aria-label="Back to dashboard"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</a>
<div class="min-w-0">
<h1 class="truncate text-lg font-semibold text-foreground">Find people</h1>
<p class="truncate text-xs text-muted-foreground">Search for people you share servers with.</p>
</div>
</header>
<div class="border-b border-border px-4 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 people"
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="Search people..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
/>
</div>
</div>
@if (hasDiscoverablePeople()) {
<app-user-search-list
class="min-h-0 flex-1 overflow-y-auto"
[searchQuery]="searchQuery()"
/>
} @else {
<div class="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center text-muted-foreground">
<div class="mb-4 grid h-14 w-14 place-items-center rounded-full bg-secondary">
<ng-icon
name="lucideUsers"
class="h-7 w-7 text-muted-foreground"
/>
</div>
<p class="text-base font-semibold text-foreground">No people to show yet</p>
<p class="mt-1 max-w-sm text-sm">Join servers to discover people with shared interests.</p>
<a
routerLink="/servers"
class="mt-5 inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Find servers
</a>
</div>
}
</div>
</ng-template>
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full bg-background"
slides-per-view="1"
space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<ng-container [ngTemplateOutlet]="pageContent" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<ng-container [ngTemplateOutlet]="pageContent" />
}

View File

@@ -0,0 +1,89 @@
import '@angular/compiler';
import {
describe,
it,
expect,
vi
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { FindPeopleComponent } from './find-people.component';
import { ViewportService } from '../../../../core/platform';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import type { User, Room } from '../../../../shared-kernel';
interface HarnessOptions {
users?: User[];
saved?: Room[];
isMobile?: boolean;
}
function createHarness(options: HarnessOptions = {}) {
const usersSig = signal<User[]>(options.users ?? []);
const savedSig = signal<Room[]>(options.saved ?? []);
const store = {
selectSignal: (selector: unknown) => {
if (selector === selectAllUsers) {
return usersSig;
}
if (selector === selectSavedRooms) {
return savedSig;
}
return signal(null);
},
dispatch: vi.fn()
} as unknown as Store;
const injector = Injector.create({
providers: [
FindPeopleComponent,
{ provide: Store, useValue: store },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
]
});
const component = runInInjectionContext(injector, () => injector.get(FindPeopleComponent));
return { component };
}
describe('FindPeopleComponent', () => {
it('exposes the mobile viewport flag', () => {
expect(createHarness().component.isMobile()).toBe(false);
expect(createHarness({ isMobile: true }).component.isMobile()).toBe(true);
});
it('has no discoverable people for a brand-new account', () => {
const { component } = createHarness();
expect(component.hasDiscoverablePeople()).toBe(false);
});
it('reports discoverable people when users are known', () => {
const { component } = createHarness({ users: [{ id: 'u1' } as unknown as User] });
expect(component.hasDiscoverablePeople()).toBe(true);
});
it('reports discoverable people from saved-room members', () => {
const { component } = createHarness({
saved: [{ id: 'r1', members: [{ id: 'm1' }] } as unknown as Room]
});
expect(component.hasDiscoverablePeople()).toBe(true);
});
it('updates the search query', () => {
const { component } = createHarness();
component.onSearchChange('alice');
expect(component.searchQuery()).toBe('alice');
});
});

View File

@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
computed,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideSearch,
lucideUsers
} from '@ng-icons/lucide';
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
/**
* Dedicated people-discovery page. Wraps {@link UserSearchListComponent} with a search
* field and an onboarding empty state for accounts that have not joined any servers yet.
* On mobile the page is mounted inside a Swiper slide alongside the servers rail so the
* primary navigation stays reachable, matching the chat-room and DM workspace layouts.
*/
@Component({
selector: 'app-find-people',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterLink,
NgIcon,
UserSearchListComponent,
ServersRailComponent
],
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './find-people.component.html'
})
export class FindPeopleComponent {
private store = inject(Store);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
searchQuery = signal('');
private users = this.store.selectSignal(selectAllUsers);
private savedRooms = this.store.selectSignal(selectSavedRooms);
/** True when the account has any people to surface (known users or server members). */
hasDiscoverablePeople = computed(() => {
if (this.users().length > 0) {
return true;
}
return this.savedRooms().some((room) => (room.members?.length ?? 0) > 0);
});
onSearchChange(query: string): void {
this.searchQuery.set(query);
}
}

View File

@@ -148,7 +148,7 @@ export class PluginManagerComponent {
}
openStore(): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url;
this.storeOpened.emit();
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });

View File

@@ -1,6 +1,6 @@
<main class="min-h-screen bg-background p-6 text-foreground">
<a
routerLink="/search"
routerLink="/dashboard"
class="text-sm text-muted-foreground hover:text-foreground"
>Back</a
>

View File

@@ -601,7 +601,7 @@ export class PluginStoreComponent implements OnInit {
return returnUrl;
}
return '/search';
return '/dashboard';
}
private canManageServerPlugins(room: Room, user: User): boolean {

View File

@@ -34,7 +34,9 @@ server-directory/
├── feature/
│ ├── invite/ Invite creation and resolution UI
│ ├── server-search/ Server search/browse panel
│ ├── server-browser/ Reusable server discovery + join-flow component (`app-server-browser`)
│ ├── find-servers/ `/servers` page: discovery sections (Recently active, Featured, Trending) + browse
│ ├── create-server/ `/create-server` page: category presets + create form
│ └── settings/ Server endpoint management settings
└── index.ts Barrel exports
@@ -151,9 +153,20 @@ The facade's `searchServers(query)` method supports two modes controlled by a `s
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
## Server discovery (featured / trending)
Beyond free-text search, the directory exposes curated discovery lists that power the `/servers` page and the `/dashboard` landing:
- `ServerDirectoryFacade.getFeaturedServers()``GET /api/servers/featured`
- `ServerDirectoryFacade.getTrendingServers()``GET /api/servers/trending`
Both pass through `ServerDirectoryService` to `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()`, which share a private `getDiscoveryServers(path)` HTTP helper and normalise results into `ServerInfo[]` exactly like search. The server ranks featured servers (stable curation) and trending servers (recent activity) via `server-ranking.util.ts`; each route caps results at 50 (`parseDiscoveryLimit`). The discovery routes are registered before the parameterised `/:id` route so `featured`/`trending` are not captured as server IDs.
`FindServersComponent` (`/servers`) composes these into discovery sections — **Recently active** (the user's saved rooms, capped at 6), **Featured servers**, and **Trending** — and renders them through the reusable `app-server-browser`. `DashboardComponent` (`/dashboard`) uses the same facade methods for its quick search results.
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.
The `/servers` "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.

View File

@@ -137,6 +137,18 @@ export class ServerDirectoryFacade {
return this.service.getServers(...args);
}
getFeaturedServers(
...args: Parameters<ServerDirectoryService['getFeaturedServers']>
): ReturnType<ServerDirectoryService['getFeaturedServers']> {
return this.service.getFeaturedServers(...args);
}
getTrendingServers(
...args: Parameters<ServerDirectoryService['getTrendingServers']>
): ReturnType<ServerDirectoryService['getTrendingServers']> {
return this.service.getTrendingServers(...args);
}
getServer(
...args: Parameters<ServerDirectoryService['getServer']>
): ReturnType<ServerDirectoryService['getServer']> {

View File

@@ -238,6 +238,14 @@ export class ServerDirectoryService {
return this.api.getServers(this.shouldSearchAllServers);
}
getFeaturedServers(limit?: number): Observable<ServerInfo[]> {
return this.api.getFeaturedServers(limit);
}
getTrendingServers(limit?: number): Observable<ServerInfo[]> {
return this.api.getTrendingServers(limit);
}
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.api.getServer(serverId, selector);
}

View File

@@ -0,0 +1,180 @@
<div class="flex h-full min-h-0 flex-col">
<header class="flex items-center gap-3 border-b border-border px-4 py-3">
<a
routerLink="/dashboard"
aria-label="Back to dashboard"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</a>
<div class="min-w-0">
<h1 class="truncate text-lg font-semibold text-foreground">Create a server</h1>
<p class="truncate text-xs text-muted-foreground">Your server is where you and your community hang out.</p>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full max-w-lg space-y-6 p-4 sm:p-6">
<div>
<span class="mb-2 block text-sm font-medium text-foreground">Pick a category</span>
<div class="flex flex-wrap gap-2">
@for (category of categories; track category.id) {
<button
type="button"
class="rounded-full border px-3 py-1.5 text-sm font-medium transition-colors"
[class.border-primary]="selectedCategoryId() === category.id"
[class.bg-primary/10]="selectedCategoryId() === category.id"
[class.text-primary]="selectedCategoryId() === category.id"
[class.border-border]="selectedCategoryId() !== category.id"
[class.bg-secondary]="selectedCategoryId() !== category.id"
[class.text-foreground]="selectedCategoryId() !== category.id"
(click)="selectCategory(category)"
>
{{ category.label }}
</button>
}
</div>
</div>
<div>
<label
for="create-server-name"
class="mb-1 block text-sm font-medium text-foreground"
>Server name</label
>
<input
id="create-server-name"
type="text"
[ngModel]="name()"
(ngModelChange)="name.set($event)"
placeholder="My Awesome Server"
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>
<div>
<label
for="create-server-description"
class="mb-1 block text-sm font-medium text-foreground"
>Description (optional)</label
>
<textarea
id="create-server-description"
[ngModel]="description()"
(ngModelChange)="description.set($event)"
placeholder="What's your server about?"
rows="3"
class="w-full resize-none 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"
></textarea>
</div>
<div class="rounded-lg border border-border">
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-medium text-foreground"
[attr.aria-expanded]="showAdvanced()"
(click)="toggleAdvanced()"
>
<span>Advanced settings</span>
<ng-icon
[name]="showAdvanced() ? 'lucideChevronUp' : 'lucideChevronDown'"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (showAdvanced()) {
<div class="space-y-4 border-t border-border p-3">
<div>
<label
for="create-server-topic"
class="mb-1 block text-sm font-medium text-foreground"
>Topic (optional)</label
>
<input
id="create-server-topic"
type="text"
[ngModel]="topic()"
(ngModelChange)="topic.set($event)"
placeholder="gaming, music, coding..."
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>
<div>
<label
for="create-server-signal-endpoint"
class="mb-1 block text-sm font-medium text-foreground"
>Signal server endpoint</label
>
<select
id="create-server-signal-endpoint"
[(ngModel)]="sourceId"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (endpoint of activeEndpoints(); track endpoint.id) {
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
}
</select>
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this server.</p>
</div>
<div class="flex items-center gap-2">
<input
id="create-server-private"
type="checkbox"
[ngModel]="isPrivate()"
(ngModelChange)="isPrivate.set($event)"
class="h-4 w-4 rounded border-border bg-secondary"
/>
<label
for="create-server-private"
class="text-sm text-foreground"
>Private server</label
>
</div>
<div>
<label
for="create-server-password"
class="mb-1 block text-sm font-medium text-foreground"
>Password (optional)</label
>
<input
id="create-server-password"
type="password"
[ngModel]="password()"
(ngModelChange)="password.set($event)"
placeholder="Leave blank to allow joining without a 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"
/>
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
</div>
</div>
}
</div>
<div class="flex gap-3 pt-2">
<button
id="create-server-cancel"
type="button"
class="flex-1 rounded-lg bg-secondary px-4 py-2 text-foreground transition-colors hover:bg-secondary/80"
(click)="cancel()"
>
Cancel
</button>
<button
id="create-server-submit"
type="button"
class="flex-1 rounded-lg bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
[disabled]="!canCreate"
(click)="createServer()"
>
Create server
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,121 @@
import {
describe,
it,
expect,
vi,
beforeEach
} from 'vitest';
import { Injector, runInInjectionContext } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { CreateServerComponent } from './create-server.component';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
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;
}
});
}
function createHarness() {
const dispatch = vi.fn();
const store = { dispatch, selectSignal: () => () => null } as unknown as Store;
const router = { navigate: vi.fn() } as unknown as Router;
const serverDirectory = {
activeServers: () => [{ id: 'ep-1', name: 'Local', url: 'https://local.test' }]
} as unknown as ServerDirectoryFacade;
const injector = Injector.create({
providers: [
CreateServerComponent,
{ provide: Store, useValue: store },
{ provide: Router, useValue: router },
{ provide: ServerDirectoryFacade, useValue: serverDirectory }
]
});
const component = runInInjectionContext(injector, () => injector.get(CreateServerComponent));
return { component, dispatch, router };
}
describe('CreateServerComponent', () => {
beforeEach(() => {
installLocalStorageMock();
localStorage.setItem('metoyou_currentUserId', 'user-1');
});
it('defaults the signal endpoint to the first active endpoint', () => {
const { component } = createHarness();
component.ngOnInit();
expect(component.sourceId).toBe('ep-1');
expect(component.canCreate).toBe(false);
});
it('dispatches createRoom with the form values', () => {
const { component, dispatch } = createHarness();
component.ngOnInit();
component.name.set('My Server');
component.description.set('A place');
component.topic.set('gaming');
component.isPrivate.set(true);
component.password.set('secret');
component.createServer();
const action = dispatch.mock.calls.find(([entry]) => entry.type === RoomsActions.createRoom.type)?.[0];
expect(action).toMatchObject({
name: 'My Server',
description: 'A place',
topic: 'gaming',
isPrivate: true,
password: 'secret',
sourceId: 'ep-1'
});
});
it('does not dispatch when the name is blank', () => {
const { component, dispatch } = createHarness();
component.ngOnInit();
component.createServer();
expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false);
});
it('applies a category preset to the topic and toggles it off', () => {
const { component } = createHarness();
const gaming = component.categories[0];
component.selectCategory(gaming);
expect(component.topic()).toBe(gaming.topic);
expect(component.selectedCategoryId()).toBe(gaming.id);
component.selectCategory(gaming);
expect(component.topic()).toBe('');
expect(component.selectedCategoryId()).toBeNull();
});
it('navigates to the dashboard on cancel', () => {
const { component, router } = createHarness();
component.cancel();
expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
});
});

View File

@@ -0,0 +1,122 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideChevronDown,
lucideChevronUp
} from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
/** Preset categories that pre-fill the server topic to speed up creation. */
export interface ServerCategoryPreset {
id: string;
label: string;
topic: string;
}
const CATEGORY_PRESETS: ServerCategoryPreset[] = [
{ id: 'gaming', label: 'Gaming', topic: 'gaming' },
{ id: 'music', label: 'Music', topic: 'music' },
{ id: 'coding', label: 'Coding', topic: 'coding' },
{ id: 'community', label: 'Community', topic: 'community' },
{ id: 'study', label: 'Study', topic: 'study' }
];
/**
* Dedicated server-creation page. Replaces the old in-search create dialog with a
* focused form: category presets up front and signaling/privacy options behind a
* progressive-disclosure "Advanced settings" section.
*/
@Component({
selector: 'app-create-server',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterLink,
NgIcon
],
viewProviders: [provideIcons({ lucideArrowLeft, lucideChevronDown, lucideChevronUp })],
templateUrl: './create-server.component.html'
})
export class CreateServerComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
private serverDirectory = inject(ServerDirectoryFacade);
readonly categories = CATEGORY_PRESETS;
activeEndpoints = this.serverDirectory.activeServers;
name = signal('');
description = signal('');
topic = signal('');
selectedCategoryId = signal<string | null>(null);
isPrivate = signal(false);
password = signal('');
sourceId = '';
showAdvanced = signal(false);
ngOnInit(): void {
this.sourceId = this.activeEndpoints()[0]?.id ?? '';
}
/** True when the form has enough to create a server. */
get canCreate(): boolean {
return this.name().trim().length > 0 && this.sourceId.length > 0;
}
selectCategory(category: ServerCategoryPreset): void {
if (this.selectedCategoryId() === category.id) {
this.selectedCategoryId.set(null);
this.topic.set('');
return;
}
this.selectedCategoryId.set(category.id);
this.topic.set(category.topic);
}
toggleAdvanced(): void {
this.showAdvanced.update((shown) => !shown);
}
cancel(): void {
this.router.navigate(['/dashboard']);
}
createServer(): void {
if (!this.canCreate) {
return;
}
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.store.dispatch(
RoomsActions.createRoom({
name: this.name().trim(),
description: this.description().trim() || undefined,
topic: this.topic().trim() || undefined,
isPrivate: this.isPrivate(),
password: this.password().trim() || undefined,
sourceId: this.sourceId || undefined
})
);
}
}

View File

@@ -0,0 +1,51 @@
<ng-template #pageContent>
<div class="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<header class="flex items-center gap-3 border-b border-border px-4 py-3">
<a
routerLink="/dashboard"
aria-label="Back to dashboard"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</a>
<div class="min-w-0">
<h1 class="truncate text-lg font-semibold text-foreground">Find servers</h1>
<p class="truncate text-xs text-muted-foreground">Browse, search, and join communities.</p>
</div>
</header>
<app-server-browser
class="min-h-0 flex-1"
[discoverySections]="discoverySections()"
[showMyServers]="true"
searchPlaceholder="Search servers..."
emptyStateTitle="No servers to show yet"
emptyStateMessage="Search for a server above, or create your own to get started."
/>
</div>
</ng-template>
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full bg-background"
slides-per-view="1"
space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<ng-container [ngTemplateOutlet]="pageContent" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<ng-container [ngTemplateOutlet]="pageContent" />
}

View File

@@ -0,0 +1,105 @@
import '@angular/compiler';
import {
describe,
it,
expect,
vi
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { FindServersComponent } from './find-servers.component';
import { ViewportService } from '../../../../core/platform';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import type { ServerInfo } from '../../domain/models/server-directory.model';
import type { Room } from '../../../../shared-kernel';
function makeServer(id: string): ServerInfo {
return { id, name: id, maxUsers: 50, userCount: 1, isPrivate: false } as unknown as ServerInfo;
}
function makeRoom(id: string): Room {
return { id, name: id } as unknown as Room;
}
interface HarnessOptions {
saved?: Room[];
featured?: ServerInfo[];
trending?: ServerInfo[];
isMobile?: boolean;
}
function createHarness(options: HarnessOptions = {}) {
const savedSig = signal<Room[]>(options.saved ?? []);
const store = {
selectSignal: (selector: unknown) => (selector === selectSavedRooms ? savedSig : signal(null)),
dispatch: vi.fn()
} as unknown as Store;
const serverDirectory = {
getFeaturedServers: vi.fn(() => of(options.featured ?? [])),
getTrendingServers: vi.fn(() => of(options.trending ?? []))
} as unknown as ServerDirectoryFacade;
const injector = Injector.create({
providers: [
FindServersComponent,
{ provide: Store, useValue: store },
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
]
});
const component = runInInjectionContext(injector, () => injector.get(FindServersComponent));
return { component, savedSig };
}
describe('FindServersComponent', () => {
it('exposes the mobile viewport flag', () => {
expect(createHarness().component.isMobile()).toBe(false);
expect(createHarness({ isMobile: true }).component.isMobile()).toBe(true);
});
it('builds featured and trending sections after init', () => {
const { component } = createHarness({
featured: [makeServer('f1')],
trending: [makeServer('t1')]
});
component.ngOnInit();
const ids = component.discoverySections().map((section) => section.id);
expect(ids).toContain('featured');
expect(ids).toContain('trending');
});
it('includes a recently-active section from saved rooms', () => {
const { component } = createHarness({ saved: [makeRoom('r1')] });
const recent = component.discoverySections().find((section) => section.id === 'recent');
expect(recent).toBeTruthy();
expect(recent?.servers[0].id).toBe('r1');
});
it('reports a new user when there is nothing to recommend', () => {
const { component } = createHarness();
component.ngOnInit();
expect(component.isNewUser()).toBe(true);
expect(component.discoverySections()).toHaveLength(0);
});
it('is not a new user once recommendations exist', () => {
const { component } = createHarness({ featured: [makeServer('f1')] });
component.ngOnInit();
expect(component.isNewUser()).toBe(false);
});
});

View File

@@ -0,0 +1,122 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
computed,
inject,
OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideArrowLeft } from '@ng-icons/lucide';
import { ServerBrowserComponent, type ServerDiscoverySection } from '../server-browser/server-browser.component';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import type { Room } from '../../../../shared-kernel';
import type { ServerInfo } from '../../domain/models/server-directory.model';
/** Number of recently-joined servers surfaced as the "Recently active" section. */
const RECENT_SERVER_LIMIT = 6;
/**
* Dedicated server-discovery page. Hosts the reusable {@link ServerBrowserComponent}
* and feeds it featured, trending, and recently-active discovery sections. On mobile the
* page is mounted inside a Swiper slide alongside the servers rail so the primary
* navigation stays reachable, matching the chat-room and DM workspace layouts.
*/
@Component({
selector: 'app-find-servers',
standalone: true,
imports: [
CommonModule,
RouterLink,
NgIcon,
ServerBrowserComponent,
ServersRailComponent
],
viewProviders: [provideIcons({ lucideArrowLeft })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './find-servers.component.html'
})
export class FindServersComponent implements OnInit {
private store = inject(Store);
private serverDirectory = inject(ServerDirectoryFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
featured = signal<ServerInfo[]>([]);
trending = signal<ServerInfo[]>([]);
savedRooms = this.store.selectSignal(selectSavedRooms);
/** Discovery sections shown when the user is not actively searching. */
discoverySections = computed<ServerDiscoverySection[]>(() => {
const sections: ServerDiscoverySection[] = [];
const recent = this.savedRooms()
.slice(0, RECENT_SERVER_LIMIT)
.map((room) => this.toServerInfo(room));
if (recent.length > 0) {
sections.push({
id: 'recent',
title: 'Recently active',
subtitle: 'Servers you have joined',
servers: recent
});
}
if (this.featured().length > 0) {
sections.push({
id: 'featured',
title: 'Featured servers',
subtitle: 'The busiest communities right now',
servers: this.featured()
});
}
if (this.trending().length > 0) {
sections.push({
id: 'trending',
title: 'Trending',
subtitle: 'Recently active and gaining momentum',
servers: this.trending()
});
}
return sections;
});
/** True when there is nothing to recommend (a brand-new account). */
isNewUser = computed(() => this.discoverySections().length === 0);
ngOnInit(): void {
this.serverDirectory.getFeaturedServers().subscribe((servers) => this.featured.set(servers));
this.serverDirectory.getTrendingServers().subscribe((servers) => this.trending.set(servers));
}
private toServerInfo(room: Room): ServerInfo {
return {
id: room.id,
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
channels: room.channels,
createdAt: room.createdAt,
ownerId: room.hostId,
sourceId: room.sourceId,
sourceName: room.sourceName,
sourceUrl: room.sourceUrl
};
}
}

View File

@@ -57,7 +57,7 @@ export class InviteComponent implements OnInit {
}
goToSearch(): void {
this.router.navigate(['/search']).catch(() => {});
this.router.navigate(['/dashboard']).catch(() => {});
}
private buildEndpointName(sourceUrl: string): string {

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

@@ -3,6 +3,8 @@ import {
Component,
effect,
inject,
Injector,
Input,
OnInit,
signal
} from '@angular/core';
@@ -18,17 +20,13 @@ import {
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideExternalLink,
lucideFileText,
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
lucideChevronDown,
lucideLogIn
lucideChevronDown
} from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@@ -36,16 +34,14 @@ import {
selectSearchResults,
selectIsSearching,
selectRoomsError,
selectSavedRooms,
selectCurrentRoom
selectSavedRooms
} from '../../../../store/rooms/rooms.selectors';
import {
Room,
User,
type PluginRequirementSummary
} from '../../../../shared-kernel';
import { ExternalLinkService, ViewportService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
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';
@@ -57,7 +53,6 @@ import {
} from '../../../../shared';
import { ChatMessageMarkdownComponent } from '../../../chat';
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,
@@ -72,8 +67,21 @@ interface JoinPluginConsentDialog {
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-search',
selector: 'app-server-browser',
standalone: true,
imports: [
CommonModule,
@@ -81,56 +89,50 @@ interface JoinPluginConsentDialog {
NgIcon,
ChatMessageMarkdownComponent,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent
LeaveServerDialogComponent
],
viewProviders: [
provideIcons({
lucideArrowLeft,
lucideExternalLink,
lucideFileText,
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
lucideChevronDown,
lucideLogIn
lucideChevronDown
})
],
templateUrl: './server-search.component.html'
templateUrl: './server-browser.component.html'
})
/**
* Server search and discovery view with server creation dialog.
* Allows users to search for, join, and create new servers.
*/
export class ServerSearchComponent implements OnInit {
export class ServerBrowserComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
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 viewport = inject(ViewportService);
private injector = inject(Injector);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
/** True on mobile breakpoints. Drives the tabbed mobile layout. */
readonly isMobile = this.viewport.isMobile;
/** Active mobile tab. Ignored on desktop where both panes are visible side-by-side. */
readonly mobileTab = signal<'people' | 'servers'>('servers');
/** 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);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser);
activeEndpoints = this.serverDirectory.activeServers;
bannedServerLookup = signal<Record<string, boolean>>({});
@@ -151,43 +153,47 @@ export class ServerSearchComponent implements OnInit {
pluginConsentReadmeLoadingId = signal<string | null>(null);
pluginConsentReadmeError = signal<string | null>(null);
// Create dialog state
showCreateDialog = signal(false);
newServerName = signal('');
newServerDescription = signal('');
newServerTopic = signal('');
newServerPrivate = signal(false);
newServerPassword = signal('');
newServerSourceId = '';
constructor() {
effect(() => {
const servers = this.searchResults();
const currentUser = this.currentUser();
void this.refreshBannedLookup(servers, currentUser ?? null);
void this.requestMissingServerIcons(servers, currentUser ?? null);
});
}
/** Initialize server search, load saved rooms, and set up debounced search. */
// 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 {
// Initial load
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());
// Setup debounced search
this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => {
this.store.dispatch(RoomsActions.searchServers({ query }));
});
}
/** Emit a search query to the debounced search subject. */
/** 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);
}
/** Join a server from the search results. Redirects to login if unauthenticated. */
async joinServer(server: ServerInfo): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
@@ -205,73 +211,6 @@ export class ServerSearchComponent implements OnInit {
await this.attemptJoinServer(server);
}
/** Open the create-server dialog. */
openCreateDialog(): void {
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
this.showCreateDialog.set(true);
}
/** Close the create-server dialog and reset the form. */
closeCreateDialog(): void {
this.showCreateDialog.set(false);
this.resetCreateForm();
}
/** Submit the new server creation form and dispatch the create action. */
createServer(): void {
if (!this.newServerName())
return;
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.store.dispatch(
RoomsActions.createRoom({
name: this.newServerName(),
description: this.newServerDescription() || undefined,
topic: this.newServerTopic() || undefined,
isPrivate: this.newServerPrivate(),
password: this.newServerPassword().trim() || undefined,
sourceId: this.newServerSourceId || undefined
})
);
this.closeCreateDialog();
}
/** Open the unified settings modal to the Network page. */
openSettings(): void {
this.settingsModal.open('network');
}
/** Navigate to the login screen, preserving the search route as the return URL. */
goLogin(): void {
this.router.navigate(['/login'], { queryParams: { returnUrl: '/search' } });
}
/**
* Navigate back from the Search page to the chat-room view (server rail + current server).
* Prefers the current room; falls back to the first saved room. No-op when the user has not
* joined any servers.
*/
goBack(): void {
const target = this.currentRoom() ?? this.savedRooms()[0] ?? null;
if (target) {
this.store.dispatch(RoomsActions.viewServer({ room: target }));
}
}
/** True when the back button has a destination (user is in or has joined at least one server). */
canGoBack(): boolean {
return !!this.currentRoom() || this.savedRooms().length > 0;
}
/** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void {
this.openJoinedRoom(room);
}
@@ -483,27 +422,6 @@ export class ServerSearchComponent implements OnInit {
this.store.dispatch(RoomsActions.viewServer({ room }));
}
private toServerInfo(room: Room): ServerInfo {
return {
id: room.id,
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
channels: room.channels,
createdAt: room.createdAt,
ownerId: room.hostId,
sourceId: room.sourceId,
sourceName: room.sourceName,
sourceUrl: room.sourceUrl
};
}
private async attemptJoinServer(
server: ServerInfo,
password?: string,
@@ -714,13 +632,4 @@ export class ServerSearchComponent implements OnInit {
return hasRoomBanForUser(bans, currentUser, currentUserId);
}
private resetCreateForm(): void {
this.newServerName.set('');
this.newServerDescription.set('');
this.newServerTopic.set('');
this.newServerPrivate.set(false);
this.newServerPassword.set('');
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
}
}

View File

@@ -1,745 +0,0 @@
<div class="flex h-full min-h-0 flex-col">
<div class="border-b border-border px-3 py-3">
<!--
Mobile-only header row:
[Back] ----- Search ----- [Settings]
Hidden on >=md where the original inline header (search bar + buttons) is used.
-->
<div class="mb-2 flex items-center gap-2 md:hidden">
<button
type="button"
aria-label="Back to server view"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
[class.invisible]="!canGoBack()"
[disabled]="!canGoBack()"
(click)="goBack()"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</button>
<h1 class="min-w-0 flex-1 truncate text-center text-base font-semibold text-foreground">Search</h1>
@if (!currentUser()) {
<button
type="button"
aria-label="Log in"
class="inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="goLogin()"
>
<ng-icon
name="lucideLogIn"
class="h-5 w-5"
/>
<span>Log in</span>
</button>
} @else {
<button
type="button"
aria-label="Settings"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
(click)="openSettings()"
>
<ng-icon
name="lucideSettings"
class="h-5 w-5"
/>
</button>
}
</div>
<div class="flex flex-row items-center gap-2">
<div class="relative min-w-0 flex-1">
<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 people and 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="Search servers and users..."
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
/>
</div>
<!-- Create button is shown inline next to the search input on all sizes; Settings is desktop-only here (mobile uses the top header row above). -->
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="Create New Server"
class="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="openCreateDialog()"
>
<ng-icon
name="lucidePlus"
class="h-4 w-4"
/>
<span>Create</span>
</button>
<button
type="button"
class="hidden h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80 md:grid"
title="Settings"
(click)="openSettings()"
>
<ng-icon
name="lucideSettings"
class="h-5 w-5 text-muted-foreground"
/>
</button>
</div>
</div>
@if (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>
<!-- Mobile tab strip: toggle between People and Servers panes (hidden on >=md) -->
<div
role="tablist"
aria-label="Search results"
class="flex border-b border-border md:hidden"
>
<button
type="button"
role="tab"
[attr.aria-selected]="mobileTab() === 'people'"
class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors border-b-2"
[class.border-primary]="mobileTab() === 'people'"
[class.text-foreground]="mobileTab() === 'people'"
[class.border-transparent]="mobileTab() !== 'people'"
[class.text-muted-foreground]="mobileTab() !== 'people'"
(click)="mobileTab.set('people')"
>
People
</button>
<button
type="button"
role="tab"
[attr.aria-selected]="mobileTab() === 'servers'"
class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors border-b-2"
[class.border-primary]="mobileTab() === 'servers'"
[class.text-foreground]="mobileTab() === 'servers'"
[class.border-transparent]="mobileTab() !== 'servers'"
[class.text-muted-foreground]="mobileTab() !== 'servers'"
(click)="mobileTab.set('servers')"
>
Servers ({{ searchResults().length }})
</button>
</div>
<div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]">
<app-user-search-list
class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r"
[class.hidden]="isMobile() && mobileTab() !== 'people'"
[searchQuery]="searchQuery"
/>
<section
class="min-h-0 overflow-y-auto"
[class.hidden]="isMobile() && mobileTab() !== 'servers'"
>
<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">Servers</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) {
<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-[opacity,transform] duration-75 ease-out md:pointer-events-none md:scale-95 md:opacity-0 md:hover:scale-100 md:hover:opacity-100 md:group-hover:pointer-events-auto md:group-hover:scale-100 md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:scale-100 md:group-focus-within:opacity-100"
[attr.aria-label]="'Join ' + server.name"
(click)="joinServer(server)"
>
<span class="sr-only">{{ server.name }}</span>
Join
</button>
}
</div>
</div>
</div>
}
</div>
}
</section>
</div>
@if (joinErrorMessage() || error()) {
<div class="p-4 bg-destructive/10 border-t border-destructive">
<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>
}
}
<!-- Create Server Dialog -->
@if (showCreateDialog()) {
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
(click)="closeCreateDialog()"
(keydown.enter)="closeCreateDialog()"
(keydown.space)="closeCreateDialog()"
role="button"
tabindex="0"
aria-label="Close create server dialog"
>
<div
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
<div class="space-y-4">
<div>
<label
for="create-server-name"
class="block text-sm font-medium text-foreground mb-1"
>Server Name</label
>
<input
type="text"
[(ngModel)]="newServerName"
placeholder="My Awesome Server"
id="create-server-name"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="create-server-description"
class="block text-sm font-medium text-foreground mb-1"
>Description (optional)</label
>
<textarea
[(ngModel)]="newServerDescription"
placeholder="What's your server about?"
rows="3"
id="create-server-description"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
></textarea>
</div>
<div>
<label
for="create-server-topic"
class="block text-sm font-medium text-foreground mb-1"
>Topic (optional)</label
>
<input
type="text"
[(ngModel)]="newServerTopic"
placeholder="gaming, music, coding..."
id="create-server-topic"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="create-server-signal-endpoint"
class="block text-sm font-medium text-foreground mb-1"
>Signal Server Endpoint</label
>
<select
id="create-server-signal-endpoint"
[(ngModel)]="newServerSourceId"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (endpoint of activeEndpoints(); track endpoint.id) {
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
}
</select>
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this chat server.</p>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
[(ngModel)]="newServerPrivate"
id="private"
class="w-4 h-4 rounded border-border bg-secondary"
/>
<label
for="private"
class="text-sm text-foreground"
>Private server</label
>
</div>
<div>
<label
for="create-server-password"
class="block text-sm font-medium text-foreground mb-1"
>Password (optional)</label
>
<input
type="password"
[(ngModel)]="newServerPassword"
placeholder="Leave blank to allow joining without a password"
id="create-server-password"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
(click)="closeCreateDialog()"
type="button"
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Cancel
</button>
<button
(click)="createServer()"
[disabled]="!newServerName() || !newServerSourceId"
type="button"
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create
</button>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,82 @@
import {
describe,
it,
expect,
vi
} from 'vitest';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injector, runInInjectionContext } from '@angular/core';
import { of, throwError } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { ServerDirectoryApiService } from './server-directory-api.service';
import { ServerEndpointStateService } from '../../application/services/server-endpoint-state.service';
interface HarnessOptions {
getResult?: unknown;
getError?: unknown;
}
function createHarness(options: HarnessOptions = {}) {
const get = vi.fn((..._args: unknown[]) =>
options.getError ? throwError(() => options.getError) : of(options.getResult ?? { servers: [], total: 0 })
);
const http = { get } as unknown as HttpClient;
const endpointState = {
activeServer: () => ({ id: 'ep-1', name: 'Local', url: 'https://local.test', status: 'online' }),
activeServers: () => [],
servers: () => [],
resolveCanonicalEndpoint: (endpoint: unknown) => endpoint ?? null,
findServerByUrl: () => null,
sanitiseUrl: (value: string) => value
} as unknown as ServerEndpointStateService;
const injector = Injector.create({
providers: [
ServerDirectoryApiService,
{ provide: HttpClient, useValue: http },
{ provide: ServerEndpointStateService, useValue: endpointState }
]
});
const service = runInInjectionContext(injector, () => injector.get(ServerDirectoryApiService));
return { service, get };
}
describe('ServerDirectoryApiService discovery endpoints', () => {
it('requests the featured endpoint and normalizes the response', async () => {
const { service, get } = createHarness({
getResult: { servers: [{ id: 's1', name: 'Alpha', currentUsers: 12 }], total: 1 }
});
const result = await firstValueFrom(service.getFeaturedServers());
expect(get).toHaveBeenCalledWith('https://local.test/api/servers/featured', {});
expect(result).toHaveLength(1);
expect(result[0].id).toBe('s1');
expect(result[0].userCount).toBe(12);
});
it('passes the limit as a query param for featured servers', async () => {
const { service, get } = createHarness();
await firstValueFrom(service.getFeaturedServers(5));
const [url, options] = get.mock.calls[0];
expect(url).toBe('https://local.test/api/servers/featured');
expect((options as { params: HttpParams }).params.get('limit')).toBe('5');
});
it('requests the trending endpoint', async () => {
const { service, get } = createHarness({ getResult: { servers: [], total: 0 } });
await firstValueFrom(service.getTrendingServers());
expect(get).toHaveBeenCalledWith('https://local.test/api/servers/trending', {});
});
it('returns an empty array when the discovery request fails', async () => {
const { service } = createHarness({ getError: new Error('network') });
const result = await firstValueFrom(service.getFeaturedServers());
expect(result).toEqual([]);
});
});

View File

@@ -103,6 +103,14 @@ export class ServerDirectoryApiService {
);
}
getFeaturedServers(limit?: number): Observable<ServerInfo[]> {
return this.getDiscoveryServers('featured', limit);
}
getTrendingServers(limit?: number): Observable<ServerInfo[]> {
return this.getDiscoveryServers('trending', limit);
}
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
@@ -288,6 +296,20 @@ export class ServerDirectoryApiService {
);
}
private getDiscoveryServers(kind: 'featured' | 'trending', limit?: number): Observable<ServerInfo[]> {
const params = typeof limit === 'number' ? new HttpParams().set('limit', String(limit)) : undefined;
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers/${kind}`, params ? { params } : {})
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
catchError((error) => {
console.error(`Failed to get ${kind} servers:`, error);
return of([]);
})
);
}
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
const resolvedEndpoint = this.resolveEndpoint(selector);