Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

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,352 @@
/* 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 ?? '';
}
}