import { Component, effect, inject, Injector, Input, 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 { lucideExternalLink, lucideFileText, lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucideChevronDown } from '@ng-icons/lucide'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage'; import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules'; import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service'; import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives'; import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; import { Room, User, type PluginRequirementSummary } from '../../../../shared-kernel'; import { ExternalLinkService } from '../../../../core/platform'; 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, ModalBackdropComponent, type LeaveServerDialogResult } from '../../../../shared'; import { ChatMessageMarkdownComponent } from '../../../chat'; import { hasRoomBanForUser } from '../../../access-control'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { PluginRequirementService, PluginStoreService, type PluginStoreReadme } from '../../../plugins'; interface JoinPluginConsentDialog { optional: PluginRequirementSummary[]; password?: string; required: PluginRequirementSummary[]; server: ServerInfo; } /** A named group of servers rendered when the browser is not in active search mode. */ export interface ServerDiscoverySection { id: string; title: string; subtitle?: string; servers: ServerInfo[]; } /** * Reusable server discovery + join surface. Owns the full join flow (password prompt, * plugin-consent, banned, plugin readme) and the leave-server dialog, and renders both * live search results and any caller-supplied discovery sections with the same card UI. */ @Component({ selector: 'app-server-browser', standalone: true, imports: [ CommonModule, FormsModule, NgIcon, ChatMessageMarkdownComponent, ConfirmDialogComponent, LeaveServerDialogComponent, ModalBackdropComponent, AutoFocusDirective, SelectOnFocusDirective, ...APP_TRANSLATE_IMPORTS ], viewProviders: [ provideIcons({ lucideExternalLink, lucideFileText, lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucideChevronDown }) ], templateUrl: './server-browser.component.html' }) export class ServerBrowserComponent implements OnInit { /** Discovery sections shown when the search query is empty. */ @Input() discoverySections: ServerDiscoverySection[] = []; /** Title for the onboarding empty state when there is nothing to show. */ @Input() emptyStateTitle?: string; /** Supporting copy for the onboarding empty state. */ @Input() emptyStateMessage?: string; /** Placeholder for the search input. */ @Input() searchPlaceholder?: string; /** Whether the My Servers quick bar is shown. */ @Input() showMyServers = true; get resolvedEmptyStateTitle(): string { return this.emptyStateTitle ?? this.i18n.instant('servers.browser.empty.title'); } get resolvedEmptyStateMessage(): string { return this.emptyStateMessage ?? this.i18n.instant('servers.browser.empty.message'); } get resolvedSearchPlaceholder(): string { return this.searchPlaceholder ?? this.i18n.instant('servers.browser.search.placeholder'); } 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); 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); /** True while the user is actively searching (non-empty query). */ get isSearchMode(): boolean { return this.searchQuery.trim().length > 0; } /** Discovery sections that actually contain servers. */ get visibleSections(): ServerDiscoverySection[] { return this.discoverySections.filter((section) => section.servers.length > 0); } /** True when there is nothing to render outside of search mode. */ get showEmptyState(): boolean { return !this.isSearchMode && this.visibleSections.length === 0; } private store = inject(Store); private router = inject(Router); 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 injector = inject(Injector); private readonly i18n = inject(AppI18nService); private readonly signalServerAuthorize = inject(SignalServerAuthorizeService); private searchSubject = new Subject(); private banLookupRequestVersion = 0; serverCardTitle(server: ServerInfo): string { return this.isJoinedServer(server) ? this.i18n.instant('servers.browser.card.doubleClickOpen', { name: server.name }) : this.i18n.instant('servers.browser.card.doubleClickJoin', { name: server.name }); } serverActionsLabel(server: ServerInfo): string { return this.i18n.instant('servers.browser.card.serverActions', { name: server.name }); } joinServerLabel(server: ServerInfo): string { return this.i18n.instant('servers.browser.card.joinServer', { name: server.name }); } ownerLabel(server: ServerInfo): string { return this.i18n.instant('servers.browser.card.owner', { name: this.getServerOwnerLabel(server) }); } bannedDialogMessage(): string { return this.i18n.instant('servers.browser.bannedDialog.message', { name: this.bannedServerName() || this.i18n.instant('servers.browser.bannedDialog.thisServer') }); } passwordDialogMessage(server: ServerInfo): string { return this.i18n.instant('servers.browser.passwordDialog.message', { name: server.name }); } pluginUsesPluginsLabel(serverName: string): string { return this.i18n.instant('servers.plugins.usesPlugins', { name: serverName }); } pluginConsentConfirmLabel(requiredCount: number): string { if (this.pluginConsentBusy()) { return this.i18n.instant('servers.plugins.downloading'); } return requiredCount > 0 ? this.i18n.instant('servers.plugins.acceptAndJoin') : this.i18n.instant('servers.plugins.join'); } pluginReadmeButtonLabel(pluginId: string): string { return this.pluginConsentReadmeLoadingId() === pluginId ? this.i18n.instant('common.labels.loading') : this.i18n.instant('servers.plugins.readme'); } // The reactive effect is created in ngOnInit with an explicit injector so the // component can be instantiated outside a change-detection context (e.g. unit tests). ngOnInit(): void { effect( () => { const servers = this.searchResults(); const currentUser = this.currentUser(); void this.refreshBannedLookup(servers, currentUser ?? null); void this.requestMissingServerIcons(servers, currentUser ?? null); }, { injector: this.injector } ); this.store.dispatch(RoomsActions.searchServers({ query: '' })); this.store.dispatch(RoomsActions.loadRooms()); this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => { this.store.dispatch(RoomsActions.searchServers({ query })); }); } onSearchChange(query: string): void { this.searchSubject.next(query); } async joinServer(server: ServerInfo): Promise { const currentUser = this.currentUser(); const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id; if (!currentUserId) { this.router.navigate(['/login'], { queryParams: buildLoginReturnQueryParams(this.router.url) }); return; } setStoredCurrentUserId(currentUserId); if (await this.isServerBanned(server)) { this.bannedServerName.set(server.name); this.showBannedDialog.set(true); return; } await this.attemptJoinServer(server); } 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 : this.i18n.instant('servers.errors.installPluginsFailed')); } 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 : this.i18n.instant('servers.errors.loadPluginReadmeFailed')); } 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 || this.i18n.instant('servers.browser.ownerUnknown'); } private openJoinedRoom(room: Room): void { this.joinedServerMenuId.set(null); this.store.dispatch(RoomsActions.viewServer({ room })); } private async attemptJoinServer( server: ServerInfo, password?: string, options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {} ): Promise { const currentUser = this.currentUser(); const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id; if (!currentUserId) { this.router.navigate(['/login'], { queryParams: buildLoginReturnQueryParams(this.router.url) }); return; } setStoredCurrentUserId(currentUserId); 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; } } if (server.sourceUrl) { const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl); if (!hasCredential) { return; } } const response = await firstValueFrom( this.serverDirectory.requestJoin( { roomId: server.id, userId: currentUserId, userPublicKey: currentUser?.oderId || currentUserId, displayName: currentUser?.displayName || this.i18n.instant('common.labels.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 || this.i18n.instant('servers.errors.joinFailed'); 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 || this.i18n.instant('common.labels.user'), wsUrl, { description: currentUser.description, profileUpdatedAt: currentUser.profileUpdatedAt, homeSignalServerUrl: currentUser.homeSignalServerUrl }); 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); } }