/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, computed, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { firstValueFrom } from 'rxjs'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu, lucidePackage, lucideRefreshCw, lucideShield } from '@ng-icons/lucide'; import { NavigationEnd, Router } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; import { filter, map } from 'rxjs'; import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels, selectIsSignalServerReconnecting, selectSignalServerCompatibilityError } from '../../../store/rooms/rooms.selectors'; import { RoomsActions } from '../../../store/rooms/rooms.actions'; import { selectCurrentUser } from '../../../store/users/users.selectors'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { RealtimeSessionFacade } from '../../../core/realtime'; import { ServerDirectoryFacade } from '../../../domains/server-directory'; import { PlatformService } from '../../../core/platform'; import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service'; import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules'; import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared'; import { Room, type PluginRequirementSummary } from '../../../shared-kernel'; import { VoiceWorkspaceService } from '../../../domains/voice-session'; import { ThemeNodeDirective } from '../../../domains/theme'; import { PluginRegistryService, PluginRequirementStateService, PluginStoreService } from '../../../domains/plugins'; import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plugin-install-scope.logic'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n'; @Component({ selector: 'app-title-bar', standalone: true, imports: [ CommonModule, NgIcon, LeaveServerDialogComponent, ThemeNodeDirective, ModalBackdropComponent, ...APP_TRANSLATE_IMPORTS ], viewProviders: [ provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu, lucidePackage, lucideRefreshCw, lucideShield }) ], templateUrl: './title-bar.component.html' }) /** * Electron-style title bar with window controls, navigation, and server menu. */ export class TitleBarComponent { private readonly appI18n = inject(AppI18nService); private store = inject(Store); private electronBridge = inject(ElectronBridgeService); private serverDirectory = inject(ServerDirectoryFacade); private router = inject(Router); private webrtc = inject(RealtimeSessionFacade); private platform = inject(PlatformService); private voiceWorkspace = inject(VoiceWorkspaceService); private settingsModal = inject(SettingsModalService); private pluginRegistry = inject(PluginRegistryService); private pluginRequirements = inject(PluginRequirementStateService); private pluginStore = inject(PluginStoreService); private userLogout = inject(UserLogoutService); private getWindowControlsApi() { return this.electronBridge.getApi(); } isElectron = computed(() => this.platform.isElectron); showMenuState = computed(() => false); currentUser = this.store.selectSignal(selectCurrentUser); username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest')); serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer')); isConnected = computed(() => this.webrtc.isConnected()); isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected()); isAuthed = computed(() => !!this.currentUser()); currentRoom = this.store.selectSignal(selectCurrentRoom); activeChannelId = this.store.selectSignal(selectActiveChannelId); textChannels = this.store.selectSignal(selectTextChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels); isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting); signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError); isInDirectMessage = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/')) ), { initialValue: this.router.url.startsWith('/dm/') } ); isInRoomView = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/room/')) ), { initialValue: this.router.url.startsWith('/room/') } ); inRoom = computed(() => !!this.currentRoom() && this.isInRoomView()); roomName = computed(() => this.currentRoom()?.name || ''); activeTextChannelName = computed(() => { const textChannels = this.textChannels(); if (textChannels.length === 0) { return this.appI18n.instant('shell.titleBar.noTextChannels'); } const id = this.activeChannelId(); const activeChannel = textChannels.find((channel) => channel.id === id) ?? textChannels[0]; return activeChannel ? activeChannel.name : id; }); connectedVoiceChannelName = computed(() => { const voiceChannelId = this.currentUser()?.voiceState?.roomId; const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId); return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge'); }); roomContextMeta = computed(() => { if (!this.currentRoom()) { return ''; } const parts = [this.appI18n.instant('shell.titleBar.textChannelCount', { count: this.textChannels().length })]; if (this.voiceChannels().length > 0) { parts.push(this.appI18n.instant('shell.titleBar.voiceChannelCount', { count: this.voiceChannels().length })); } return parts.join(' | '); }); showRoomCompatibilityNotice = computed(() => this.inRoom() && !!this.signalServerCompatibilityError() ); showRoomReconnectNotice = computed(() => this.inRoom() && !this.signalServerCompatibilityError() && ( this.isSignalServerReconnecting() || this.webrtc.shouldShowConnectionError() || this.isReconnecting() ) ); serverPluginCount = computed(() => this.pluginRegistry.entries() .filter((entry) => getPluginInstallScope(entry.manifest) === 'server') .length); hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0); requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements; optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null); optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length); private _showMenu = signal(false); showMenu = computed(() => this._showMenu()); showLeaveConfirm = signal(false); inviteStatus = signal(null); creatingInvite = signal(false); pluginRequirementBusy = signal(false); pluginRequirementError = signal(null); /** Minimize the Electron window. */ minimize() { const api = this.getWindowControlsApi(); api?.minimizeWindow?.(); } /** Maximize or restore the Electron window. */ maximize() { const api = this.getWindowControlsApi(); api?.maximizeWindow?.(); } /** Close the Electron window. */ close() { const api = this.getWindowControlsApi(); api?.closeWindow?.(); } /** Navigate to the login page. */ goLogin() { this.router.navigate(['/login'], { queryParams: buildLoginReturnQueryParams(this.router.url) }); } openPluginStore(): void { const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url; this._showMenu.set(false); void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } }); } openServerPlugins(): void { const roomId = this.currentRoom()?.id; if (!roomId) { return; } this._showMenu.set(false); this.settingsModal.open('serverPlugins', roomId); } openSettings(): void { this._showMenu.set(false); this.settingsModal.open('general'); } async openDocumentation(): Promise { const api = this.electronBridge.getApi(); this._showMenu.set(false); if (!api) { return; } const result = await api.openDocusaurusDocs(); if (result && !result.opened) { this.inviteStatus.set(result.reason ?? this.appI18n.instant('shell.titleBar.docsOpenFailed')); } } /** Open the unified leave-server confirmation dialog. */ private openLeaveConfirm() { this._showMenu.set(false); if (this.currentRoom()) { this.showLeaveConfirm.set(true); } } /** Toggle the server dropdown menu. */ toggleMenu() { this.inviteStatus.set(null); this._showMenu.set(!this._showMenu()); } /** Create a new invite link for the active room and copy it to the clipboard. */ async createInviteLink(): Promise { const room = this.currentRoom(); const user = this.currentUser(); if (!room || !user || this.creatingInvite()) { return; } this.creatingInvite.set(true); this.inviteStatus.set(this.appI18n.instant('shell.titleBar.creatingInvite')); try { const invite = await firstValueFrom(this.serverDirectory.createInvite( room.id, { requesterDisplayName: user.displayName, requesterRole: user.role }, this.toSourceSelector(room) )); await this.copyInviteLink(invite.browserUrl); this.inviteStatus.set(this.appI18n.instant('shell.titleBar.inviteCopied')); } catch (error: unknown) { const inviteError = error as { error?: { error?: string } }; this.inviteStatus.set(inviteError?.error?.error || this.appI18n.instant('shell.titleBar.inviteCreateFailed')); } finally { this.creatingInvite.set(false); } } /** Leave the current server and navigate to the servers list. */ leaveServer() { this.openLeaveConfirm(); } installRequiredServerPlugins(): void { void this.installServerRequirements(this.requiredPluginRequirements()); } installOptionalServerPlugin(requirement: PluginRequirementSummary): void { void this.installServerRequirements([requirement]); } rejectOptionalServerPlugin(requirement: PluginRequirementSummary): void { this.pluginRequirements.dismissOptionalRequirement(requirement); this.pluginRequirementError.set(null); } hideOptionalServerPlugin(requirement: PluginRequirementSummary): void { this.pluginRequirements.dismissOptionalRequirement(requirement, { persist: true }); this.pluginRequirementError.set(null); } /** Confirm the unified leave action and remove the server locally. */ confirmLeave(result: { nextOwnerKey?: string }) { const roomId = this.currentRoom()?.id; this.showLeaveConfirm.set(false); if (!roomId) return; this.store.dispatch(RoomsActions.forgetRoom({ roomId, nextOwnerKey: result.nextOwnerKey })); this.router.navigate(['/dashboard']); } /** Cancel the leave-server confirmation dialog. */ cancelLeave() { this.showLeaveConfirm.set(false); } /** Close the server dropdown menu. */ closeMenu() { this._showMenu.set(false); } private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise { const room = this.currentRoom(); if (!room || requirements.length === 0 || this.pluginRequirementBusy()) { return; } this.pluginRequirementBusy.set(true); this.pluginRequirementError.set(null); try { await this.pluginStore.installServerRequirementsLocally(room.id, requirements, { activate: true }); } catch (error) { this.pluginRequirementError.set(error instanceof Error ? error.message : this.appI18n.instant('shell.titleBar.pluginInstallFailed')); } finally { this.pluginRequirementBusy.set(false); } } /** Log out the current user, disconnect from signaling, and navigate to login. */ logout() { this._showMenu.set(false); this.userLogout.logout(); } private async copyInviteLink(inviteUrl: string): Promise { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(inviteUrl); return; } catch {} } const textarea = document.createElement('textarea'); textarea.value = inviteUrl; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; textarea.style.pointerEvents = 'none'; document.body.appendChild(textarea); textarea.select(); try { const copied = document.execCommand('copy'); if (copied) { return; } } catch { /* fall through to prompt fallback */ } finally { document.body.removeChild(textarea); } window.prompt(this.appI18n.instant('shell.titleBar.copyInvitePrompt'), inviteUrl); } private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } { return { sourceId: room.sourceId, sourceUrl: room.sourceUrl }; } }