diff --git a/agents-docs/FEATURES.md b/agents-docs/FEATURES.md index ea5f44a..c7529d9 100644 --- a/agents-docs/FEATURES.md +++ b/agents-docs/FEATURES.md @@ -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//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. diff --git a/agents-docs/features/server-discovery.md b/agents-docs/features/server-discovery.md new file mode 100644 index 0000000..e2db575 --- /dev/null +++ b/agents-docs/features/server-discovery.md @@ -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 `` 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 `` + `[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` diff --git a/e2e/pages/server-search.page.ts b/e2e/pages/server-search.page.ts index 9c1777a..0bbf8d1 100644 --- a/e2e/pages/server-search.page.ts +++ b/e2e/pages/server-search.page.ts @@ -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 . + 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(); diff --git a/e2e/tests/auth/user-session-data-isolation.spec.ts b/e2e/tests/auth/user-session-data-isolation.spec.ts index 1aa6424..98ee2bd 100644 --- a/e2e/tests/auth/user-session-data-isolation.spec.ts +++ b/e2e/tests/auth/user-session-data-isolation.spec.ts @@ -170,7 +170,7 @@ async function registerUser(page: Page, user: TestUser): Promise { 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 { @@ -178,7 +178,7 @@ async function loginUser(page: Page, user: TestUser): Promise { 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 { @@ -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 { - 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 { 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 { 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(navigate: () => Promise, attempts = 4): Promise { diff --git a/e2e/tests/chat/chat-message-features.spec.ts b/e2e/tests/chat/chat-message-features.spec.ts index 40ab2f6..25c0f47 100644 --- a/e2e/tests/chat/chat-message-features.spec.ts +++ b/e2e/tests/chat/chat-message-features.spec.ts @@ -249,7 +249,7 @@ async function createSingleClientChatScenario(createClient: () => Promise Promise): 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): 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); diff --git a/e2e/tests/chat/dm-flow.spec.ts b/e2e/tests/chat/dm-flow.spec.ts index 4c16fb6..f4636ae 100644 --- a/e2e/tests/chat/dm-flow.spec.ts +++ b/e2e/tests/chat/dm-flow.spec.ts @@ -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 { diff --git a/e2e/tests/chat/notifications.spec.ts b/e2e/tests/chat/notifications.spec.ts index d38ef10..d318912 100644 --- a/e2e/tests/chat/notifications.spec.ts +++ b/e2e/tests/chat/notifications.spec.ts @@ -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 { diff --git a/e2e/tests/chat/profile-avatar-sync.spec.ts b/e2e/tests/chat/profile-avatar-sync.spec.ts index c4e52c1..59c4198 100644 --- a/e2e/tests/chat/profile-avatar-sync.spec.ts +++ b/e2e/tests/chat/profile-avatar-sync.spec.ts @@ -380,7 +380,7 @@ async function registerUser(client: PersistentClient): Promise { 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 { diff --git a/e2e/tests/chat/server-icon-sync.spec.ts b/e2e/tests/chat/server-icon-sync.spec.ts index 752c010..5fc84de 100644 --- a/e2e/tests/chat/server-icon-sync.spec.ts +++ b/e2e/tests/chat/server-icon-sync.spec.ts @@ -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 { 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 { @@ -403,7 +403,7 @@ async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: s } async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { - 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 }); diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts index 8ffa0f3..d82bf54 100644 --- a/e2e/tests/plugins/plugin-api-two-users.spec.ts +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -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 { diff --git a/e2e/tests/plugins/plugin-manager-ui.spec.ts b/e2e/tests/plugins/plugin-manager-ui.spec.ts index 6e69e8b..a2433ec 100644 --- a/e2e/tests/plugins/plugin-manager-ui.spec.ts +++ b/e2e/tests/plugins/plugin-manager-ui.spec.ts @@ -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' }); diff --git a/e2e/tests/screen-share/screen-share.spec.ts b/e2e/tests/screen-share/screen-share.spec.ts index f36a24a..2df744d 100644 --- a/e2e/tests/screen-share/screen-share.spec.ts +++ b/e2e/tests/screen-share/screen-share.spec.ts @@ -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. */ diff --git a/e2e/tests/settings/connectivity-warning.spec.ts b/e2e/tests/settings/connectivity-warning.spec.ts index c93b69b..e61643a 100644 --- a/e2e/tests/settings/connectivity-warning.spec.ts +++ b/e2e/tests/settings/connectivity-warning.spec.ts @@ -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 ── diff --git a/e2e/tests/settings/ice-server-settings.spec.ts b/e2e/tests/settings/ice-server-settings.spec.ts index 97bc714..32da9b2 100644 --- a/e2e/tests/settings/ice-server-settings.spec.ts +++ b/e2e/tests/settings/ice-server-settings.spec.ts @@ -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(); diff --git a/e2e/tests/settings/stun-turn-fallback.spec.ts b/e2e/tests/settings/stun-turn-fallback.spec.ts index a1ee180..85e6be2 100644 --- a/e2e/tests/settings/stun-turn-fallback.spec.ts +++ b/e2e/tests/settings/stun-turn-fallback.spec.ts @@ -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 () => { diff --git a/e2e/tests/voice/data-channel-recovery.spec.ts b/e2e/tests/voice/data-channel-recovery.spec.ts index c310a43..4de306b 100644 --- a/e2e/tests/voice/data-channel-recovery.spec.ts +++ b/e2e/tests/voice/data-channel-recovery.spec.ts @@ -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 }); } }); diff --git a/e2e/tests/voice/direct-call.spec.ts b/e2e/tests/voice/direct-call.spec.ts index 43bfe02..96f5dc3 100644 --- a/e2e/tests/voice/direct-call.spec.ts +++ b/e2e/tests/voice/direct-call.spec.ts @@ -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 { 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 }); diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts index ef95f08..8addd1b 100644 --- a/e2e/tests/voice/mixed-signal-config-voice.spec.ts +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -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 { } async function openSearchView(page: Page): Promise { - 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 { - 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); diff --git a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts index 5f7dc95..e1e60f3 100644 --- a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts +++ b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts @@ -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 { } async function openSearchView(page: Page): Promise { - 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 { - 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); diff --git a/e2e/tests/voice/voice-full-journey.spec.ts b/e2e/tests/voice/voice-full-journey.spec.ts index d0b5bf6..8bc8db0 100644 --- a/e2e/tests/voice/voice-full-journey.spec.ts +++ b/e2e/tests/voice/voice-full-journey.spec.ts @@ -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 ─────────────────────────────── diff --git a/images/icon-new-transparent.png b/images/icon-new-transparent.png new file mode 100644 index 0000000..3931acf Binary files /dev/null and b/images/icon-new-transparent.png differ diff --git a/images/icon-new.png b/images/icon-new.png new file mode 100644 index 0000000..0e54f72 Binary files /dev/null and b/images/icon-new.png differ diff --git a/images/icon-transparent.png b/images/icon-transparent.png new file mode 100644 index 0000000..439a3f6 Binary files /dev/null and b/images/icon-transparent.png differ diff --git a/server/src/cqrs/index.ts b/server/src/cqrs/index.ts index 26bc7cd..6f83580 100644 --- a/server/src/cqrs/index.ts +++ b/server/src/cqrs/index.ts @@ -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()); diff --git a/server/src/cqrs/queries/handlers/getFeaturedServers.ts b/server/src/cqrs/queries/handlers/getFeaturedServers.ts new file mode 100644 index 0000000..aea7b1a --- /dev/null +++ b/server/src/cqrs/queries/handlers/getFeaturedServers.ts @@ -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))); +} diff --git a/server/src/cqrs/queries/handlers/getTrendingServers.ts b/server/src/cqrs/queries/handlers/getTrendingServers.ts new file mode 100644 index 0000000..af8ebac --- /dev/null +++ b/server/src/cqrs/queries/handlers/getTrendingServers.ts @@ -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))); +} diff --git a/server/src/cqrs/queries/handlers/server-ranking.util.spec.ts b/server/src/cqrs/queries/handlers/server-ranking.util.spec.ts new file mode 100644 index 0000000..01fb6e2 --- /dev/null +++ b/server/src/cqrs/queries/handlers/server-ranking.util.spec.ts @@ -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(); + 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']); + }); +}); diff --git a/server/src/cqrs/queries/handlers/server-ranking.util.ts b/server/src/cqrs/queries/handlers/server-ranking.util.ts new file mode 100644 index 0000000..94adea1 --- /dev/null +++ b/server/src/cqrs/queries/handlers/server-ranking.util.ts @@ -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, 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( + rows: readonly T[], + counts: Map, + 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( + rows: readonly T[], + counts: Map, + 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> { + const counts = new Map(); + + 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; +} diff --git a/server/src/cqrs/queries/index.ts b/server/src/cqrs/queries/index.ts index 66099bf..790403a 100644 --- a/server/src/cqrs/queries/index.ts +++ b/server/src/cqrs/queries/index.ts @@ -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 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) diff --git a/server/src/cqrs/types.ts b/server/src/cqrs/types.ts index e4f82ef..69ebcf5 100644 --- a/server/src/cqrs/types.ts +++ b/server/src/cqrs/types.ts @@ -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' diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index f44edf6..71e4e13 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -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, diff --git a/toju-app/public/toju-icon-bg.png b/toju-app/public/toju-icon-bg.png new file mode 100644 index 0000000..0e54f72 Binary files /dev/null and b/toju-app/public/toju-icon-bg.png differ diff --git a/toju-app/public/toju-icon.png b/toju-app/public/toju-icon.png new file mode 100644 index 0000000..3931acf Binary files /dev/null and b/toju-app/public/toju-icon.png differ diff --git a/toju-app/src/app/app.routes.ts b/toju-app/src/app/app.routes.ts index 43fe380..6a9c294 100644 --- a/toju-app/src/app/app.routes.ts +++ b/toju-app/src/app/app.routes.ts @@ -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 ) }, { diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index e0bbb9c..5d879f2 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -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(() => {}); } diff --git a/toju-app/src/app/domains/authentication/feature/login/login.component.ts b/toju-app/src/app/domains/authentication/feature/login/login.component.ts index a8c4644..c65abe8 100644 --- a/toju-app/src/app/domains/authentication/feature/login/login.component.ts +++ b/toju-app/src/app/domains/authentication/feature/login/login.component.ts @@ -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'); diff --git a/toju-app/src/app/domains/authentication/feature/register/register.component.ts b/toju-app/src/app/domains/authentication/feature/register/register.component.ts index 3761294..4df3b07 100644 --- a/toju-app/src/app/domains/authentication/feature/register/register.component.ts +++ b/toju-app/src/app/domains/authentication/feature/register/register.component.ts @@ -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'); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 7b85a2a..e2e2374 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -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: { diff --git a/toju-app/src/app/domains/direct-message/README.md b/toju-app/src/app/domains/direct-message/README.md index 22230a7..1778f65 100644 --- a/toju-app/src/app/domains/direct-message/README.md +++ b/toju-app/src/app/domains/direct-message/README.md @@ -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`. diff --git a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.html b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.html new file mode 100644 index 0000000..c5feb6b --- /dev/null +++ b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.html @@ -0,0 +1,83 @@ + +
+
+ + + +
+

