diff --git a/agents-docs/features/server-discovery.md b/agents-docs/features/server-discovery.md index e2db575..48cf87c 100644 --- a/agents-docs/features/server-discovery.md +++ b/agents-docs/features/server-discovery.md @@ -70,7 +70,7 @@ Both endpoints live in `server/src/routes/servers.ts` and **must be registered b - `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. +- 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`. A **Create a server** button (`lucidePlus`, `data-testid="server-rail-create"`) sits below the saved-server icons and opens `app-create-server-dialog` (a Toju modal on desktop / bottom sheet on mobile) which dispatches `RoomsActions.createRoom` directly; the dashboard / `/create-server` route remains as an alternative entry point. Rail icons (`h-12 w-12`, `md:h-11 w-11`) animate their corner radius on hover and `:active` for a Discord-style squircle effect. - 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 diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html index 3ce0954..afe8dbd 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html @@ -1,7 +1,7 @@ @if (lightboxAttachment()) {
+
@if (serverInstallDialog(); as dialog) { - +
+
+@if (isMobile()) { + + +
+ + +
+
+} @else { + + + + +
+
+

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-dialog/create-server-dialog.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.spec.ts new file mode 100644 index 0000000..c3b8d5e --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.spec.ts @@ -0,0 +1,142 @@ +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 { CreateServerDialogComponent } from './create-server-dialog.component'; +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; +import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; +import { ViewportService } from '../../../../core/platform'; + +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 } 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 viewport = { isMobile: () => false } as unknown as ViewportService; + const injector = Injector.create({ + providers: [ + CreateServerDialogComponent, + { provide: Store, useValue: store }, + { provide: Router, useValue: router }, + { provide: ServerDirectoryFacade, useValue: serverDirectory }, + { provide: ViewportService, useValue: viewport } + ] + }); + const component = runInInjectionContext(injector, () => injector.get(CreateServerDialogComponent)); + + return { component, dispatch, router }; +} + +describe('CreateServerDialogComponent', () => { + beforeEach(() => { + installLocalStorageMock(); + localStorage.setItem('metoyou_currentUserId', 'user-1'); + }); + + it('defaults the signal endpoint to the first active endpoint', () => { + const { component } = createHarness(); + + expect(component.sourceId()).toBe('ep-1'); + expect(component.canCreate).toBe(false); + }); + + it('dispatches createRoom and emits created with the form values', () => { + const { component, dispatch } = createHarness(); + const created = vi.fn(); + + component.created.subscribe(created); + + component.name.set('My Server'); + component.description.set('A place'); + component.topic.set('gaming'); + component.isPrivate.set(true); + component.password.set('secret'); + + component.create(); + + 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' + }); + + expect(created).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch or emit created when the name is blank', () => { + const { component, dispatch } = createHarness(); + const created = vi.fn(); + + component.created.subscribe(created); + + component.create(); + + expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false); + expect(created).not.toHaveBeenCalled(); + }); + + 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('emits cancelled when cancelled', () => { + const { component } = createHarness(); + const cancelled = vi.fn(); + + component.cancelled.subscribe(cancelled); + + component.cancel(); + + expect(cancelled).toHaveBeenCalledTimes(1); + }); + + it('redirects to login and does not dispatch when not logged in', () => { + const { component, dispatch, router } = createHarness(); + + localStorage.removeItem('metoyou_currentUserId'); + component.name.set('My Server'); + + component.create(); + + expect(router.navigate).toHaveBeenCalledWith(['/login']); + expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false); + }); +}); diff --git a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts new file mode 100644 index 0000000..51a252b --- /dev/null +++ b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/member-ordering */ +import { + Component, + HostListener, + inject, + output, + signal +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideChevronDown, lucideChevronUp } from '@ng-icons/lucide'; + +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; +import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; +import { ThemeNodeDirective } from '../../../theme'; +import { ViewportService } from '../../../../core/platform'; +import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared'; +import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component'; + +/** + * Modal presentation of the server-creation form. Mirrors the dedicated + * `/create-server` page but is rendered as a Toju modal (desktop) / bottom sheet + * (mobile) so it can be opened straight from the servers rail. Emits `created` + * once a room creation has been dispatched and `cancelled` when dismissed. + */ +@Component({ + selector: 'app-create-server-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NgIcon, + ThemeNodeDirective, + BottomSheetComponent, + ModalBackdropComponent + ], + viewProviders: [provideIcons({ lucideChevronDown, lucideChevronUp })], + templateUrl: './create-server-dialog.component.html', + host: { + style: 'display: contents;' + } +}) +export class CreateServerDialogComponent { + private store = inject(Store); + private router = inject(Router); + private serverDirectory = inject(ServerDirectoryFacade); + + readonly isMobile = inject(ViewportService).isMobile; + + readonly created = output(); + readonly cancelled = output(); + + readonly categories = CATEGORY_PRESETS; + activeEndpoints = this.serverDirectory.activeServers; + + name = signal(''); + description = signal(''); + topic = signal(''); + selectedCategoryId = signal(null); + isPrivate = signal(false); + password = signal(''); + sourceId = signal(''); + showAdvanced = signal(false); + + constructor() { + this.sourceId.set(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); + } + + @HostListener('document:keydown.escape') + cancel(): void { + this.cancelled.emit(undefined); + } + + create(): 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 + }) + ); + + this.created.emit(undefined); + } +} 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 index 421b1af..16f9af0 100644 --- 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 @@ -26,7 +26,7 @@ export interface ServerCategoryPreset { topic: string; } -const CATEGORY_PRESETS: ServerCategoryPreset[] = [ +export const CATEGORY_PRESETS: ServerCategoryPreset[] = [ { id: 'gaming', label: 'Gaming', topic: 'gaming' }, { id: 'music', label: 'Music', topic: 'music' }, { id: 'coding', label: 'Coding', topic: 'coding' }, 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 index e6e41b4..15738b2 100644 --- 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 @@ -290,10 +290,10 @@ } @if (pluginConsentDialog(); as dialog) { - +
@@ -82,7 +82,7 @@
} + + + + + +
+ +
} + +@if (showCreateDialog()) { + +} diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts index b851046..e23232b 100644 --- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts @@ -13,7 +13,7 @@ import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; import { NavigationEnd, Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { lucidePhone } from '@ng-icons/lucide'; +import { lucidePhone, lucidePlus } from '@ng-icons/lucide'; import { EMPTY, Subject, @@ -37,6 +37,7 @@ import { NotificationsFacade } from '../../../domains/notifications'; import { DirectCallService, DirectCallSession } from '../../../domains/direct-call'; import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component'; import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory'; +import { CreateServerDialogComponent } from '../../../domains/server-directory/feature/create-server-dialog/create-server-dialog.component'; import { ThemeNodeDirective } from '../../../domains/theme'; import { hasRoomBanForUser } from '../../../domains/access-control'; import { @@ -56,12 +57,13 @@ const ACTIVATION_DEBOUNCE_MS = 150; NgIcon, ConfirmDialogComponent, ContextMenuComponent, + CreateServerDialogComponent, DmRailComponent, LeaveServerDialogComponent, ThemeNodeDirective, UserBarComponent ], - viewProviders: [provideIcons({ lucidePhone })], + viewProviders: [provideIcons({ lucidePhone, lucidePlus })], templateUrl: './servers-rail.component.html' }) export class ServersRailComponent { @@ -90,6 +92,7 @@ export class ServersRailComponent { contextRoom = signal(null); optimisticSelectedRoomId = signal(null); showLeaveConfirm = signal(false); + showCreateDialog = signal(false); currentUser = this.store.selectSignal(selectCurrentUser); onlineUsers = this.store.selectSignal(selectOnlineUsers); bannedRoomLookup = signal>({}); @@ -257,6 +260,14 @@ export class ServersRailComponent { this.router.navigate(['/dashboard']); } + openCreateDialog(): void { + this.showCreateDialog.set(true); + } + + closeCreateDialog(): void { + this.showCreateDialog.set(false); + } + joinSavedRoom(room: Room): void { const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room; const currentUserId = localStorage.getItem('metoyou_currentUserId'); diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index 78a66b7..7de9b54 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -2,7 +2,7 @@ @if (isOpen() && !isThemeStudioFullscreen()) {