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

@@ -8,7 +8,7 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
## Feature list (alphabetical)
_No cross-context feature docs have been written yet._
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
The product client already documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.

View File

@@ -0,0 +1,79 @@
# Server Discovery
> **Area:** server-directory
> **Status:** Active
> **Last updated:** 2025-02-14
## Overview
Server discovery lets a signed-in user find public servers to join without knowing an exact name. It spans the signaling **server** (REST routes + CQRS query handlers that rank public servers) and the product **client** (`server-directory` domain API/facade plus the `/dashboard` landing and `/servers` browse page). It complements the existing free-text `GET /api/servers` search with two curated lists — **featured** and **trending**.
## Responsibilities
- Server: rank and return public servers as **featured** (most-populated) and **trending** (most-recently-active) lists, capped per request.
- Client: fetch those lists through `ServerDirectoryFacade` and render them via the reusable `app-server-browser` component on `/servers` and `/dashboard`.
- It does NOT own: free-text search (`GET /api/servers`), join/access checks (`/api/servers/:id/join`), invites, or room signal-affinity. Discovery is read-only browsing; joining flows through existing paths.
## Key concepts
- **Featured**: public servers ranked by membership count descending, ties broken by most recent `lastSeen` (`rankFeaturedServers`).
- **Trending**: public servers ranked by most recent `lastSeen` descending, ties broken by membership count (`rankTrendingServers`).
- **Discovery limit**: each route clamps `limit` to `[1, 50]` (`parseDiscoveryLimit`), default `12`.
---
## API Endpoints
Both endpoints live in `server/src/routes/servers.ts` and **must be registered before** the parameterised `/:id` route, otherwise Express resolves `featured`/`trending` as a server id.
### `GET /api/servers/featured`
- **Method**: GET
- **Authentication**: None (public discovery)
- **Rate Limiting**: No
- **Query params**: `limit` (optional integer; clamped to `[1, 50]`, default `12`)
### `GET /api/servers/trending`
- **Method**: GET
- **Authentication**: None (public discovery)
- **Rate Limiting**: No
- **Query params**: `limit` (optional integer; clamped to `[1, 50]`, default `12`)
### Response Schema (both)
```json
{
"servers": "ServerInfo[] — enriched public servers (icon, channels, sourceId/sourceName/sourceUrl filled by the client API layer)",
"total": "number — count of servers returned",
"limit": "number — the effective clamped limit"
}
```
`ServerInfo` matches the shape returned by `GET /api/servers` search results, so the client normalises and renders all three lists identically.
### Error Responses
- **500 Internal Server Error**: query handler / persistence failure.
---
## Server internals
- Routes delegate to CQRS query handlers `handleGetFeaturedServers` / `handleGetTrendingServers` (`server/src/cqrs/queries/handlers/`), dispatched via `GetFeaturedServers` / `GetTrendingServers` query types.
- Ranking lives in `server/src/cqrs/queries/handlers/server-ranking.util.ts` (`rankFeaturedServers`, `rankTrendingServers`, `loadMembershipCounts`). Membership counts load in a single grouped query.
- Results pass through the same `enrichServer()` step as search before serialisation.
## Client internals
- `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()` call the routes through a shared private `getDiscoveryServers(path)` helper and normalise into `ServerInfo[]`.
- `ServerDirectoryService``ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary.
- `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`.
- `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`.
- The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. Creating a server is reached via the dashboard / `/create-server` link instead.
- On mobile (`ViewportService.isMobile()`), `DashboardComponent`, `FindPeopleComponent` (`/people`), and `FindServersComponent` (`/servers`) each mount their page body inside a single `<swiper-container>` slide next to `app-servers-rail` (rail `shrink-0`, content `flex-1` with a left border), mirroring the chat-room / DM-workspace mobile layout so the primary navigation rail stays reachable. The page body is shared between the desktop and mobile branches via an `<ng-template #pageContent>` + `[ngTemplateOutlet]`, and each component declares `schemas: [CUSTOM_ELEMENTS_SCHEMA]` for the Swiper custom elements.
## Related
- Product-client domain README: `toju-app/src/app/domains/server-directory/README.md`
- People discovery (`/people`): `toju-app/src/app/domains/direct-message/README.md`

View File