Find people

+

Search for people you share servers with.

+
+
+ +
+
+ + +
+
+ + @if (hasDiscoverablePeople()) { + + } @else { +
+
+ +
+

No people to show yet

+

Join servers to discover people with shared interests.

+ + Find servers + +
+ } +
+
+ +@if (isMobile()) { + + +
+ +
+ +
+
+
+
+} @else { + +} diff --git a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.spec.ts b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.spec.ts new file mode 100644 index 0000000..40fc5f0 --- /dev/null +++ b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.spec.ts @@ -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(options.users ?? []); + const savedSig = signal(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'); + }); +}); diff --git a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts new file mode 100644 index 0000000..8919f8b --- /dev/null +++ b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts @@ -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); + } +} diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts index 80eb064..721c0ea 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts @@ -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 } }); diff --git a/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html index a9853d7..c836884 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html @@ -1,6 +1,6 @@
Back diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts index 92611a4..dafa6c5 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts @@ -601,7 +601,7 @@ export class PluginStoreComponent implements OnInit { return returnUrl; } - return '/search'; + return '/dashboard'; } private canManageServerPlugins(room: Room, user: User): boolean { diff --git a/toju-app/src/app/domains/server-directory/README.md b/toju-app/src/app/domains/server-directory/README.md index b7499aa..e91947a 100644 --- a/toju-app/src/app/domains/server-directory/README.md +++ b/toju-app/src/app/domains/server-directory/README.md @@ -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. diff --git a/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts index d8c981f..7bf8079 100644 --- a/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts +++ b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts @@ -137,6 +137,18 @@ export class ServerDirectoryFacade { return this.service.getServers(...args); } + getFeaturedServers( + ...args: Parameters + ): ReturnType { + return this.service.getFeaturedServers(...args); + } + + getTrendingServers( + ...args: Parameters + ): ReturnType { + return this.service.getTrendingServers(...args); + } + getServer( ...args: Parameters ): ReturnType { diff --git a/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts index 64b7d76..71398fa 100644 --- a/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts @@ -238,6 +238,14 @@ export class ServerDirectoryService { return this.api.getServers(this.shouldSearchAllServers); } + getFeaturedServers(limit?: number): Observable { + return this.api.getFeaturedServers(limit); + } + + getTrendingServers(limit?: number): Observable { + return this.api.getTrendingServers(limit); + } + getServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.api.getServer(serverId, selector); } diff --git a/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.html b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.html new file mode 100644 index 0000000..4b6b2f8 --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.html @@ -0,0 +1,180 @@ +
+
+ + + +
+

Create a server

+

Your server is where you and your community hang out.

+
+
+ +
+
+
+ Pick a category +
+ @for (category of categories; track category.id) { + + } +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (showAdvanced()) { +
+
+ + +
+ +
+ + +

This endpoint handles all signaling for this server.

+
+ +
+ + +
+ +
+ + +

Users who already joined keep access even if you change the password later.

+
+
+ } +
+ +
+ + +
+
+
+
diff --git a/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.spec.ts new file mode 100644 index 0000000..fce35ad --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.spec.ts @@ -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(); + + 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']); + }); +}); diff --git a/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts new file mode 100644 index 0000000..421b1af --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts @@ -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(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 + }) + ); + } +} diff --git a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.html b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.html new file mode 100644 index 0000000..3d7d976 --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.html @@ -0,0 +1,51 @@ + +
+
+ + + +
+

Find servers

+

Browse, search, and join communities.

+
+
+ + +
+
+ +@if (isMobile()) { + + +
+ +
+ +
+
+
+
+} @else { + +} diff --git a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.spec.ts new file mode 100644 index 0000000..241a4ff --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.spec.ts @@ -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(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); + }); +}); diff --git a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts new file mode 100644 index 0000000..d5a70b0 --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts @@ -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([]); + trending = signal([]); + savedRooms = this.store.selectSignal(selectSavedRooms); + + /** Discovery sections shown when the user is not actively searching. */ + discoverySections = computed(() => { + 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 + }; + } +} diff --git a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts index 32708ab..85bc455 100644 --- a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts @@ -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 { diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.html b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.html new file mode 100644 index 0000000..e6e41b4 --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.html @@ -0,0 +1,510 @@ + +
+
+
+ @if (server.icon) { + + } @else { + {{ server.name[0] || '?' }} + } +
+ +
+
+

+ {{ server.name }} +

+ + @if (isServerMarkedBanned(server)) { + + + Banned + + } @else if (server.isPrivate) { + + + Private + + } @else if (server.hasPassword) { + + + Password + + } @else { + + } +
+ + @if (server.description) { +

{{ server.description }}

+ } + +
+ + + {{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }} + + @if (server.topic) { + {{ server.topic }} + } + Owner: {{ getServerOwnerLabel(server) }} + {{ server.sourceName || server.hostName || 'Unknown' }} +
+
+ +
+ @if (isJoinedServer(server)) { +
+ Joined + +
+ + @if (joinedServerMenuId() === server.id) { +
+ +
+ } + } @else { + + } +
+
+
+
+ +
+
+
+ + +
+ + @if (showMyServers && savedRooms().length > 0) { +
+ My Servers + @for (room of savedRooms(); track room.id) { + + } +
+ } +
+ +
+ @if (isSearchMode) { +
+
+

Search results

+

{{ searchResults().length }} found

+
+
+ + @if (isSearching()) { +
+
+
+ } @else if (searchResults().length === 0) { +
+ +

No servers found

+
+ } @else { +
+ @for (server of searchResults(); track server.id) { + + } +
+ } + } @else if (showEmptyState) { +
+ +

{{ emptyStateTitle }}

+

{{ emptyStateMessage }}

+
+ } @else { +
+ @for (section of visibleSections; track section.id) { +
+
+

{{ section.title }}

+ @if (section.subtitle) { +

{{ section.subtitle }}

+ } +
+
+ @for (server of section.servers; track server.id) { + + } +
+
+ } +
+ } +
+ + @if (joinErrorMessage() || error()) { +
+

{{ joinErrorMessage() || error() }}

+
+ } +
+ +@if (leaveDialogRoom()) { + +} + +@if (showBannedDialog()) { + +

You are banned from {{ bannedServerName() || 'this server' }}.

+
+} + +@if (showPasswordDialog() && passwordPromptServer()) { + +
+

Enter the password to join {{ passwordPromptServer()!.name }}.

+ +
+ + +
+ + @if (joinPasswordError()) { +

{{ joinPasswordError() }}

+ } +
+
+} + +@if (pluginConsentDialog(); as dialog) { + + + + @if (pluginConsentReadme(); as readme) { + + + } +} diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts new file mode 100644 index 0000000..e8df658 --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts @@ -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; + 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([]); + const savedRoomsSig = signal([]); + const currentUserSig = signal(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())), + 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(); + + 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); + }); +}); diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts similarity index 77% rename from toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts rename to toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts index 5b6ee27..a343844 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts @@ -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(); 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>({}); @@ -151,43 +153,47 @@ export class ServerSearchComponent implements OnInit { pluginConsentReadmeLoadingId = signal(null); pluginConsentReadmeError = signal(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 { 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 ?? ''; - } } diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html deleted file mode 100644 index 5ac3792..0000000 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html +++ /dev/null @@ -1,745 +0,0 @@ -
-
- -
- - -

Search

- - @if (!currentUser()) { - - } @else { - - } -
- -
-
- - -
- - -
- - - -
-
- - @if (savedRooms().length > 0) { -
- My Servers - @for (room of savedRooms(); track room.id) { - - } -
- } -
- - -
- - -
- -
- - -
-
-
-

Servers

-

{{ searchResults().length }} found

-
-
- - @if (isSearching()) { -
-
-
- } @else if (searchResults().length === 0) { -
- -

No servers found

-
- } @else { -
- @for (server of searchResults(); track server.id) { -
-
-
- @if (server.icon) { - - } @else { - {{ server.name[0] || '?' }} - } -
- -
-
-

- {{ server.name }} -

- - @if (isServerMarkedBanned(server)) { - - - Banned - - } @else if (server.isPrivate) { - - - Private - - } @else if (server.hasPassword) { - - - Password - - } @else { - - } -
- - @if (server.description) { -

{{ server.description }}

- } - -
- - - {{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }} - - @if (server.topic) { - {{ server.topic }} - } - Owner: {{ getServerOwnerLabel(server) }} - {{ server.sourceName || server.hostName || 'Unknown' }} -
-
- -
- @if (isJoinedServer(server)) { -
- Joined - -
- - @if (joinedServerMenuId() === server.id) { -
- -
- } - } @else { - - } -
-
-
- } -
- } -
-
- - @if (joinErrorMessage() || error()) { -
-

{{ joinErrorMessage() || error() }}

-
- } -
- -@if (leaveDialogRoom()) { - -} - -@if (showBannedDialog()) { - -

You are banned from {{ bannedServerName() || 'this server' }}.

-
-} - -@if (showPasswordDialog() && passwordPromptServer()) { - -
-

Enter the password to join {{ passwordPromptServer()!.name }}.

- -
- - -
- - @if (joinPasswordError()) { -

{{ joinPasswordError() }}

- } -
-
-} - -@if (pluginConsentDialog(); as dialog) { - - - - @if (pluginConsentReadme(); as readme) { - - - } -} - - -@if (showCreateDialog()) { -
- -
-} diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.spec.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.spec.ts new file mode 100644 index 0000000..4c79cda --- /dev/null +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.spec.ts @@ -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([]); + }); +}); diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts index 66bb173..18b942b 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts @@ -103,6 +103,14 @@ export class ServerDirectoryApiService { ); } + getFeaturedServers(limit?: number): Observable { + return this.getDiscoveryServers('featured', limit); + } + + getTrendingServers(limit?: number): Observable { + return this.getDiscoveryServers('trending', limit); + } + getServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.http.get(`${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 { + 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); diff --git a/toju-app/src/app/features/dashboard/dashboard.component.html b/toju-app/src/app/features/dashboard/dashboard.component.html new file mode 100644 index 0000000..d03cf28 --- /dev/null +++ b/toju-app/src/app/features/dashboard/dashboard.component.html @@ -0,0 +1,428 @@ + +
+
+
+

+ @if (currentUser()) { + Welcome back, {{ currentUser()!.displayName || 'there' }} + } @else { + Welcome to MetoYou + } +

+

Find people, discover servers, or start your own community.

+
+ +
+
+ + + +
+ + @if (!isSearchMode() && recentSearches().length > 0) { +
+ Recent: + @for (term of recentSearches(); track term) { + + + + + } + +
+ } +
+ + @if (isSearchMode()) { +
+ @if (inviteResult(); as invite) { +
+

Invite

+ +
+ } + + @if (topServerResults().length > 0) { +
+
+

Servers

+ View all +
+
+ @for (server of topServerResults(); track server.id) { + + } +
+
+ } + + @if (topPeopleResults().length > 0) { +
+
+

People

+ View all +
+
+ @for (person of topPeopleResults(); track person.id) { + + +
+

{{ personLabel(person) }}

+

{{ isOnline(person) ? 'Online' : 'Offline' }}

+
+ +
+ } +
+
+ } + + @if (hasNoQuickResults() && !isSearching()) { +
+ No people, servers, or invites match + {{ searchQuery() }}. +
+ } +
+ } @else { + +
+ +
+ +
+
+

Find People

+

Connect with friends.

+
+ +
+ + +
+ +
+
+

Find Servers

+

Browse communities.

+
+ +
+ + +
+ +
+
+

Create Server

+

Start your own.

+
+ +
+
+ + @if (isNewUser()) { +
+
+ +
+

Get started

+

+ You have not joined any servers yet. Find a community to join, or create your own to invite friends. +

+
+ } + + +
+
+
+

People you might know

+ See all +
+ @if (peopleYouMightKnow().length > 0) { +
+ @for (person of peopleYouMightKnow(); track person.id) { +
+ +
+

{{ personLabel(person) }}

+

{{ isOnline(person) ? 'Online' : 'Offline' }}

+
+ +
+ } +
+ } @else { +

No people to suggest yet.

+ } +
+ +
+
+

Popular Servers

+ See all +
+ @if (popularServers().length > 0) { +
+ @for (server of popularServers(); track server.id) { +
+
+ @if (server.icon) { + + } @else { + {{ serverInitial(server) }} + } +
+
+

{{ server.name }}

+

{{ serverMetaLabel(server) }}

+
+ +
+ } +
+ } @else { +

No popular servers right now.

+ } +
+
+ + + @if (friends().length > 0) { +
+
+

Your Friends

+ Manage +
+
+ @for (friend of friends(); track friend.id) { +
+ +
+

{{ personLabel(friend) }}

+

{{ isOnline(friend) ? 'Online' : 'Offline' }}

+
+ +
+ } +
+
+ } + + + @if (recentlyActiveServers().length > 0) { +
+

Recently Active Servers

+
+ @for (room of recentlyActiveServers(); track room.id) { + + } +
+
+ } + } +
+
+
+ +@if (isMobile()) { + + +
+ +
+ +
+
+
+
+} @else { + +} diff --git a/toju-app/src/app/features/dashboard/dashboard.component.spec.ts b/toju-app/src/app/features/dashboard/dashboard.component.spec.ts new file mode 100644 index 0000000..57f7fff --- /dev/null +++ b/toju-app/src/app/features/dashboard/dashboard.component.spec.ts @@ -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(options.searchResults ?? []); + const savedSig = signal(options.saved ?? []); + const usersSig = signal(options.users ?? []); + const currentUserSig = signal(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(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']); + }); +}); diff --git a/toju-app/src/app/features/dashboard/dashboard.component.ts b/toju-app/src/app/features/dashboard/dashboard.component.ts new file mode 100644 index 0000000..130bad8 --- /dev/null +++ b/toju-app/src/app/features/dashboard/dashboard.component.ts @@ -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(); + private readonly searchInputRef = viewChild>('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([]); + recentSearches = signal(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(() => { + const currentKey = this.currentUserKey(); + const byKey = new Map(); + + 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(() => { + 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(() => { + 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(() => { + 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(() => 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)); + } +} diff --git a/toju-app/src/app/features/dashboard/invite-query.util.spec.ts b/toju-app/src/app/features/dashboard/invite-query.util.spec.ts new file mode 100644 index 0000000..e398c02 --- /dev/null +++ b/toju-app/src/app/features/dashboard/invite-query.util.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/features/dashboard/invite-query.util.ts b/toju-app/src/app/features/dashboard/invite-query.util.ts new file mode 100644 index 0000000..fbb27c2 --- /dev/null +++ b/toju-app/src/app/features/dashboard/invite-query.util.ts @@ -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/` + * - A full URL whose path contains `/invite/` + */ +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; +} diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html index 2586665..7eade2a 100644 --- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html @@ -1,15 +1,16 @@