Files
Toju/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts
Myx 232a9ea8ea
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 7m20s
Queue Release Build / build-windows (push) Successful in 25m4s
Queue Release Build / build-linux (push) Successful in 33m59s
Queue Release Build / finalize (push) Successful in 41s
test: Ensure tests work after latest changes
2026-05-19 00:52:28 +02:00

727 lines
22 KiB
TypeScript

/* 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<string>();
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<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);
joinedServerMenuId = signal<string | null>(null);
leaveDialogRoom = signal<Room | null>(null);
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
selectedOptionalPluginIds = signal<Set<string>>(new Set());
pluginConsentBusy = signal(false);
pluginConsentError = signal<string | null>(null);
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
pluginConsentReadmeLoadingId = signal<string | null>(null);
pluginConsentReadmeError = 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);
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<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');
}
/** 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<void> {
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<void> {
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<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) : '∞';
}
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<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 {
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<JoinPluginConsentDialog | null> {
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<void> {
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<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 ?? '';
}
}