ddd test 2

This commit is contained in:
2026-03-23 00:23:56 +01:00
parent fe9c1dd1c0
commit 6dff163b88
128 changed files with 2513 additions and 676 deletions

View File

@@ -0,0 +1,176 @@
# Server Directory Domain
Manages the list of server endpoints the client can connect to, health-checking them, resolving API URLs, and providing server CRUD, search, invites, and moderation. This is the central domain that other domains (auth, chat, attachment) depend on for knowing where the backend is.
## Module map
```
server-directory/
├── application/
│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence
├── domain/
│ ├── server-directory.models.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types
│ ├── server-directory.constants.ts CLIENT_UPDATE_REQUIRED_MESSAGE
│ └── server-endpoint-defaults.ts Default endpoint templates, URL sanitisation, reconciliation helpers
├── infrastructure/
│ ├── server-directory-api.service.ts HTTP client for all server API calls
│ ├── server-endpoint-health.service.ts Health probe (GET /api/health with 5 s timeout, fallback to /api/servers)
│ ├── server-endpoint-compatibility.service.ts Semantic version comparison for client/server compatibility
│ └── server-endpoint-storage.service.ts localStorage read/write for endpoint list and removed-default tracking
├── feature/
│ ├── invite/ Invite creation and resolution UI
│ ├── server-search/ Server search/browse panel
│ └── settings/ Server endpoint management settings
└── index.ts Barrel exports
```
## Layer composition
The facade delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service.
```mermaid
graph TD
Facade[ServerDirectoryFacade]
State[ServerEndpointStateService]
API[ServerDirectoryApiService]
Health[ServerEndpointHealthService]
Compat[ServerEndpointCompatibilityService]
Storage[ServerEndpointStorageService]
Defaults[server-endpoint-defaults]
Models[server-directory.models]
Facade --> API
Facade --> State
Facade --> Health
Facade --> Compat
API --> State
State --> Storage
State --> Defaults
Health --> Compat
click Facade "application/server-directory.facade.ts" "High-level API"
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state"
click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API"
click Health "infrastructure/server-endpoint-health.service.ts" "Health probe"
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility"
click Storage "infrastructure/server-endpoint-storage.service.ts" "localStorage persistence"
click Defaults "domain/server-endpoint-defaults.ts" "Default endpoint templates"
click Models "domain/server-directory.models.ts" "Domain types"
```
## Endpoint lifecycle
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active.
```mermaid
stateDiagram-v2
[*] --> Load: constructor
Load --> HasStored: localStorage has endpoints
Load --> InitDefaults: no stored endpoints
InitDefaults --> Ready: save default endpoints
HasStored --> Reconcile: compare stored vs defaults
Reconcile --> Ready: merge, ensure active
Ready --> HealthCheck: facade.testAllServers()
state HealthCheck {
[*] --> Probing
Probing --> Online: /api/health 200 OK
Probing --> Incompatible: version mismatch
Probing --> Offline: request failed
}
```
## Health probing
The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which:
1. Sends `GET /api/health` with a 5-second timeout
2. On success, checks the response's `serverVersion` against the client version via `ServerEndpointCompatibilityService`
3. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
4. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
5. Updates the endpoint's status, latency, and version info in the state service
```mermaid
sequenceDiagram
participant Facade
participant Health as HealthService
participant Compat as CompatibilityService
participant API as Server
Facade->>Health: probeEndpoint(endpoint, clientVersion)
Health->>API: GET /api/health (5s timeout)
alt 200 OK
API-->>Health: { serverVersion }
Health->>Compat: evaluateServerVersion(serverVersion, clientVersion)
Compat-->>Health: { isCompatible, serverVersion }
Health-->>Facade: online / incompatible + latency + versions
else Request failed
Health->>API: GET /api/servers (fallback)
alt 200 OK
API-->>Health: servers list
Health-->>Facade: online + latency
else Also failed
Health-->>Facade: offline
end
end
Facade->>Facade: updateServerStatus(id, status, latency, versions)
```
## Server search
The facade's `searchServers(query)` method supports two modes controlled by a `searchAllServers` flag:
- **Single endpoint**: searches only the active server's API
- **All endpoints**: fans out the query to every online active endpoint via `forkJoin`, then deduplicates results by server ID
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
## Default endpoint management
Default servers are configured in the environment file. The state service builds `DefaultEndpointTemplate` objects from the configuration and uses them during reconciliation:
- Stored endpoints are matched to defaults by `defaultKey` or URL
- Missing defaults are added unless the user explicitly removed them (tracked in a separate localStorage key)
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
- The primary default URL is used as a fallback when no endpoint is resolved
URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol.
## Server administration
The facade provides methods for server registration, updates, and unregistration. These map directly to the API service's HTTP calls:
| Method | HTTP | Endpoint |
|---|---|---|
| `registerServer` | POST | `/api/servers` |
| `updateServer` | PUT | `/api/servers/:id` |
| `unregisterServer` | DELETE | `/api/servers/:id` |
## Invites and moderation
| Method | Purpose |
|---|---|
| `createInvite(serverId, request)` | Creates a time-limited invite link |
| `getInvite(inviteId)` | Resolves invite metadata |
| `requestServerAccess(request)` | Joins a server (via membership, password, invite, or public access) |
| `kickServerMember(serverId, request)` | Removes a user from the server |
| `banServerMember(serverId, request)` | Bans a user with optional reason and expiry |
| `unbanServerMember(serverId, request)` | Lifts a ban |
## Persistence
All endpoint state is persisted to localStorage under two keys:
| Key | Contents |
|---|---|
| `metoyou_server_endpoints` | Full `ServerEndpoint[]` array |
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.

