/* 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, ServerInfo, User } from '../../core/models/index'; import { SettingsModalService } from '../../core/services/settings-modal.service'; import { DatabaseService } from '../../core/services/database.service'; import { ServerDirectoryService } from '../../core/services/server-directory.service'; 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(ServerDirectoryService); private searchSubject = new Subject(); 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>({}); bannedServerName = signal(''); showBannedDialog = signal(false); showPasswordDialog = signal(false); passwordPromptServer = signal(null); joinPassword = signal(''); joinPasswordError = signal(null); joinErrorMessage = 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); }); } /** 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 { 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 { 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 { 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 { 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 { 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 ?? ''; } }