/* eslint-disable @typescript-eslint/member-ordering */ import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; import { NavigationEnd, Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePhone, lucidePlus } from '@ng-icons/lucide'; import { EMPTY, Subject, catchError, filter, firstValueFrom, from, map, switchMap, tap } from 'rxjs'; import { Room, User } from '../../../shared-kernel'; import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component'; import { VoiceSessionFacade } from '../../../domains/voice-session'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; import { RoomsActions } from '../../../store/rooms/rooms.actions'; import { DatabaseService } from '../../../infrastructure/persistence'; import { NotificationsFacade } from '../../../domains/notifications'; import { DirectCallService, DirectCallSession } from '../../../domains/direct-call'; import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component'; import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory'; import { ThemeNodeDirective } from '../../../domains/theme'; import { hasRoomBanForUser } from '../../../domains/access-control'; import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared'; @Component({ selector: 'app-servers-rail', standalone: true, imports: [ CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, ContextMenuComponent, DmRailComponent, LeaveServerDialogComponent, ThemeNodeDirective, UserBarComponent ], viewProviders: [provideIcons({ lucidePhone, lucidePlus })], templateUrl: './servers-rail.component.html' }) export class ServersRailComponent { private store = inject(Store); private router = inject(Router); private voiceSession = inject(VoiceSessionFacade); private db = inject(DatabaseService); private notifications = inject(NotificationsFacade); readonly directCalls = inject(DirectCallService); private serverDirectory = inject(ServerDirectoryFacade); private destroyRef = inject(DestroyRef); private banLookupRequestVersion = 0; private visibleSavedRoomCache: Room[] = []; private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>(); savedRooms = this.store.selectSignal(selectSavedRooms); currentRoom = this.store.selectSignal(selectCurrentRoom); showMenu = signal(false); menuX = signal(72); menuY = signal(100); contextRoom = signal(null); optimisticSelectedRoomId = signal(null); showLeaveConfirm = signal(false); currentUser = this.store.selectSignal(selectCurrentUser); onlineUsers = this.store.selectSignal(selectOnlineUsers); bannedRoomLookup = signal>({}); isOnSearch = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search')) ), { initialValue: this.router.url.startsWith('/search') } ); isOnDirectMessage = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), map((navigationEvent) => this.isDirectMessageUrl(navigationEvent.urlAfterRedirects)) ), { initialValue: this.isDirectMessageUrl(this.router.url) } ); isOnCall = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/call/')) ), { initialValue: this.router.url.startsWith('/call/') } ); currentCallId = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), map((navigationEvent) => this.callIdFromUrl(navigationEvent.urlAfterRedirects)) ), { initialValue: this.callIdFromUrl(this.router.url) } ); selectedCallIndex = computed(() => { const routeCallId = this.currentCallId(); const visibleCalls = this.directCalls.visibleActiveSessions(); if (routeCallId) { const routeMatchIndex = visibleCalls.findIndex((call) => call.callId === routeCallId || call.conversationId === routeCallId); if (routeMatchIndex >= 0) { return routeMatchIndex; } } const currentSession = this.directCalls.currentSession(); if (!currentSession) { return -1; } return visibleCalls.findIndex((call) => call.callId === currentSession.callId); }); bannedServerName = signal(''); showBannedDialog = signal(false); showPasswordDialog = signal(false); passwordPromptRoom = signal(null); joinPassword = signal(''); joinPasswordError = signal(null); visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)))); voicePresenceByRoom = computed(() => { const presence: Record = {}; const seenByRoom = new Map>(); const addVoicePresence = (user: User | null | undefined): void => { if (!user) { return; } const voiceState = user?.voiceState; const roomId = voiceState?.serverId; if (!voiceState?.isConnected || !roomId) { return; } const userKey = user.oderId || user.id; let seenUsers = seenByRoom.get(roomId); if (!seenUsers) { seenUsers = new Set(); seenByRoom.set(roomId, seenUsers); } if (seenUsers.has(userKey)) { return; } seenUsers.add(userKey); presence[roomId] = (presence[roomId] ?? 0) + 1; }; for (const user of this.onlineUsers()) { addVoicePresence(user); } addVoicePresence(this.currentUser()); return presence; }); constructor() { effect(() => { const rooms = this.savedRooms(); const currentUser = this.currentUser(); void this.refreshBannedLookup(rooms, currentUser ?? null); }); effect(() => { const optimisticRoomId = this.optimisticSelectedRoomId(); if (!optimisticRoomId) { return; } if (this.currentRoom()?.id === optimisticRoomId && !this.isOnDirectMessage() && !this.isOnCall()) { this.optimisticSelectedRoomId.set(null); } }); this.savedRoomJoinRequests .pipe( switchMap(({ room, password }) => this.requestJoinInBackground(room, password)), takeUntilDestroyed(this.destroyRef) ) .subscribe(); } initial(name?: string): string { if (!name) return '?'; const ch = name.trim()[0]?.toUpperCase(); return ch || '?'; } trackRoomId = (index: number, room: Room) => room.id; createServer(): void { const voiceServerId = this.voiceSession.getVoiceServerId(); this.optimisticSelectedRoomId.set(null); if (voiceServerId) { this.voiceSession.setViewingVoiceServer(false); } this.router.navigate(['/search']); } joinSavedRoom(room: Room): void { const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room; const currentUserId = localStorage.getItem('metoyou_currentUserId'); if (!currentUserId) { this.router.navigate(['/login']); return; } if (this.isRoomMarkedBanned(targetRoom)) { this.bannedServerName.set(targetRoom.name); this.showBannedDialog.set(true); return; } this.optimisticSelectedRoomId.set(targetRoom.id); this.activateSavedRoom(targetRoom); this.savedRoomJoinRequests.next({ room: targetRoom }); } openCall(callId: string): void { this.optimisticSelectedRoomId.set(null); void this.directCalls.openCallView(callId); } isSelectedCall(callIndex: number): boolean { return this.selectedCallIndex() === callIndex; } callAvatarUrls(call: DirectCallSession): string[] { if (call.participantIds.length <= 2) { return []; } return Object.values(call.participants) .filter((participant) => participant.joined) .map((participant) => this.directCalls.userForParticipant(participant.userId)?.avatarUrl || participant.profile.avatarUrl) .filter((avatarUrl): avatarUrl is string => !!avatarUrl) .slice(0, 3); } closeBannedDialog(): void { this.showBannedDialog.set(false); this.bannedServerName.set(''); } closePasswordDialog(): void { this.showPasswordDialog.set(false); this.passwordPromptRoom.set(null); this.joinPassword.set(''); this.joinPasswordError.set(null); } confirmPasswordJoin(): void { const room = this.passwordPromptRoom(); if (!room) return; this.joinPasswordError.set(null); this.savedRoomJoinRequests.next({ room, password: this.joinPassword() }); } isRoomMarkedBanned(room: Room): boolean { return !!this.bannedRoomLookup()[room.id]; } private callIdFromUrl(url: string): string | null { const path = url.split(/[?#]/, 1)[0]; const match = path.match(/^\/call\/([^/]+)/); return match?.[1] ? decodeURIComponent(match[1]) : null; } openContextMenu(evt: MouseEvent, room: Room): void { evt.preventDefault(); this.contextRoom.set(room); this.menuX.set(Math.max(evt.clientX + 8, 72)); this.menuY.set(evt.clientY); this.showMenu.set(true); } closeMenu(): void { this.showMenu.set(false); } isCurrentContextRoom(): boolean { const ctx = this.contextRoom(); const cur = this.currentRoom(); return !!ctx && !!cur && ctx.id === cur.id; } openLeaveConfirm(): void { this.closeMenu(); if (this.contextRoom()) { this.showLeaveConfirm.set(true); } } confirmLeave(result: { nextOwnerKey?: string }): void { const ctx = this.contextRoom(); if (!ctx) return; const isCurrentRoom = this.currentRoom()?.id === ctx.id; this.store.dispatch( RoomsActions.forgetRoom({ roomId: ctx.id, nextOwnerKey: result.nextOwnerKey }) ); if (isCurrentRoom) { this.optimisticSelectedRoomId.set(null); this.router.navigate(['/search']); } this.showLeaveConfirm.set(false); this.contextRoom.set(null); } cancelLeave(): void { this.showLeaveConfirm.set(false); } toggleRoomNotifications(): void { const room = this.contextRoom(); if (!room) { return; } this.notifications.setRoomMuted(room.id, !this.notifications.isRoomMuted(room.id)); this.closeMenu(); } isRoomNotificationsMuted(roomId: string): boolean { return this.notifications.isRoomMuted(roomId); } roomUnreadCount(roomId: string): number { return this.notifications.roomUnreadCount(roomId); } voicePresenceCount(roomId: string): number { return this.voicePresenceByRoom()[roomId] ?? 0; } formatUnreadCount(count: number): string { return count > 99 ? '99+' : String(count); } isSelectedRoom(room: Room): boolean { if (this.isOnDirectMessage() || this.isOnCall()) { return false; } const optimisticRoomId = this.optimisticSelectedRoomId(); if (optimisticRoomId) { return optimisticRoomId === room.id; } return this.currentRoom()?.id === room.id; } private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] { const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room])); const stabilizedRooms = nextRooms.map((room) => { const previousRoom = previousById.get(room.id); return previousRoom && this.hasSameRailRoomView(previousRoom, room) ? previousRoom : room; }); if ( stabilizedRooms.length === this.visibleSavedRoomCache.length && stabilizedRooms.every((room, index) => room === this.visibleSavedRoomCache[index]) ) { return this.visibleSavedRoomCache; } this.visibleSavedRoomCache = stabilizedRooms; return stabilizedRooms; } private hasSameRailRoomView(previousRoom: Room, nextRoom: Room): boolean { return previousRoom.id === nextRoom.id && previousRoom.name === nextRoom.name && previousRoom.icon === nextRoom.icon; } private isDirectMessageUrl(url: string): boolean { const path = url.split(/[?#]/, 1)[0]; return path === '/dm' || path.startsWith('/dm/') || path === '/pm' || path.startsWith('/pm/'); } private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise { const requestVersion = ++this.banLookupRequestVersion; if (!currentUser || rooms.length === 0) { this.bannedRoomLookup.set({}); return; } const persistedUserId = localStorage.getItem('metoyou_currentUserId'); const entries = await Promise.all( rooms.map(async (room) => { const bans = await this.db.getBansForRoom(room.id); return [room.id, hasRoomBanForUser(bans, currentUser, persistedUserId)] as const; }) ); if (requestVersion !== this.banLookupRequestVersion) { return; } this.bannedRoomLookup.set(Object.fromEntries(entries)); } private prepareVoiceContext(room: Room): void { const voiceServerId = this.voiceSession.getVoiceServerId(); if (voiceServerId && voiceServerId !== room.id) { this.voiceSession.setViewingVoiceServer(false); } else if (voiceServerId === room.id) { this.voiceSession.setViewingVoiceServer(true); } } private activateSavedRoom(room: Room): void { this.prepareVoiceContext(room); this.closePasswordDialog(); this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); this.store.dispatch(RoomsActions.viewServer({ room, skipBanCheck: true })); } private requestJoinInBackground(room: Room, password?: string) { const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUser = this.currentUser(); if (!currentUserId) return EMPTY; this.joinPasswordError.set(null); return from(this.resolveRoomJoinTarget(room)).pipe( switchMap((joinTarget) => { if (!joinTarget.selector) { if (this.currentRoom()?.id === room.id) { this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); } return EMPTY; } return this.serverDirectory .requestJoin( { roomId: room.id, userId: currentUserId, userPublicKey: currentUser?.oderId || currentUserId, displayName: currentUser?.displayName || 'Anonymous', password: password?.trim() || undefined }, joinTarget.selector ) .pipe( tap((response) => { this.closePasswordDialog(); this.store.dispatch( RoomsActions.updateRoom({ roomId: room.id, changes: this.toRoomRefreshChanges(joinTarget.room, response.server, response.signalingUrl) }) ); if (this.currentRoom()?.id === room.id) { this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); } }) ); }), catchError((error: unknown) => { this.handleBackgroundJoinError(room, error); return EMPTY; }) ); } private handleBackgroundJoinError(room: Room, error: unknown): void { const serverError = error as { error?: { error?: string; errorCode?: string }; status?: number; }; const errorCode = serverError?.error?.errorCode; const message = serverError?.error?.error || 'Failed to join server'; if (errorCode === 'PASSWORD_REQUIRED') { this.passwordPromptRoom.set(room); this.showPasswordDialog.set(true); this.joinPasswordError.set(message); return; } if (errorCode === 'BANNED') { this.closePasswordDialog(); this.optimisticSelectedRoomId.set(null); this.bannedRoomLookup.update((lookup) => ({ ...lookup, [room.id]: true })); this.bannedServerName.set(room.name); this.showBannedDialog.set(true); return; } if (this.shouldFallbackToOfflineView(error) && this.currentRoom()?.id === room.id) { this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); } } private shouldFallbackToOfflineView(error: unknown): boolean { const serverError = error as { error?: { errorCode?: string }; status?: number; }; const errorCode = serverError?.error?.errorCode; const status = serverError?.status; return errorCode === 'SERVER_NOT_FOUND' || status === 0 || status === 404 || (typeof status === 'number' && status >= 500); } private toRoomRefreshChanges(room: Room, server: ServerInfo, signalingUrl?: string): Partial { const resolvedSource = this.serverDirectory.normaliseRoomSignalSource( { sourceId: server.sourceId ?? room.sourceId, sourceName: server.sourceName ?? room.sourceName, sourceUrl: server.sourceUrl ?? room.sourceUrl, signalingUrl, fallbackName: server.sourceName ?? room.sourceName ?? room.name }, { ensureEndpoint: true } ); return { name: server.name, description: server.description, topic: server.topic ?? room.topic, hostId: server.ownerId || room.hostId, userCount: server.userCount, maxUsers: server.maxUsers, hasPassword: typeof server.hasPassword === 'boolean' ? server.hasPassword : typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password, isPrivate: server.isPrivate, createdAt: server.createdAt, channels: Array.isArray(server.channels) && server.channels.length > 0 ? server.channels : room.channels, ...resolvedSource }; } private async resolveRoomJoinTarget(room: Room): Promise<{ room: Room; selector: ReturnType; }> { let resolvedRoom = this.applyResolvedRoomSource( room, this.serverDirectory.normaliseRoomSignalSource( { sourceId: room.sourceId, sourceName: room.sourceName, sourceUrl: room.sourceUrl, fallbackName: room.sourceName ?? room.name }, { ensureEndpoint: !!room.sourceUrl } ) ); let selector = this.serverDirectory.buildRoomSignalSelector( { sourceId: resolvedRoom.sourceId, sourceName: resolvedRoom.sourceName, sourceUrl: resolvedRoom.sourceUrl, fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name }, { ensureEndpoint: !!resolvedRoom.sourceUrl } ); const authoritativeServer = selector ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) : null; if (!authoritativeServer) { return { room: resolvedRoom, selector }; } const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource( { sourceId: authoritativeServer.sourceId ?? resolvedRoom.sourceId, sourceName: authoritativeServer.sourceName ?? resolvedRoom.sourceName, sourceUrl: authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl, fallbackName: authoritativeServer.sourceName ?? resolvedRoom.sourceName ?? resolvedRoom.name }, { ensureEndpoint: !!(authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl) } ); resolvedRoom = this.applyResolvedRoomSource(resolvedRoom, authoritativeSource); selector = this.serverDirectory.buildRoomSignalSelector( { sourceId: resolvedRoom.sourceId, sourceName: resolvedRoom.sourceName, sourceUrl: resolvedRoom.sourceUrl, fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name }, { ensureEndpoint: !!resolvedRoom.sourceUrl } ); return { room: resolvedRoom, selector }; } private applyResolvedRoomSource(room: Room, source: Pick): Room { const nextRoom: Room = { ...room, sourceId: source.sourceId, sourceName: source.sourceName, sourceUrl: source.sourceUrl }; if (room.sourceId === nextRoom.sourceId && room.sourceName === nextRoom.sourceName && room.sourceUrl === nextRoom.sourceUrl) { return room; } this.store.dispatch( RoomsActions.updateRoom({ roomId: room.id, changes: { sourceId: nextRoom.sourceId, sourceName: nextRoom.sourceName, sourceUrl: nextRoom.sourceUrl } }) ); return nextRoom; } }