View File

@@ -5,7 +5,7 @@ import {
} from '@angular/core';
import { Observable } from 'rxjs';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
import { ServerInfo, User } from '../../../core/models';
import { User } from '../../../shared-kernel';
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
import type {
@@ -14,6 +14,7 @@ import type {
KickServerMemberRequest,
ServerEndpoint,
ServerEndpointVersions,
ServerInfo,
ServerInviteInfo,
ServerJoinAccessRequest,
ServerJoinAccessResponse,

View File

@@ -1,7 +1,25 @@
import type { ServerInfo } from '../../../core/models';
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
export interface ServerInfo {
id: string;
name: string;
description?: string;
topic?: string;
hostName: string;
ownerId?: string;
ownerName?: string;
ownerPublicKey?: string;
userCount: number;
maxUsers: number;
hasPassword?: boolean;
isPrivate: boolean;
tags?: string[];
createdAt: number;
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface ConfiguredDefaultServerDefinition {
key?: string;
name?: string;

View File

@@ -0,0 +1,85 @@
<div class="min-h-full bg-background px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
<div
class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground"
>
Invite link
</div>
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
@if (invite()) {
Join {{ invite()!.server.name }}
} @else {
Toju server invite
}
</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
@switch (status()) {
@case ('redirecting') {
Sign in to continue with this invite.
}
@case ('joining') {
We are connecting you to the invited server.
}
@case ('error') {
This invite could not be completed automatically.
}
@default {
Loading invite details and preparing the correct signal server.
}
}
</p>
</div>
<div class="grid gap-6 px-6 py-8 sm:px-10 lg:grid-cols-[1.2fr,0.8fr]">
<section class="space-y-4">
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Status</h2>
<p class="mt-3 text-lg font-medium text-foreground">{{ message() }}</p>
</div>
@if (invite()) {
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Server</h2>
<p class="mt-3 text-xl font-semibold text-foreground">{{ invite()!.server.name }}</p>
@if (invite()!.server.description) {
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ invite()!.server.description }}</p>
}
<div class="mt-4 flex flex-wrap gap-2 text-xs">
@if (invite()!.server.isPrivate) {
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Private</span>
}
@if (invite()!.server.hasPassword) {
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
}
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary"> Expires {{ invite()!.expiresAt | date: 'medium' }} </span>
</div>
</div>
}
</section>
<aside class="space-y-4">
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">What happens next</h2>
<ul class="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
<li>• The linked signal server is added to your configured server list if needed.</li>
<li>• Invite links bypass private and password restrictions.</li>
<li>• Banned users still cannot join through invites.</li>
</ul>
</div>
@if (status() === 'error') {
<button
type="button"
(click)="goToSearch()"
class="inline-flex w-full items-center justify-center rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Back to server search
</button>
}
</aside>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,193 @@
import {
Component,
OnInit,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import type { ServerInviteInfo } from '../../domain/server-directory.models';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
import { User } from '../../../../shared-kernel';
@Component({
selector: 'app-invite',
standalone: true,
imports: [CommonModule],
templateUrl: './invite.component.html'
})
export class InviteComponent implements OnInit {
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly invite = signal<ServerInviteInfo | null>(null);
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
readonly message = signal('Loading invite…');
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly databaseService = inject(DatabaseService);
async ngOnInit(): Promise<void> {
const inviteContext = this.resolveInviteContext();
if (!inviteContext) {
return;
}
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) {
await this.redirectToLogin();
return;
}
try {
await this.joinInvite(inviteContext, currentUserId);
} catch (error: unknown) {
this.applyInviteError(error);
}
}
goToSearch(): void {
this.router.navigate(['/search']).catch(() => {});
}
private buildEndpointName(sourceUrl: string): string {
try {
const url = new URL(sourceUrl);
return url.hostname;
} catch {
return 'Signal Server';
}
}
private applyInviteError(error: unknown): void {
const inviteError = error as {
error?: { error?: string; errorCode?: string };
};
const errorCode = inviteError?.error?.errorCode;
const fallbackMessage = inviteError?.error?.error || 'Unable to accept this invite.';
this.status.set('error');
if (errorCode === 'BANNED') {
this.message.set('You are banned from this server and cannot accept this invite.');
return;
}
if (errorCode === 'INVITE_EXPIRED') {
this.message.set('This invite has expired. Ask for a fresh invite link.');
return;
}
this.message.set(fallbackMessage);
}
private async hydrateCurrentUser(): Promise<User | null> {
const currentUser = this.currentUser();
if (currentUser) {
return currentUser;
}
const storedUser = await this.databaseService.getCurrentUser();
if (storedUser) {
this.store.dispatch(UsersActions.setCurrentUser({ user: storedUser }));
}
return storedUser;
}
private async joinInvite(
context: { endpoint: { id: string; name: string }; inviteId: string; sourceUrl: string },
currentUserId: string
): Promise<void> {
const invite = await firstValueFrom(this.serverDirectory.getInvite(context.inviteId, {
sourceId: context.endpoint.id,
sourceUrl: context.sourceUrl
}));
this.invite.set(invite);
this.status.set('joining');
this.message.set(`Joining ${invite.server.name}`);
const currentUser = await this.hydrateCurrentUser();
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: invite.server.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
inviteId: context.inviteId
}, {
sourceId: context.endpoint.id,
sourceUrl: context.sourceUrl
}));
this.store.dispatch(
RoomsActions.joinRoom({
roomId: joinResponse.server.id,
serverInfo: {
...joinResponse.server,
sourceId: context.endpoint.id,
sourceName: context.endpoint.name,
sourceUrl: context.sourceUrl
}
})
);
}
private async redirectToLogin(): Promise<void> {
this.status.set('redirecting');
this.message.set('Redirecting to login…');
await this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
});
}
private resolveInviteContext(): {
endpoint: { id: string; name: string };
inviteId: string;
sourceUrl: string;
} | null {
const inviteId = this.route.snapshot.paramMap.get('inviteId')?.trim() || '';
const sourceUrl = this.route.snapshot.queryParamMap.get('server')?.trim() || '';
if (!inviteId || !sourceUrl) {
this.status.set('error');
this.message.set('This invite link is missing required server information.');
return null;
}
const endpoint = this.serverDirectory.ensureServerEndpoint({
name: this.buildEndpointName(sourceUrl),
url: sourceUrl
}, {
setActive: !localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)
});
return {
endpoint: {
id: endpoint.id,
name: endpoint.name
},
inviteId,
sourceUrl
};
}
}

