/* 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 { lucideArrowLeft, lucideExternalLink, lucideFileText, lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown, lucideLogIn } from '@ng-icons/lucide'; import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { Room, User, type PluginRequirementSummary } from '../../../../shared-kernel'; import { ExternalLinkService, ViewportService } from '../../../../core/platform'; import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { type ServerInfo } from '../../domain/models/server-directory.model'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { ConfirmDialogComponent, LeaveServerDialogComponent, type LeaveServerDialogResult } from '../../../../shared'; import { ChatMessageMarkdownComponent } from '../../../chat'; import { hasRoomBanForUser } from '../../../access-control'; import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { PluginRequirementService, PluginStoreService, type PluginStoreReadme } from '../../../plugins'; interface JoinPluginConsentDialog { optional: PluginRequirementSummary[]; password?: string; required: PluginRequirementSummary[]; server: ServerInfo; } @Component({ selector: 'app-server-search', standalone: true, imports: [ CommonModule, FormsModule, NgIcon, ChatMessageMarkdownComponent, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent ], viewProviders: [ provideIcons({ lucideArrowLeft, lucideExternalLink, lucideFileText, lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown, lucideLogIn }) ], 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 externalLinks = inject(ExternalLinkService); private serverDirectory = inject(ServerDirectoryFacade); private webrtc = inject(RealtimeSessionFacade); private pluginRequirements = inject(PluginRequirementService); private pluginStore = inject(PluginStoreService); private viewport = inject(ViewportService); private searchSubject = new Subject(); private banLookupRequestVersion = 0; /** True on mobile breakpoints. Drives the tabbed mobile layout. */ readonly isMobile = this.viewport.isMobile; /** Active mobile tab. Ignored on desktop where both panes are visible side-by-side. */ readonly mobileTab = signal<'people' | 'servers'>('servers'); searchQuery = ''; searchResults = this.store.selectSignal(selectSearchResults); isSearching = this.store.selectSignal(selectIsSearching); error = this.store.selectSignal(selectRoomsError); savedRooms = this.store.selectSignal(selectSavedRooms); currentRoom = this.store.selectSignal(selectCurrentRoom); currentUser = this.store.selectSignal(selectCurrentUser); activeEndpoints = this.serverDirectory.activeServers; bannedServerLookup = signal>({}); bannedServerName = signal(''); showBannedDialog = signal(false); showPasswordDialog = signal(false); passwordPromptServer = signal(null); joinPassword = signal(''); joinPasswordError = signal(null); joinErrorMessage = signal(null); joinedServerMenuId = signal(null); leaveDialogRoom = signal(null); pluginConsentDialog = signal(null); selectedOptionalPluginIds = signal>(new Set()); pluginConsentBusy = signal(false); pluginConsentError = signal(null); pluginConsentReadme = signal(null); pluginConsentReadmeLoadingId = signal(null); pluginConsentReadmeError = signal(null); // Create dialog state showCreateDialog = signal(false); newServerName = signal(''); newServerDescription = signal(''); newServerTopic = signal(''); newServerPrivate = signal(false); newServerPassword = signal(''); newServerSourceId = ''; constructor() { effect(() => { const servers = this.searchResults(); const currentUser = this.currentUser(); void this.refreshBannedLookup(servers, currentUser ?? null); void this.requestMissingServerIcons(servers, currentUser ?? null); }); } /** Initialize server search, load saved rooms, and set up debounced search. */ ngOnInit(): void { // Initial load this.store.dispatch(RoomsActions.searchServers({ query: '' })); this.store.dispatch(RoomsActions.loadRooms()); // Setup debounced search this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => { this.store.dispatch(RoomsActions.searchServers({ query })); }); } /** Emit a search query to the debounced search subject. */ 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'); } /** Navigate to the login screen, preserving the search route as the return URL. */ goLogin(): void { this.router.navigate(['/login'], { queryParams: { returnUrl: '/search' } }); } /** * Navigate back from the Search page to the chat-room view (server rail + current server). * Prefers the current room; falls back to the first saved room. No-op when the user has not * joined any servers. */ goBack(): void { const target = this.currentRoom() ?? this.savedRooms()[0] ?? null; if (target) { this.store.dispatch(RoomsActions.viewServer({ room: target })); } } /** True when the back button has a destination (user is in or has joined at least one server). */ canGoBack(): boolean { return !!this.currentRoom() || this.savedRooms().length > 0; } /** Join a previously saved room by converting it to a ServerInfo payload. */ joinSavedRoom(room: Room): void { this.openJoinedRoom(room); } openServerCard(server: ServerInfo): void { const joinedRoom = this.joinedRoomForServer(server); if (joinedRoom) { this.openJoinedRoom(joinedRoom); return; } void this.joinServer(server); } joinedRoomForServer(server: ServerInfo): Room | null { return this.savedRooms().find((room) => room.id === server.id) ?? null; } isJoinedServer(server: ServerInfo): boolean { return !!this.joinedRoomForServer(server); } toggleJoinedServerMenu(event: Event, server: ServerInfo): void { event.stopPropagation(); this.joinedServerMenuId.update((currentId) => (currentId === server.id ? null : server.id)); } closeJoinedServerMenu(): void { this.joinedServerMenuId.set(null); } openLeaveDialog(event: Event, server: ServerInfo): void { event.stopPropagation(); const room = this.joinedRoomForServer(server); if (!room) { return; } this.joinedServerMenuId.set(null); this.leaveDialogRoom.set(room); } closeLeaveDialog(): void { this.leaveDialogRoom.set(null); } confirmLeaveServer(result: LeaveServerDialogResult): void { const room = this.leaveDialogRoom(); if (!room) { return; } this.store.dispatch( RoomsActions.forgetRoom({ roomId: room.id, nextOwnerKey: result.nextOwnerKey }) ); this.leaveDialogRoom.set(null); } 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); } closePluginConsentDialog(): void { if (this.pluginConsentBusy()) { return; } this.pluginConsentDialog.set(null); this.selectedOptionalPluginIds.set(new Set()); this.pluginConsentError.set(null); this.closePluginConsentReadme(); } toggleOptionalPluginInstall(pluginId: string, checked: boolean): void { this.selectedOptionalPluginIds.update((selectedIds) => { const nextIds = new Set(selectedIds); if (checked) { nextIds.add(pluginId); } else { nextIds.delete(pluginId); } return nextIds; }); } async confirmPluginConsent(): Promise { const dialog = this.pluginConsentDialog(); if (!dialog) { return; } const selectedOptionalIds = this.selectedOptionalPluginIds(); const acceptedRequirements = dialog.required.concat( dialog.optional.filter((requirement) => selectedOptionalIds.has(requirement.pluginId)) ); this.pluginConsentBusy.set(true); this.pluginConsentError.set(null); try { await this.attemptJoinServer(dialog.server, dialog.password, { acceptedRequirements, skipPluginConsent: true }); this.pluginConsentDialog.set(null); this.selectedOptionalPluginIds.set(new Set()); this.closePluginConsentReadme(); } catch (error) { this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins'); } finally { this.pluginConsentBusy.set(false); } } async openPluginConsentReadme(requirement: PluginRequirementSummary): Promise { this.pluginConsentReadmeError.set(null); this.pluginConsentReadmeLoadingId.set(requirement.pluginId); try { const readme = await this.pluginStore.loadRequirementReadme(requirement); this.pluginConsentReadme.set(readme); } catch (error) { this.pluginConsentReadmeError.set(error instanceof Error ? error.message : 'Unable to load plugin readme'); } finally { this.pluginConsentReadmeLoadingId.set(null); } } closePluginConsentReadme(): void { this.pluginConsentReadme.set(null); this.pluginConsentReadmeError.set(null); this.pluginConsentReadmeLoadingId.set(null); } openPluginSource(requirement: PluginRequirementSummary): void { const sourceUrl = this.getPluginSourceUrl(requirement); if (sourceUrl) { this.externalLinks.open(sourceUrl); } } getPluginSourceUrl(requirement: PluginRequirementSummary): string | null { const candidate = requirement.manifest?.homepage ?? requirement.sourceUrl ?? requirement.installUrl ?? requirement.manifest?.bugs ?? null; return candidate?.startsWith('http://') || candidate?.startsWith('https://') ? candidate : null; } hasPluginReadme(requirement: PluginRequirementSummary): boolean { return !!requirement.manifest?.readme; } 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) : '∞'; } getServerOwnerLabel(server: ServerInfo): string { const joinedRoom = this.joinedRoomForServer(server); const ownerKey = server.ownerId || joinedRoom?.hostId || ''; const ownerMember = joinedRoom?.members?.find((member) => member.id === ownerKey || member.oderId === ownerKey); return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner'; } private openJoinedRoom(room: Room): void { this.joinedServerMenuId.set(null); this.store.dispatch(RoomsActions.viewServer({ room })); } private toServerInfo(room: Room): ServerInfo { return { id: room.id, name: room.name, description: room.description, hostName: room.hostId || 'Unknown', userCount: room.userCount ?? 0, maxUsers: room.maxUsers ?? 50, icon: room.icon, iconUpdatedAt: room.iconUpdatedAt, hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password, isPrivate: room.isPrivate, channels: room.channels, createdAt: room.createdAt, ownerId: room.hostId, sourceId: room.sourceId, sourceName: room.sourceName, sourceUrl: room.sourceUrl }; } private async attemptJoinServer( server: ServerInfo, password?: string, options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {} ): 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 { if (options.skipPluginConsent !== true) { const consentDialog = await this.buildPluginConsentDialog(server, password); if (consentDialog) { this.pluginConsentDialog.set(consentDialog); this.selectedOptionalPluginIds.set(new Set(consentDialog.optional.map((requirement) => requirement.pluginId))); return; } } 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 resolvedSource = this.serverDirectory.normaliseRoomSignalSource( { sourceId: response.server.sourceId ?? server.sourceId, sourceName: response.server.sourceName ?? server.sourceName, sourceUrl: response.server.sourceUrl ?? server.sourceUrl, signalingUrl: response.signalingUrl, fallbackName: response.server.sourceName ?? server.sourceName ?? server.name }, { ensureEndpoint: true } ); const resolvedServer = { ...server, ...response.server, channels: Array.isArray(response.server.channels) && response.server.channels.length > 0 ? response.server.channels : server.channels, ...resolvedSource, signalingUrl: response.signalingUrl }; this.closePasswordDialog(); if (options.acceptedRequirements?.length) { await this.pluginStore.installServerRequirementsLocally(resolvedServer.id, options.acceptedRequirements, { activate: true }); } 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); if (options.skipPluginConsent) { throw new Error(message); } } } private async buildPluginConsentDialog(server: ServerInfo, password?: string): Promise { const apiBaseUrl = this.serverDirectory.getApiBaseUrl({ sourceId: server.sourceId, sourceUrl: server.sourceUrl }); const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(apiBaseUrl, server.id)); const installedPluginIds = await this.pluginStore.getLocalServerInstalledPluginIds(server.id); const installableRequirements = snapshot.requirements .filter((requirement) => !installedPluginIds.has(requirement.pluginId)) .filter((requirement) => !!requirement.manifest || !!requirement.installUrl); const required = installableRequirements.filter((requirement) => requirement.status === 'required'); const optional = installableRequirements.filter( (requirement) => requirement.status === 'optional' || requirement.status === 'recommended' ); if (required.length === 0 && optional.length === 0) { return null; } return { optional, password, required, server }; } private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise { if (!currentUser) { return; } for (const server of servers) { if (server.icon) { continue; } const selector = this.serverDirectory.buildRoomSignalSelector( { sourceId: server.sourceId, sourceName: server.sourceName, sourceUrl: server.sourceUrl, fallbackName: server.sourceName ?? server.name }, { ensureEndpoint: !!server.sourceUrl } ); if (!selector) { continue; } const wsUrl = this.serverDirectory.getWebSocketUrl(selector); try { await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl)); this.webrtc.identify(currentUser.oderId || currentUser.id, currentUser.displayName || 'User', wsUrl, { description: currentUser.description, profileUpdatedAt: currentUser.profileUpdatedAt }); this.webrtc.sendRawMessageToSignalUrl(wsUrl, { type: 'server_icon_sync_request', serverId: server.id, iconUpdatedAt: 0 }); } catch { /* discovery icons are best-effort */ } } } 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 ?? ''; } }