@@ -7,72 +7,71 @@ import {
export class ServerSearchPage {
readonly searchInput: Locator;
readonly createServerButton: Locator;
readonly railCreateServerButton: Locator;
readonly searchCreateServerButton: Locator;
readonly railDashboardButton: Locator;
readonly settingsButton: Locator;
// Create server dialog
// Create server page
readonly serverNameInput: Locator;
readonly serverDescriptionInput: Locator;
readonly serverTopicInput: Locator;
readonly signalEndpointSelect: Locator;
readonly advancedSettingsToggle: Locator;
readonly privateCheckbox: Locator;
readonly serverPasswordInput: Locator;
readonly dialogCreateButton: Locator;
readonly dialogCancelButton: Locator;
readonly createSubmitButton: Locator;
readonly cancelButton: Locator;
constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers and users...');
this.railCreateServerButton = page.locator('button[title="Create Server"]');
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
this.createServerButton = this.searchCreateServerButton;
// Server discovery lives on /servers via <app-server-browser>.
this.searchInput = page.getByPlaceholder('Search servers...');
this.railDashboardButton = page.locator('button[title="Dashboard"]');
// Dashboard "Create Server" entry point.
this.createServerButton = page.getByRole('link', { name: 'Create Server' }).first();
this.settingsButton = page.locator('button[title="Settings"]');
// Create dialog elements
// Create-server page elements.
this.serverNameInput = page.locator('#create-server-name');
this.serverDescriptionInput = page.locator('#create-server-description');
this.serverTopicInput = page.locator('#create-server-topic');
this.signalEndpointSelect = page.locator('#create-server-signal-endpoint');
this.privateCheckbox = page.locator('#private');
this.advancedSettingsToggle = page.getByRole('button', { name: 'Advanced settings' });
this.privateCheckbox = page.locator('#create-server-private');
this.serverPasswordInput = page.locator('#create-server-password');
this.dialogCreateButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Create' });
this.dialogCancelButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Cancel' });
this.createSubmitButton = page.locator('#create-server-submit');
this.cancelButton = page.locator('#create-server-cancel');
}
async goto() {
await this.page.goto('/search');
await this.page.goto('/servers');
}
async createServer(name: string, options?: { description?: string; topic?: string; sourceId?: string }) {
if (!await this.serverNameInput.isVisible()) {
if (await this.searchCreateServerButton.isVisible()) {
await this.searchCreateServerButton.click();
} else {
await this.railCreateServerButton.click();
await this.page.goto('/create-server', { waitUntil: 'domcontentloaded' });
if (!await this.serverNameInput.isVisible()) {
await expect(this.searchCreateServerButton).toBeVisible({ timeout: 10_000 });
await this.searchCreateServerButton.click();
}
}
}
await expect(this.serverNameInput).toBeVisible();
await expect(this.serverNameInput).toBeVisible({ timeout: 10_000 });
await this.serverNameInput.fill(name);
if (options?.description) {
await this.serverDescriptionInput.fill(options.description);
}
if (options?.topic) {
await this.serverTopicInput.fill(options.topic);
if (options?.topic || options?.sourceId) {
if (!await this.serverTopicInput.isVisible()) {
await this.advancedSettingsToggle.click();
}
await expect(this.serverTopicInput).toBeVisible({ timeout: 10_000 });
if (options?.topic) {
await this.serverTopicInput.fill(options.topic);
}
if (options?.sourceId) {
await this.signalEndpointSelect.selectOption(options.sourceId);
}
}
if (options?.sourceId) {
await this.signalEndpointSelect.selectOption(options.sourceId);
}
await this.dialogCreateButton.click();
await this.createSubmitButton.click();
}
async joinSavedRoom(name: string) {
@@ -80,6 +79,8 @@ export class ServerSearchPage {
}
async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) {
await this.page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(this.searchInput).toBeVisible({ timeout: 15_000 });
await this.searchInput.fill(name);
const serverCard = this.page.locator('div[title]', { hasText: name }).first();

View File

@@ -170,7 +170,7 @@ async function registerUser(page: Page, user: TestUser): Promise<void> {
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
async function loginUser(page: Page, user: TestUser): Promise<void> {
@@ -178,7 +178,7 @@ async function loginUser(page: Page, user: TestUser): Promise<void> {
await retryTransientNavigation(() => loginPage.goto());
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/(search|room)(\/|$)/, { timeout: 15_000 });
await expect(page).toHaveURL(/\/(dashboard|room)(\/|$)/, { timeout: 15_000 });
}
async function logoutUser(page: Page): Promise<void> {
@@ -213,7 +213,7 @@ async function expectSavedRoomAndHistory(page: Page, roomName: string, messageTe
const messagesPage = new ChatMessagesPage(page);
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
await page.goto('/search', { waitUntil: 'domcontentloaded' });
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
@@ -223,10 +223,8 @@ async function expectSavedRoomAndHistory(page: Page, roomName: string, messageTe
}
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
const searchPage = new ServerSearchPage(page);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
for (const roomName of hiddenRoomNames) {
await expectSavedRoomHidden(page, roomName);
@@ -235,15 +233,15 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
await page.goto('/search', { waitUntil: 'domcontentloaded' });
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
if (!page.url().includes('/search')) {
await page.goto('/search', { waitUntil: 'domcontentloaded' });
if (!page.url().includes('/servers')) {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
}
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
@@ -254,7 +252,7 @@ function getRailSavedRoomButton(page: Page, roomName: string) {
}
function getSearchSavedRoomButton(page: Page, roomName: string) {
return page.locator('app-server-search').getByRole('button', { name: roomName, exact: true });
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {

View File

@@ -249,7 +249,7 @@ async function createSingleClientChatScenario(createClient: () => Promise<Client
credentials.password
);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
return {
client,
@@ -288,7 +288,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
aliceCredentials.password
);
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
await bobRegisterPage.goto();
await bobRegisterPage.register(
@@ -297,7 +297,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
bobCredentials.password
);
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const aliceSearchPage = new ServerSearchPage(alice.page);

View File

@@ -51,9 +51,9 @@ test.describe('Direct message flow', () => {
const scenario = await createDmScenario(createClient);
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
await scenario.alice.page.goto('/people', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/people/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-find-people')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
@@ -119,7 +119,7 @@ async function registerUser(page: Page, username: string, displayName: string):
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
async function openDmFromRoomUserCard(page: Page, displayName: string): Promise<void> {

View File

@@ -140,7 +140,7 @@ async function registerUser(page: Page, username: string, displayName: string, p
await registerPage.goto();
await registerPage.register(username, displayName, password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
async function installDesktopNotificationSpy(page: Page): Promise<void> {

View File

@@ -380,7 +380,7 @@ async function registerUser(client: PersistentClient): Promise<void> {
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {

View File

@@ -142,11 +142,11 @@ test.describe('Server icon sync', () => {
await test.step('Dave has not joined, but discovery loads the icon through a temporary peer sync', async () => {
await registerUser(dave);
await stripServerIconFromDirectorySearch(dave.page, serverName);
await dave.page.goto('/search', { waitUntil: 'domcontentloaded' });
await dave.page.goto('/servers', { waitUntil: 'domcontentloaded' });
await new ServerSearchPage(dave.page).searchInput.fill(serverName);
await expectSearchResultIcon(dave.page, serverName, icon.dataUrl);
await expect(dave.page).toHaveURL(/\/search/);
await expect(dave.page).toHaveURL(/\/servers/);
});
} finally {
await Promise.all(
@@ -209,7 +209,7 @@ async function registerUser(client: PersistentClient): Promise<void> {
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
@@ -403,7 +403,7 @@ async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: s
}
async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first();
const serverCard = page.locator('app-server-browser div[title]', { hasText: serverName }).first();
const image = serverCard.locator('[style*="background-image"]').first();
await expect(serverCard).toBeVisible({ timeout: 20_000 });

View File

@@ -137,7 +137,7 @@ async function registerUser(page: Page, username: string, displayName: string):
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 30_000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
}
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {

View File

@@ -15,7 +15,7 @@ test.describe('Plugin manager UI', () => {
await test.step('Register user and create server context', async () => {
await register.goto();
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
await search.createServer(`Plugin API Server ${suffix}`, {
description: 'Plugin manager UI E2E coverage'
});

View File

@@ -29,14 +29,14 @@ const BOB = { username: `bob_ss_${Date.now()}`, displayName: 'Bob', password: 'T
const SERVER_NAME = `SS Test ${Date.now()}`;
const VOICE_CHANNEL = 'General';
/** Register a user and navigate to /search. */
/** Register a user and navigate to /dashboard. */
async function registerUser(page: import('@playwright/test').Page, user: typeof ALICE) {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
/** Both users register -> Alice creates server -> Bob joins. */

View File

@@ -88,7 +88,7 @@ test.describe('Connectivity warning', () => {
await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Bob', async () => {
@@ -96,7 +96,7 @@ test.describe('Connectivity warning', () => {
await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Charlie', async () => {
@@ -104,7 +104,7 @@ test.describe('Connectivity warning', () => {
await register.goto();
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(charlie.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
});
// ── Create server and have everyone join ──

View File

@@ -9,7 +9,7 @@ test.describe('ICE server settings', () => {
await register.goto();
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
await page.getByTitle('Settings').click();
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();

View File

@@ -89,7 +89,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Bob', async () => {
@@ -97,7 +97,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Alice creates a server', async () => {

View File

@@ -105,7 +105,7 @@ async function createVoiceScenario(
await registerPage.goto();
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
});

View File

@@ -55,7 +55,7 @@ test.describe('Direct private calls', () => {
await test.step('Alice starts a call from the search people card', async () => {
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await scenario.alice.page.goto('/people', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first();
@@ -597,12 +597,12 @@ async function registerUser(page: Page, username: string, displayName: string):
await registerPage.goto();
await registerPage.register(username, displayName, USER_PASSWORD);
await expect(page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise<void> {
await disableLastViewedChatResume(page);
await page.goto('/search', { waitUntil: 'domcontentloaded' });
await page.goto('/people', { waitUntil: 'domcontentloaded' });
const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first();
await expect(peopleCard).toBeVisible({ timeout: 20_000 });

View File

@@ -136,7 +136,7 @@ test.describe('Mixed signal-config voice', () => {
await registerPage.goto();
await registerPage.serverSelect.selectOption(registrationEndpointId);
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
});
@@ -556,18 +556,13 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
if (await searchInput.isVisible().catch(() => false)) {
return;
}
await page.locator('button[title="Create Server"]').click();
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 20_000 });
}
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);

View File

@@ -71,7 +71,7 @@ test.describe('Dual-signal multi-user voice', () => {
await registerPage.goto();
await registerPage.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
});
@@ -319,18 +319,13 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
if (await searchInput.isVisible().catch(() => false)) {
return;
}
await page.locator('button[title="Create Server"]').click();
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 20_000 });
}
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);

View File

@@ -64,8 +64,8 @@ test.describe('Full user journey: register -> server -> voice chat', () => {
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(ALICE.username, ALICE.displayName, ALICE.password);
// After registration, app should navigate to /search
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
// After registration, app should navigate to /dashboard
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
await test.step('Bob registers an account', async () => {
@@ -75,7 +75,7 @@ test.describe('Full user journey: register -> server -> voice chat', () => {
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(BOB.username, BOB.displayName, BOB.password);
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
// ── Step 2: Alice creates a server ───────────────────────────────

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
images/icon-new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

BIN
images/icon-transparent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -15,6 +15,8 @@ import { handleDeleteStaleJoinRequests } from './commands/handlers/deleteStaleJo
import { handleGetUserByUsername } from './queries/handlers/getUserByUsername';
import { handleGetUserById } from './queries/handlers/getUserById';
import { handleGetAllPublicServers } from './queries/handlers/getAllPublicServers';
import { handleGetFeaturedServers } from './queries/handlers/getFeaturedServers';
import { handleGetTrendingServers } from './queries/handlers/getTrendingServers';
import { handleGetServerById } from './queries/handlers/getServerById';
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
@@ -46,6 +48,12 @@ export const getUserById = (userId: string) =>
export const getAllPublicServers = () =>
handleGetAllPublicServers(getDataSource());
export const getFeaturedServers = (limit?: number) =>
handleGetFeaturedServers(getDataSource(), limit);
export const getTrendingServers = (limit?: number) =>
handleGetTrendingServers(getDataSource(), limit);
export const getServerById = (serverId: string) =>
handleGetServerById({ type: QueryType.GetServerById, payload: { serverId } }, getDataSource());

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
import { loadMembershipCounts, rankFeaturedServers } from './server-ranking.util';
const DEFAULT_LIMIT = 12;
export async function handleGetFeaturedServers(dataSource: DataSource, limit = DEFAULT_LIMIT) {
const rows = await dataSource.getRepository(ServerEntity).find({ where: { isPrivate: 0 } });
const counts = await loadMembershipCounts(dataSource, rows.map((row) => row.id));
const ranked = rankFeaturedServers(rows, counts, limit);
const relationsByServerId = await loadServerRelationsMap(dataSource, ranked.map((row) => row.id));
return ranked.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
import { loadMembershipCounts, rankTrendingServers } from './server-ranking.util';
const DEFAULT_LIMIT = 12;
export async function handleGetTrendingServers(dataSource: DataSource, limit = DEFAULT_LIMIT) {
const rows = await dataSource.getRepository(ServerEntity).find({ where: { isPrivate: 0 } });
const counts = await loadMembershipCounts(dataSource, rows.map((row) => row.id));
const ranked = rankTrendingServers(rows, counts, limit);
const relationsByServerId = await loadServerRelationsMap(dataSource, ranked.map((row) => row.id));
return ranked.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
}

View File

@@ -0,0 +1,142 @@
import {
describe,
it,
expect
} from 'vitest';
import {
rankFeaturedServers,
rankTrendingServers,
RankableServer
} from './server-ranking.util';
function server(id: string, lastSeen: number): RankableServer {
return { id, lastSeen };
}
describe('rankFeaturedServers', () => {
it('orders by membership count descending', () => {
const rows = [
server('a', 100),
server('b', 100),
server('c', 100)
];
const counts = new Map([
['a', 2],
['b', 10],
['c', 5]
]);
const ranked = rankFeaturedServers(rows, counts);
expect(ranked.map((row) => row.id)).toEqual([
'b',
'c',
'a'
]);
});
it('breaks membership ties by most recent activity', () => {
const rows = [
server('a', 50),
server('b', 200),
server('c', 120)
];
const counts = new Map([
['a', 5],
['b', 5],
['c', 5]
]);
const ranked = rankFeaturedServers(rows, counts);
expect(ranked.map((row) => row.id)).toEqual([
'b',
'c',
'a'
]);
});
it('treats missing counts as zero', () => {
const rows = [server('a', 10), server('b', 10)];
const counts = new Map([['a', 1]]);
const ranked = rankFeaturedServers(rows, counts);
expect(ranked.map((row) => row.id)).toEqual(['a', 'b']);
});
it('applies the limit', () => {
const rows = [
server('a', 1),
server('b', 2),
server('c', 3)
];
const counts = new Map([
['a', 3],
['b', 2],
['c', 1]
]);
expect(rankFeaturedServers(rows, counts, 2).map((row) => row.id)).toEqual(['a', 'b']);
});
it('returns an empty array for a non-positive limit', () => {
const rows = [server('a', 1)];
expect(rankFeaturedServers(rows, new Map(), 0)).toEqual([]);
expect(rankFeaturedServers(rows, new Map(), -5)).toEqual([]);
});
it('does not mutate the input rows', () => {
const rows = [server('a', 1), server('b', 2)];
rankFeaturedServers(rows, new Map([['b', 9]]));
expect(rows.map((row) => row.id)).toEqual(['a', 'b']);
});
});
describe('rankTrendingServers', () => {
it('orders by most recent activity descending', () => {
const rows = [
server('a', 100),
server('b', 300),
server('c', 200)
];
const counts = new Map<string, number>();
const ranked = rankTrendingServers(rows, counts);
expect(ranked.map((row) => row.id)).toEqual([
'b',
'c',
'a'
]);
});
it('breaks activity ties by membership count', () => {
const rows = [
server('a', 100),
server('b', 100),
server('c', 100)
];
const counts = new Map([
['a', 1],
['b', 9],
['c', 4]
]);
const ranked = rankTrendingServers(rows, counts);
expect(ranked.map((row) => row.id)).toEqual([
'b',
'c',
'a'
]);
});
it('applies the limit', () => {
const rows = [
server('a', 1),
server('b', 2),
server('c', 3)
];
expect(rankTrendingServers(rows, new Map(), 1).map((row) => row.id)).toEqual(['c']);
});
});

View File

@@ -0,0 +1,94 @@
import { DataSource } from 'typeorm';
import { ServerMembershipEntity } from '../../../entities';
export interface RankableServer {
id: string;
lastSeen: number;
}
const DEFAULT_LIMIT = 12;
function clampLimit(limit: number): number {
if (!Number.isFinite(limit) || limit <= 0) {
return 0;
}
return Math.floor(limit);
}
function membershipCount(counts: Map<string, number>, id: string): number {
return counts.get(id) ?? 0;
}
/**
* Featured = most-populated public servers. Ranks by membership count descending,
* breaking ties by most recent activity (`lastSeen`).
*/
export function rankFeaturedServers<T extends RankableServer>(
rows: readonly T[],
counts: Map<string, number>,
limit: number = DEFAULT_LIMIT
): T[] {
return [...rows]
.sort((first, second) => {
const countDelta = membershipCount(counts, second.id) - membershipCount(counts, first.id);
if (countDelta !== 0) {
return countDelta;
}
return (second.lastSeen ?? 0) - (first.lastSeen ?? 0);
})
.slice(0, clampLimit(limit));
}
/**
* Trending = recently-active public servers. Ranks by most recent activity
* (`lastSeen`) descending, breaking ties by membership count.
*/
export function rankTrendingServers<T extends RankableServer>(
rows: readonly T[],
counts: Map<string, number>,
limit: number = DEFAULT_LIMIT
): T[] {
return [...rows]
.sort((first, second) => {
const activityDelta = (second.lastSeen ?? 0) - (first.lastSeen ?? 0);
if (activityDelta !== 0) {
return activityDelta;
}
return membershipCount(counts, second.id) - membershipCount(counts, first.id);
})
.slice(0, clampLimit(limit));
}
/**
* Loads membership counts for the supplied server ids in a single grouped query.
*/
export async function loadMembershipCounts(
dataSource: DataSource,
serverIds: readonly string[]
): Promise<Map<string, number>> {
const counts = new Map<string, number>();
if (serverIds.length === 0) {
return counts;
}
const rows = await dataSource
.getRepository(ServerMembershipEntity)
.createQueryBuilder('membership')
.select('membership.serverId', 'serverId')
.addSelect('COUNT(membership.id)', 'count')
.where('membership.serverId IN (:...serverIds)', { serverIds: [...serverIds] })
.groupBy('membership.serverId')
.getRawMany<{ serverId: string; count: string | number }>();
for (const row of rows) {
counts.set(row.serverId, Number(row.count) || 0);
}
return counts;
}

View File

@@ -12,6 +12,8 @@ import {
import { handleGetUserByUsername } from './handlers/getUserByUsername';
import { handleGetUserById } from './handlers/getUserById';
import { handleGetAllPublicServers } from './handlers/getAllPublicServers';
import { handleGetFeaturedServers } from './handlers/getFeaturedServers';
import { handleGetTrendingServers } from './handlers/getTrendingServers';
import { handleGetServerById } from './handlers/getServerById';
import { handleGetJoinRequestById } from './handlers/getJoinRequestById';
import { handleGetPendingRequestsForServer } from './handlers/getPendingRequestsForServer';
@@ -20,6 +22,8 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
[QueryType.GetUserByUsername]: (query) => handleGetUserByUsername(query as GetUserByUsernameQuery, dataSource),
[QueryType.GetUserById]: (query) => handleGetUserById(query as GetUserByIdQuery, dataSource),
[QueryType.GetAllPublicServers]: () => handleGetAllPublicServers(dataSource),
[QueryType.GetFeaturedServers]: () => handleGetFeaturedServers(dataSource),
[QueryType.GetTrendingServers]: () => handleGetTrendingServers(dataSource),
[QueryType.GetServerById]: (query) => handleGetServerById(query as GetServerByIdQuery, dataSource),
[QueryType.GetJoinRequestById]: (query) => handleGetJoinRequestById(query as GetJoinRequestByIdQuery, dataSource),
[QueryType.GetPendingRequestsForServer]: (query) => handleGetPendingRequestsForServer(query as GetPendingRequestsForServerQuery, dataSource)

View File

@@ -13,6 +13,8 @@ export const QueryType = {
GetUserByUsername: 'get-user-by-username',
GetUserById: 'get-user-by-id',
GetAllPublicServers: 'get-all-public-servers',
GetFeaturedServers: 'get-featured-servers',
GetTrendingServers: 'get-trending-servers',
GetServerById: 'get-server-by-id',
GetJoinRequestById: 'get-join-request-by-id',
GetPendingRequestsForServer: 'get-pending-requests-for-server'

View File

@@ -3,6 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
import {
getAllPublicServers,
getFeaturedServers,
getTrendingServers,
getServerById,
getUserById,
upsertServer,
@@ -155,6 +157,34 @@ router.get('/', async (req, res) => {
res.json({ servers: enrichedResults, total, limit: Number(limit), offset: Number(offset) });
});
function parseDiscoveryLimit(value: unknown, fallback: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.min(Math.floor(parsed), 50);
}
// NOTE: `/featured` and `/trending` must be registered before `/:id`,
// otherwise Express resolves them as a server id.
router.get('/featured', async (req, res) => {
const limit = parseDiscoveryLimit(req.query.limit, 12);
const servers = await getFeaturedServers(limit);
const enrichedResults = await Promise.all(servers.map((server) => enrichServer(server)));
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
});
router.get('/trending', async (req, res) => {
const limit = parseDiscoveryLimit(req.query.limit, 12);
const servers = await getTrendingServers(limit);
const enrichedResults = await Promise.all(servers.map((server) => enrichServer(server)));
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
});
router.post('/', async (req, res) => {
const {
id: clientId,

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -4,7 +4,7 @@ import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: 'search',
redirectTo: 'dashboard',
pathMatch: 'full'
},
{
@@ -23,10 +23,25 @@ export const routes: Routes = [
import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent)
},
{
path: 'search',
path: 'dashboard',
loadComponent: () =>
import('./domains/server-directory/feature/server-search/server-search.component').then(
(module) => module.ServerSearchComponent
import('./features/dashboard/dashboard.component').then((module) => module.DashboardComponent)
},
{
path: 'people',
loadComponent: () =>
import('./domains/direct-message/feature/find-people/find-people.component').then((module) => module.FindPeopleComponent)
},
{
path: 'servers',
loadComponent: () =>
import('./domains/server-directory/feature/find-servers/find-servers.component').then((module) => module.FindServersComponent)
},
{
path: 'create-server',
loadComponent: () =>
import('./domains/server-directory/feature/create-server/create-server.component').then(
(module) => module.CreateServerComponent
)
},
{

View File

@@ -282,14 +282,14 @@ export class App implements OnInit, OnDestroy {
if (!currentUserId) {
if (!this.isPublicRoute(currentUrl)) {
// On mobile, new/unauthenticated visitors landing on the app root or
// /search should stay on /search (which already exposes a login CTA).
// The login form has no mobile chrome / back button, so dropping new
// users straight onto it leaves them with no way to navigate away.
// /dashboard should stay on /dashboard (which already exposes a login
// CTA). The login form has no mobile chrome / back button, so dropping
// new users straight onto it leaves them with no way to navigate away.
const currentPath = this.getRoutePath(currentUrl);
const isSearchLanding = currentPath === '/' || currentPath === '/search';
const isSearchLanding = currentPath === '/' || currentPath === '/dashboard';
if (this.isMobile() && isSearchLanding) {
this.router.navigate(['/search'], { replaceUrl: true }).catch(() => {});
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
} else {
this.router.navigate(['/login'], {
queryParams: {
@@ -308,7 +308,7 @@ export class App implements OnInit, OnDestroy {
if (
generalSettings.reopenLastViewedChat
&& lastViewedChat
&& (currentUrl === '/' || currentUrl === '/search')
&& (currentUrl === '/' || currentUrl === '/dashboard')
) {
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
}

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);

View File

@@ -0,0 +1,428 @@
<ng-template #pageContent>
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
<header class="space-y-1">
<h1 class="text-2xl font-semibold text-foreground">
@if (currentUser()) {
Welcome back, {{ currentUser()!.displayName || 'there' }}
} @else {
Welcome to MetoYou
}
</h1>
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
</header>
<div>
<div class="relative">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
aria-label="Search people, servers, and invites"
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search for people, servers, or paste an invite..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
/>
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex">
Ctrl K
</kbd>
</div>
@if (!isSearchMode() && recentSearches().length > 0) {
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-muted-foreground">Recent:</span>
@for (term of recentSearches(); track term) {
<span class="group inline-flex items-center gap-1 rounded-full border border-border bg-secondary py-1 pl-3 pr-1 text-xs text-foreground">
<button
type="button"
class="max-w-[10rem] truncate hover:text-primary"
(click)="applyRecentSearch(term)"
>
{{ term }}
</button>
<button
type="button"
class="grid h-4 w-4 place-items-center rounded-full text-muted-foreground hover:bg-card hover:text-foreground"
[attr.aria-label]="'Remove ' + term"
(click)="removeRecentSearch(term)"
>
<ng-icon
name="lucideX"
class="h-3 w-3"
/>
</button>
</span>
}
<button
type="button"
class="text-xs font-medium text-muted-foreground hover:text-foreground hover:underline"
(click)="clearRecentSearches()"
>
Clear
</button>
</div>
}
</div>
@if (isSearchMode()) {
<section class="space-y-5">
@if (inviteResult(); as invite) {
<div>
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invite</h2>
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openInvite()"
>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<ng-icon
name="lucideTicket"
class="h-5 w-5"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">Open invite</p>
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
/>
</button>
</div>
}
@if (topServerResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>
</div>
<div class="space-y-2">
@for (server of topServerResults(); track server.id) {
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openServer(server)"
>
<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 {
{{ serverInitial(server) }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
</div>
</div>
}
@if (topPeopleResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">People</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>
</div>
<div class="space-y-2">
@for (person of topPeopleResults(); track person.id) {
<a
routerLink="/people"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
>
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
size="md"
[status]="person.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
</a>
}
</div>
</div>
}
@if (hasNoQuickResults() && !isSearching()) {
<div class="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground">
No people, servers, or invites match
<span class="font-medium text-foreground">{{ searchQuery() }}</span
>.
</div>
}
</section>
} @else {
<!-- Primary actions -->
<section class="grid gap-3 sm:grid-cols-3">
<a
routerLink="/people"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
<ng-icon
name="lucideUsers"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find People</p>
<p class="mt-0.5 text-xs text-muted-foreground">Connect with friends.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/servers"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
<ng-icon
name="lucideCompass"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find Servers</p>
<p class="mt-0.5 text-xs text-muted-foreground">Browse communities.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/create-server"
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
<ng-icon
name="lucidePlus"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Create Server</p>
<p class="mt-0.5 text-xs text-muted-foreground">Start your own.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-emerald-400 transition-transform group-hover:translate-x-0.5"
/>
</a>
</section>
@if (isNewUser()) {
<section class="rounded-xl border border-border bg-card p-6 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-secondary">
<ng-icon
name="lucideServer"
class="h-6 w-6 text-muted-foreground"
/>
</div>
<h2 class="text-base font-semibold text-foreground">Get started</h2>
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
You have not joined any servers yet. Find a community to join, or create your own to invite friends.
</p>
</section>
}
<!-- People + Popular servers -->
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>
</div>
@if (peopleYouMightKnow().length > 0) {
<div class="space-y-1">
@for (person of peopleYouMightKnow(); track person.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
size="md"
[status]="person.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
</div>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No people to suggest yet.</p>
}
</div>
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>
</div>
@if (popularServers().length > 0) {
<div class="space-y-1">
@for (server of popularServers(); track server.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<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 {
{{ serverInitial(server) }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<button
type="button"
class="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="openServer(server)"
>
Join
</button>
</div>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No popular servers right now.</p>
}
</div>
</section>
<!-- Your friends -->
@if (friends().length > 0) {
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Your Friends</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>Manage</a
>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
@for (friend of friends(); track friend.id) {
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
<app-user-avatar
[name]="personLabel(friend)"
[avatarUrl]="friend.avatarUrl"
size="md"
[status]="friend.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(friend) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(friend) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="friend" />
</div>
}
</div>
</section>
}
<!-- Recently active servers -->
@if (recentlyActiveServers().length > 0) {
<section>
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@for (room of recentlyActiveServers(); track room.id) {
<button
type="button"
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openSavedRoom(room)"
>
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-secondary text-base font-semibold text-foreground">
@if (room.icon) {
<div
aria-hidden="true"
class="h-full w-full bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + room.icon + ')'"
></div>
} @else {
{{ room.name[0]?.toUpperCase() || '?' }}
}
</div>
<p class="w-full truncate text-sm font-medium text-foreground">{{ room.name }}</p>
<p class="text-xs text-muted-foreground">{{ room.userCount }} members</p>
</button>
}
</div>
</section>
}
}
</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,243 @@
import '@angular/compiler';
import {
describe,
it,
expect,
vi
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { DashboardComponent } from './dashboard.component';
import { ViewportService } from '../../core/platform';
import { ServerDirectoryFacade } from '../../domains/server-directory/application/facades/server-directory.facade';
import { FriendService } from '../../domains/direct-message/application/services/friend.service';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import {
selectSearchResults,
selectIsSearching,
selectSavedRooms
} from '../../store/rooms/rooms.selectors';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import type { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model';
import type { Room, User } from '../../shared-kernel';
interface HarnessOptions {
searchResults?: ServerInfo[];
saved?: Room[];
users?: User[];
currentUser?: User | null;
featured?: ServerInfo[];
trending?: ServerInfo[];
friendIds?: string[];
isMobile?: boolean;
}
function makeServer(id: string, name = id): ServerInfo {
return { id, name, maxUsers: 50, userCount: 1, isPrivate: false } as unknown as ServerInfo;
}
function createHarness(options: HarnessOptions = {}) {
const dispatch = vi.fn();
const searchResultsSig = signal<ServerInfo[]>(options.searchResults ?? []);
const savedSig = signal<Room[]>(options.saved ?? []);
const usersSig = signal<User[]>(options.users ?? []);
const currentUserSig = signal<User | null>(options.currentUser ?? null);
const store = {
selectSignal: (selector: unknown) => {
if (selector === selectSearchResults) {
return searchResultsSig;
}
if (selector === selectIsSearching) {
return signal(false);
}
if (selector === selectSavedRooms) {
return savedSig;
}
if (selector === selectAllUsers) {
return usersSig;
}
if (selector === selectCurrentUser) {
return currentUserSig;
}
return signal(null);
},
dispatch
} as unknown as Store;
const router = { navigate: vi.fn() } as unknown as Router;
const getFeaturedServers = vi.fn(() => of(options.featured ?? []));
const getTrendingServers = vi.fn(() => of(options.trending ?? []));
const serverDirectory = { getFeaturedServers, getTrendingServers } as unknown as ServerDirectoryFacade;
const friendIds = new Set<string>(options.friendIds ?? []);
const friendService = { friendIds: () => friendIds, friends: () => [] } as unknown as FriendService;
const injector = Injector.create({
providers: [
DashboardComponent,
{ provide: Store, useValue: store },
{ provide: Router, useValue: router },
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
{ provide: FriendService, useValue: friendService },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
]
});
const component = runInInjectionContext(injector, () => injector.get(DashboardComponent));
return {
component,
dispatch,
router,
getFeaturedServers,
getTrendingServers
};
}
describe('DashboardComponent', () => {
it('exposes the mobile viewport flag', () => {
expect(createHarness().component.isMobile()).toBe(false);
expect(createHarness({ isMobile: true }).component.isMobile()).toBe(true);
});
it('reports a new user when there are no servers or users', () => {
const { component } = createHarness();
expect(component.isNewUser()).toBe(true);
});
it('filters people by the active query', () => {
const { component } = createHarness({
users: [{ id: 'u1', displayName: 'Alice' } as unknown as User, { id: 'u2', displayName: 'Bob' } as unknown as User]
});
component.onSearchChange('ali');
expect(component.topPeopleResults().map((user) => user.id)).toEqual(['u1']);
});
it('limits server quick results', () => {
const { component } = createHarness({
searchResults: Array.from({ length: 8 }, (_, index) => makeServer(`s${index}`))
});
component.onSearchChange('s');
expect(component.topServerResults()).toHaveLength(5);
});
it('exposes an invite result for invite-like queries', () => {
const { component } = createHarness();
component.onSearchChange('https://app.test/invite/Code_42');
expect(component.inviteResult()).toBe('Code_42');
});
it('opens a joined server in place and routes others to the servers page', () => {
const joined = { id: 's1', name: 'Joined' } as unknown as Room;
const { component, dispatch, router } = createHarness({ saved: [joined] });
component.openServer(makeServer('s1', 'Joined'));
expect(dispatch).toHaveBeenCalledWith(RoomsActions.viewServer({ room: joined }));
component.openServer(makeServer('s2', 'Other'));
expect(router.navigate).toHaveBeenCalledWith(['/servers']);
});
it('navigates to the invite route when opening an invite', () => {
const { component, router } = createHarness();
component.onSearchChange('abc123');
component.openInvite();
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'abc123']);
});
it('suggests people you might know independent of the query, excluding self', () => {
const { component } = createHarness({
users: [{ id: 'u1', oderId: 'u1', displayName: 'Alice' } as unknown as User, { id: 'u2', oderId: 'u2', displayName: 'Bob' } as unknown as User],
currentUser: { id: 'u1', oderId: 'u1' } as unknown as User
});
expect(component.peopleYouMightKnow().map((user) => user.id)).toEqual(['u2']);
});
it('loads popular servers from featured results on init', () => {
const featured = [makeServer('f1'), makeServer('f2')];
const { component } = createHarness({ featured });
component.ngOnInit();
expect(component.popularServers().map((server) => server.id)).toEqual(['f1', 'f2']);
});
it('falls back to trending servers when featured is empty', () => {
const trending = [makeServer('t1')];
const { component } = createHarness({ featured: [], trending });
component.ngOnInit();
expect(component.popularServers().map((server) => server.id)).toEqual(['t1']);
});
it('limits recently active servers to the discovery cap', () => {
const saved = Array.from({ length: 9 }, (_, index) => ({ id: `r${index}`, name: `Room ${index}`, userCount: 1 }) as unknown as Room);
const { component } = createHarness({ saved });
expect(component.recentlyActiveServers()).toHaveLength(5);
});
it('excludes existing friends from people you might know and lists them under friends', () => {
const { component } = createHarness({
users: [
{ id: 'u1', oderId: 'u1', displayName: 'Alice' } as unknown as User,
{ id: 'u2', oderId: 'u2', displayName: 'Bob' } as unknown as User,
{ id: 'u3', oderId: 'u3', displayName: 'Cara' } as unknown as User
],
currentUser: { id: 'u1', oderId: 'u1' } as unknown as User,
friendIds: ['u2']
});
expect(component.peopleYouMightKnow().map((user) => user.id)).toEqual(['u3']);
expect(component.friends().map((user) => user.id)).toEqual(['u2']);
});
it('records, removes, and clears recent searches', () => {
const { component } = createHarness();
component.onSearchChange('gaming');
component.submitSearch();
component.onSearchChange('music');
component.submitSearch();
expect(component.recentSearches()).toEqual(['music', 'gaming']);
component.removeRecentSearch('gaming');
expect(component.recentSearches()).toEqual(['music']);
component.clearRecentSearches();
expect(component.recentSearches()).toEqual([]);
});
it('deduplicates recent searches and keeps the most recent first', () => {
const { component } = createHarness();
component.onSearchChange('gaming');
component.submitSearch();
component.onSearchChange('music');
component.submitSearch();
component.onSearchChange('gaming');
component.submitSearch();
expect(component.recentSearches()).toEqual(['gaming', 'music']);
});
});

View File

@@ -0,0 +1,342 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
ElementRef,
HostListener,
computed,
inject,
OnInit,
signal,
viewChild
} 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 {
debounceTime,
distinctUntilChanged,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideUsers,
lucideCompass,
lucidePlus,
lucideSearch,
lucideArrowRight,
lucideTicket,
lucideServer,
lucideX
} from '@ng-icons/lucide';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import {
selectSearchResults,
selectIsSearching,
selectSavedRooms
} from '../../store/rooms/rooms.selectors';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import type { Room, User } from '../../shared-kernel';
import type { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model';
import { ServerDirectoryFacade } from '../../domains/server-directory/application/facades/server-directory.facade';
import { ServersRailComponent } from '../servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../core/platform';
import { FriendService } from '../../domains/direct-message/application/services/friend.service';
import { FriendButtonComponent } from '../../domains/direct-message/feature/friend-button/friend-button.component';
import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component';
import { parseInviteQuery } from './invite-query.util';
/** Maximum quick-search rows shown per group on the dashboard. */
const QUICK_RESULT_LIMIT = 5;
/** Maximum entries shown in the discovery panels (people / popular / friends / recent servers). */
const DISCOVERY_LIMIT = 5;
/** Maximum remembered recent searches. */
const RECENT_SEARCH_LIMIT = 8;
/** localStorage key backing the recent-searches list. */
const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
/**
* Application landing page. Presents the three primary actions (find people, find
* servers, create a server), a global quick-search across people / servers / invites,
* discovery panels (people you might know, popular servers, recently active servers),
* and an onboarding state for brand-new accounts.
*/
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterLink,
NgIcon,
FriendButtonComponent,
UserAvatarComponent,
ServersRailComponent
],
viewProviders: [
provideIcons({
lucideUsers,
lucideCompass,
lucidePlus,
lucideSearch,
lucideArrowRight,
lucideTicket,
lucideServer,
lucideX
})
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
private serverDirectory = inject(ServerDirectoryFacade);
private friendsService = inject(FriendService);
private readonly viewport = inject(ViewportService);
private searchSubject = new Subject<string>();
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isMobile = this.viewport.isMobile;
searchQuery = signal('');
serverResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentUser = this.store.selectSignal(selectCurrentUser);
popularServers = signal<ServerInfo[]>([]);
recentSearches = signal<string[]>(this.loadRecentSearches());
private users = this.store.selectSignal(selectAllUsers);
/** True while the user is actively typing a query. */
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
/** Server matches limited for the quick-search list. */
topServerResults = computed(() => this.serverResults().slice(0, QUICK_RESULT_LIMIT));
/** Every distinct person known to the account (known users plus saved-room members), excluding self. */
private discoveredPeople = computed<User[]>(() => {
const currentKey = this.currentUserKey();
const byKey = new Map<string, User>();
for (const user of this.users()) {
byKey.set(user.oderId || user.id, user);
}
for (const room of this.savedRooms()) {
for (const member of room.members ?? []) {
const key = member.oderId || member.id;
if (byKey.has(key)) {
continue;
}
byKey.set(key, {
id: member.id,
oderId: key,
username: member.username,
displayName: member.displayName,
avatarUrl: member.avatarUrl,
status: 'disconnected'
} as User);
}
}
return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey);
});
/** People matches derived from known users and saved-room members. */
topPeopleResults = computed<User[]>(() => {
const query = this.searchQuery().trim()
.toLowerCase();
if (!query) {
return [];
}
return this.discoveredPeople()
.filter((user) => this.matchesQuery(user, query))
.slice(0, QUICK_RESULT_LIMIT);
});
/** Suggested people for the discovery panel (excludes self and existing friends). */
peopleYouMightKnow = computed<User[]>(() => {
const friendIds = this.friendsService.friendIds();
return this.discoveredPeople()
.filter((user) => !friendIds.has(user.oderId || user.id))
.slice(0, DISCOVERY_LIMIT);
});
/** People the user has added as friends. */
friends = computed<User[]>(() => {
const friendIds = this.friendsService.friendIds();
return this.discoveredPeople().filter((user) => friendIds.has(user.oderId || user.id));
});
/** Recently joined servers surfaced as horizontal cards. */
recentlyActiveServers = computed<Room[]>(() => this.savedRooms().slice(0, DISCOVERY_LIMIT));
/** Parsed invite when the query looks like an invite code or URL. */
inviteResult = computed(() => parseInviteQuery(this.searchQuery()));
/** True when quick-search yielded nothing across every group. */
hasNoQuickResults = computed(
() => this.topServerResults().length === 0 && this.topPeopleResults().length === 0 && !this.inviteResult()
);
/** True for a brand-new account with no servers and no known people. */
isNewUser = computed(() => this.savedRooms().length === 0 && this.users().length === 0);
ngOnInit(): void {
this.store.dispatch(RoomsActions.loadRooms());
this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => {
this.store.dispatch(RoomsActions.searchServers({ query }));
});
this.serverDirectory.getFeaturedServers(DISCOVERY_LIMIT).subscribe((servers) => {
if (servers.length > 0) {
this.popularServers.set(servers);
return;
}
this.serverDirectory.getTrendingServers(DISCOVERY_LIMIT).subscribe((trending) => this.popularServers.set(trending));
});
}
@HostListener('document:keydown', ['$event'])
onGlobalKeydown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.searchInputRef()?.nativeElement.focus();
}
}
onSearchChange(query: string): void {
this.searchQuery.set(query);
this.searchSubject.next(query);
}
submitSearch(): void {
this.rememberSearch(this.searchQuery());
}
applyRecentSearch(term: string): void {
this.onSearchChange(term);
this.rememberSearch(term);
this.searchInputRef()?.nativeElement.focus();
}
removeRecentSearch(term: string): void {
this.recentSearches.update((terms) => terms.filter((entry) => entry !== term));
this.persistRecentSearches();
}
clearRecentSearches(): void {
this.recentSearches.set([]);
this.persistRecentSearches();
}
openServer(server: ServerInfo): void {
const joined = this.savedRooms().find((room) => room.id === server.id);
if (joined) {
this.store.dispatch(RoomsActions.viewServer({ room: joined }));
return;
}
this.router.navigate(['/servers']);
}
openSavedRoom(room: Room): void {
this.store.dispatch(RoomsActions.viewServer({ room }));
}
openInvite(): void {
const invite = this.inviteResult();
if (invite) {
this.router.navigate(['/invite', invite]);
}
}
serverInitial(server: ServerInfo): string {
return server.name.trim()[0]?.toUpperCase() || '?';
}
serverMetaLabel(server: ServerInfo): string {
const members = `${server.userCount ?? 0} ${server.userCount === 1 ? 'member' : 'members'}`;
const detail = server.description?.trim();
return detail ? `${members}${detail}` : members;
}
personLabel(user: User): string {
return user.displayName || user.username || user.oderId || user.id;
}
isOnline(user: User): boolean {
return user.isOnline === true || [
'online',
'away',
'busy'
].includes(user.status);
}
private rememberSearch(rawTerm: string): void {
const term = rawTerm.trim();
if (!term) {
return;
}
this.recentSearches.update((terms) => [term, ...terms.filter((entry) => entry !== term)].slice(0, RECENT_SEARCH_LIMIT));
this.persistRecentSearches();
}
private loadRecentSearches(): string[] {
if (typeof localStorage === 'undefined') {
return [];
}
try {
const stored = localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY);
const parsed: unknown = stored ? JSON.parse(stored) : [];
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
} catch {
return [];
}
}
private persistRecentSearches(): void {
if (typeof localStorage === 'undefined') {
return;
}
try {
localStorage.setItem(RECENT_SEARCHES_STORAGE_KEY, JSON.stringify(this.recentSearches()));
} catch {
// Persistence is best-effort; ignore storage failures.
}
}
private currentUserKey(): string {
const currentUser = this.currentUser();
return currentUser ? currentUser.oderId || currentUser.id : '';
}
private matchesQuery(user: User, query: string): boolean {
return [
user.displayName,
user.username,
user.oderId
]
.filter((value): value is string => typeof value === 'string')
.some((value) => value.toLowerCase().includes(query));
}
}

View File

@@ -0,0 +1,31 @@
import {
describe,
it,
expect
} from 'vitest';
import { parseInviteQuery } from './invite-query.util';
describe('parseInviteQuery', () => {
it('returns null for empty or whitespace queries', () => {
expect(parseInviteQuery('')).toBeNull();
expect(parseInviteQuery(' ')).toBeNull();
});
it('returns null for short or space-containing queries', () => {
expect(parseInviteQuery('abc')).toBeNull();
expect(parseInviteQuery('hello world')).toBeNull();
});
it('treats a bare url-safe code as an invite', () => {
expect(parseInviteQuery('abc123')).toBe('abc123');
expect(parseInviteQuery('Team-Code_9')).toBe('Team-Code_9');
});
it('extracts the id from an invite path', () => {
expect(parseInviteQuery('/invite/xyz789')).toBe('xyz789');
});
it('extracts the id from a full invite URL', () => {
expect(parseInviteQuery('https://app.test/invite/Code_42?ref=1')).toBe('Code_42');
});
});

View File

@@ -0,0 +1,29 @@
/**
* Parses a dashboard search query into an invite identifier when it looks like an
* invite code or an invite URL. Returns `null` when the query is not invite-like.
*
* Accepted shapes:
* - A bare code: `abc123`, `Team-Code_9` (6+ url-safe chars, no whitespace)
* - A path containing `/invite/<id>`
* - A full URL whose path contains `/invite/<id>`
*/
export function parseInviteQuery(rawQuery: string): string | null {
const query = rawQuery.trim();
if (query.length === 0) {
return null;
}
const invitePathMatch = /\/invite\/([A-Za-z0-9_-]+)/.exec(query);
if (invitePathMatch) {
return invitePathMatch[1];
}
// A bare invite code: url-safe characters only, no whitespace, reasonably long.
if (/^[A-Za-z0-9_-]{6,}$/.test(query)) {
return query;
}
return null;
}

View File

@@ -1,15 +1,16 @@
<nav class="relative flex h-full min-w-14 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full">
<!-- Create button -->
<!-- Home / dashboard button -->
<button
appThemeNode="serversRailCreateButton"
type="button"
class="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90 md:h-10 md:w-10"
title="Create Server"
(click)="createServer()"
class="flex h-11 w-11 items-center justify-center overflow-hidden rounded-md bg-primary transition-colors hover:bg-primary/90 md:h-10 md:w-10"
title="Dashboard"
(click)="goToDashboard()"
>
<ng-icon
name="lucidePlus"
class="h-[22px] w-[22px] md:h-5 md:w-5"
<img
src="toju-icon.png"
alt="Toju"
class="h-full w-full object-contain p-1"
/>
</button>

View File

@@ -13,7 +13,7 @@ import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePlus } from '@ng-icons/lucide';
import { lucidePhone } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
@@ -61,7 +61,7 @@ const ACTIVATION_DEBOUNCE_MS = 150;
ThemeNodeDirective,
UserBarComponent
],
viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
viewProviders: [provideIcons({ lucidePhone })],
templateUrl: './servers-rail.component.html'
})
export class ServersRailComponent {
@@ -107,6 +107,13 @@ export class ServersRailComponent {
),
{ initialValue: this.isDirectMessageUrl(this.router.url) }
);
isOnDashboard = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dashboard'))
),
{ initialValue: this.router.url.startsWith('/dashboard') }
);
isOnCall = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -238,7 +245,7 @@ export class ServersRailComponent {
trackRoomId = (index: number, room: Room) => room.id;
createServer(): void {
goToDashboard(): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
this.optimisticSelectedRoomId.set(null);
@@ -247,7 +254,7 @@ export class ServersRailComponent {
this.voiceSession.setViewingVoiceServer(false);
}
this.router.navigate(['/search']);
this.router.navigate(['/dashboard']);
}
joinSavedRoom(room: Room): void {
@@ -407,7 +414,7 @@ export class ServersRailComponent {
}
isSelectedRoom(room: Room): boolean {
if (this.isOnDirectMessage() || this.isOnCall()) {
if (this.isOnDirectMessage() || this.isOnCall() || this.isOnDashboard()) {
return false;
}

View File

@@ -176,7 +176,7 @@ export class SettingsComponent implements OnInit {
}
openPluginStore(): 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: { returnUrl } });
}

View File

@@ -85,19 +85,6 @@
Login
</button>
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-foreground transition-colors hover:bg-secondary"
[class.hidden]="!isAuthed()"
(click)="openPluginStore()"
title="Plugin Store"
>
<ng-icon
name="lucidePackage"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (hasServerPlugins()) {
<button
type="button"
@@ -116,20 +103,6 @@
</button>
}
@if (isElectron()) {
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
title="Open Documentation"
(click)="openDocumentation()"
>
<ng-icon
name="lucideBookOpen"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
<div class="relative">
<button
type="button"

View File

@@ -14,7 +14,6 @@ import {
lucideSquare,
lucideX,
lucideChevronLeft,
lucideBookOpen,
lucideHash,
lucideMenu,
lucidePackage,
@@ -67,7 +66,6 @@ import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plu
lucideSquare,
lucideX,
lucideChevronLeft,
lucideBookOpen,
lucideHash,
lucideMenu,
lucidePackage,
@@ -119,7 +117,14 @@ export class TitleBarComponent {
),
{ initialValue: this.router.url.startsWith('/dm/') }
);
inRoom = computed(() => !!this.currentRoom() && !this.isInDirectMessage());
isInRoomView = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/room/'))
),
{ initialValue: this.router.url.startsWith('/room/') }
);
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
roomName = computed(() => this.currentRoom()?.name || '');
activeTextChannelName = computed(() => {
const textChannels = this.textChannels();
@@ -206,7 +211,7 @@ export class TitleBarComponent {
}
openPluginStore(): 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._showMenu.set(false);
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
@@ -330,7 +335,7 @@ export class TitleBarComponent {
nextOwnerKey: result.nextOwnerKey
}));
this.router.navigate(['/search']);
this.router.navigate(['/dashboard']);
}
/** Cancel the leave-server confirmation dialog. */

View File

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

View File

@@ -170,7 +170,7 @@ Join and leave broadcasts are also identity-aware: `handleJoinServer` only broad
### Multi-room presence
`server_users`, `user_joined`, and `user_left` are room-scoped presence messages, but the renderer must treat them as updates into a global multi-room presence view. The users store tracks `presenceServerIds` per user instead of clearing the whole slice when a new `server_users` snapshot arrives, so startup/search background rooms keep their server-rail voice badges and active voice peers do not disappear when the user views a different server.
`server_users`, `user_joined`, and `user_left` are room-scoped presence messages, but the renderer must treat them as updates into a global multi-room presence view. The users store tracks `presenceServerIds` per user instead of clearing the whole slice when a new `server_users` snapshot arrives, so startup/discovery background rooms keep their server-rail voice badges and active voice peers do not disappear when the user views a different server.
Peer routing also has to stay scoped to the signaling server that reported the membership. A `user_left` from one signaling cluster must only subtract that cluster's shared servers; otherwise a leave on `signal.toju.app` can incorrectly tear down a peer that is still shared through `signal-sweden.toju.app` or a local signaling server. Route metadata is therefore kept across peer recreation and only cleared once the renderer no longer shares any servers with that peer.