View File

@@ -0,0 +1,369 @@
<div class="flex flex-col h-full">
<!-- My Servers -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
@if (savedRooms().length === 0) {
<p class="text-sm text-muted-foreground">No joined servers yet</p>
} @else {
<div class="flex flex-wrap gap-2">
@for (room of savedRooms(); track room.id) {
<button
(click)="joinSavedRoom(room)"
type="button"
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
>
{{ room.name }}
</button>
}
</div>
}
</div>
<!-- Search Header -->
<div class="p-4 border-b border-border">
<div class="flex items-center gap-2">
<div class="relative flex-1">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
/>
<input
type="text"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
placeholder="Search servers..."
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
(click)="openSettings()"
type="button"
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
title="Settings"
>
<ng-icon
name="lucideSettings"
class="w-5 h-5 text-muted-foreground"
/>
</button>
</div>
</div>
<!-- Create Server Button -->
<div class="p-4 border-b border-border">
<button
(click)="openCreateDialog()"
type="button"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<ng-icon
name="lucidePlus"
class="w-4 h-4"
/>
Create New Server
</button>
</div>
<!-- Search Results -->
<div class="flex-1 overflow-y-auto">
@if (isSearching()) {
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
} @else if (searchResults().length === 0) {
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ng-icon
name="lucideSearch"
class="w-12 h-12 mb-4 opacity-50"
/>
<p class="text-lg">No servers found</p>
<p class="text-sm">Try a different search or create your own</p>
</div>
} @else {
<div class="p-4 space-y-3">
@for (server of searchResults(); track server.id) {
<button
(click)="joinServer(server)"
type="button"
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
[class.border-border]="!isServerMarkedBanned(server)"
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
[class.border-destructive/40]="isServerMarkedBanned(server)"
[class.bg-destructive/5]="isServerMarkedBanned(server)"
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3
class="font-semibold transition-colors"
[class.text-foreground]="!isServerMarkedBanned(server)"
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
[class.text-destructive]="isServerMarkedBanned(server)"
>
{{ server.name }}
</h3>
@if (isServerMarkedBanned(server)) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-destructive"
/>
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
>Banned</span
>
} @else if (server.isPrivate) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>Private</span
>
} @else if (server.hasPassword) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>Password</span
>
} @else {
<ng-icon
name="lucideGlobe"
class="w-4 h-4 text-muted-foreground"
/>
}
</div>
@if (server.description) {
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
{{ server.description }}
</p>
}
@if (server.topic) {
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
{{ server.topic }}
</span>
}
</div>
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
<ng-icon
name="lucideUsers"
class="w-4 h-4"
/>
<span>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
</div>
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="text-muted-foreground">
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
</div>
<div class="text-muted-foreground">
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
</div>
<div class="text-muted-foreground">
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
</div>
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
}
</div>
</button>
}
</div>
}
</div>
@if (joinErrorMessage() || error()) {
<div class="p-4 bg-destructive/10 border-t border-destructive">
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
</div>
}
</div>
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"
confirmLabel="OK"
cancelLabel="Close"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="closeBannedDialog()"
(cancelled)="closeBannedDialog()"
>
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
</app-confirm-dialog>
}
@if (showPasswordDialog() && passwordPromptServer()) {
<app-confirm-dialog
title="Password required"
confirmLabel="Join server"
cancelLabel="Cancel"
[widthClass]="'w-[420px] max-w-[92vw]'"
(confirmed)="confirmPasswordJoin()"
(cancelled)="closePasswordDialog()"
>
<div class="space-y-3">
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
<div>
<label
for="join-server-password"
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Server password
</label>
<input
id="join-server-password"
type="password"
[(ngModel)]="joinPassword"
placeholder="Enter password"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@if (joinPasswordError()) {
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
}
</div>
</app-confirm-dialog>
}
<!-- Create Server Dialog -->
@if (showCreateDialog()) {
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
(click)="closeCreateDialog()"
(keydown.enter)="closeCreateDialog()"
(keydown.space)="closeCreateDialog()"
role="button"
tabindex="0"
aria-label="Close create server dialog"
>
<div
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
<div class="space-y-4">
<div>
<label
for="create-server-name"
class="block text-sm font-medium text-foreground mb-1"
>Server Name</label
>
<input
type="text"
[(ngModel)]="newServerName"
placeholder="My Awesome Server"
id="create-server-name"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="create-server-description"
class="block text-sm font-medium text-foreground mb-1"
>Description (optional)</label
>
<textarea
[(ngModel)]="newServerDescription"
placeholder="What's your server about?"
rows="3"
id="create-server-description"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
></textarea>
</div>
<div>
<label
for="create-server-topic"
class="block text-sm font-medium text-foreground mb-1"
>Topic (optional)</label
>
<input
type="text"
[(ngModel)]="newServerTopic"
placeholder="gaming, music, coding..."
id="create-server-topic"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="create-server-signal-endpoint"
class="block text-sm font-medium text-foreground mb-1"
>Signal Server Endpoint</label
>
<select
id="create-server-signal-endpoint"
[(ngModel)]="newServerSourceId"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (endpoint of activeEndpoints(); track endpoint.id) {
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
}
</select>
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this chat server.</p>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
[(ngModel)]="newServerPrivate"
id="private"
class="w-4 h-4 rounded border-border bg-secondary"
/>
<label
for="private"
class="text-sm text-foreground"
>Private server</label
>
</div>
<div>
<label
for="create-server-password"
class="block text-sm font-medium text-foreground mb-1"
>Password (optional)</label
>
<input
type="password"
[(ngModel)]="newServerPassword"
placeholder="Leave blank to allow joining without a password"
id="create-server-password"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
(click)="closeCreateDialog()"
type="button"
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Cancel
</button>
<button
(click)="createServer()"
[disabled]="!newServerName() || !newServerSourceId"
type="button"
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create
</button>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,355 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import {
debounceTime,
distinctUntilChanged,
firstValueFrom,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings
} from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import {
selectSearchResults,
selectIsSearching,
selectRoomsError,
selectSavedRooms
} from '../../../../store/rooms/rooms.selectors';
import {
Room,
User
} from '../../../../shared-kernel';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/server-directory.models';
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ConfirmDialogComponent } from '../../../../shared';
import { hasRoomBanForUser } from '../../../../core/helpers/room-ban.helpers';
@Component({
selector: 'app-server-search',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings
})
],
templateUrl: './server-search.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 {
private store = inject(Store);
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
searchQuery = '';
searchResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching);
error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentUser = this.store.selectSignal(selectCurrentUser);
activeEndpoints = this.serverDirectory.activeServers;
bannedServerLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptServer = signal<ServerInfo | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
joinErrorMessage = signal<string | null>(null);
// 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);
});
}
/** Initialize server search, load saved rooms, and set up debounced search. */
ngOnInit(): void {
// Initial load
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
this.store.dispatch(RoomsActions.loadRooms());
// Setup debounced search
this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe((query) => {
this.store.dispatch(RoomsActions.searchServers({ query }));
});
}
/** Emit a search query to the debounced search subject. */
onSearchChange(query: string): void {
this.searchSubject.next(query);
}
/** Join a server from the search results. Redirects to login if unauthenticated. */
async joinServer(server: ServerInfo): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
if (await this.isServerBanned(server)) {
this.bannedServerName.set(server.name);
this.showBannedDialog.set(true);
return;
}
await this.attemptJoinServer(server);
}
/** 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');
}
/** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void {
void this.joinServer(this.toServerInfo(room));
}
closeBannedDialog(): void {
this.showBannedDialog.set(false);
this.bannedServerName.set('');
}
closePasswordDialog(): void {
this.showPasswordDialog.set(false);
this.passwordPromptServer.set(null);
this.joinPassword.set('');
this.joinPasswordError.set(null);
}
async confirmPasswordJoin(): Promise<void> {
const server = this.passwordPromptServer();
if (!server)
return;
await this.attemptJoinServer(server, this.joinPassword());
}
isServerMarkedBanned(server: ServerInfo): boolean {
return !!this.bannedServerLookup()[server.id];
}
getServerUserCount(server: ServerInfo): number {
const candidate = server as ServerInfo & { currentUsers?: number };
if (typeof server.userCount === 'number')
return server.userCount;
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
}
getServerCapacityLabel(server: ServerInfo): string {
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
}
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,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
createdAt: room.createdAt,
ownerId: room.hostId,
sourceId: room.sourceId,
sourceName: room.sourceName,
sourceUrl: room.sourceUrl
};
}
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.joinErrorMessage.set(null);
this.joinPasswordError.set(null);
try {
const response = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: server.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
}, {
sourceId: server.sourceId,
sourceUrl: server.sourceUrl
}));
const resolvedServer = response.server ?? server;
this.closePasswordDialog();
this.store.dispatch(
RoomsActions.joinRoom({
roomId: resolvedServer.id,
serverInfo: resolvedServer
})
);
} catch (error: unknown) {
const serverError = error as {
error?: { error?: string; errorCode?: string };
};
const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
if (errorCode === 'PASSWORD_REQUIRED') {
this.passwordPromptServer.set(server);
this.showPasswordDialog.set(true);
this.joinPasswordError.set(message);
return;
}
if (errorCode === 'BANNED') {
this.bannedServerName.set(server.name);
this.showBannedDialog.set(true);
return;
}
this.joinErrorMessage.set(message);
}
}
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;
if (!currentUser || servers.length === 0) {
this.bannedServerLookup.set({});
return;
}
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const entries = await Promise.all(
servers.map(async (server) => {
const bans = await this.db.getBansForRoom(server.id);
const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId);
return [server.id, isBanned] as const;
})
);
if (requestVersion !== this.banLookupRequestVersion)
return;
this.bannedServerLookup.set(Object.fromEntries(entries));
}
private async isServerBanned(server: ServerInfo): Promise<boolean> {
const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !currentUserId)
return false;
const bans = await this.db.getBansForRoom(server.id);
return hasRoomBanForUser(bans, currentUser, currentUserId);
}
private resetCreateForm(): void {
this.newServerName.set('');
this.newServerDescription.set('');
this.newServerTopic.set('');
this.newServerPrivate.set(false);
this.newServerPassword.set('');
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
}
}

View File

@@ -8,13 +8,14 @@ import {
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerInfo, User } from '../../../core/models';
import { User } from '../../../shared-kernel';
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
import type {
BanServerMemberRequest,
CreateServerInviteRequest,
KickServerMemberRequest,
ServerEndpoint,
ServerInfo,
ServerInviteInfo,
ServerJoinAccessRequest,
ServerJoinAccessResponse,