/* 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, lucideRefreshCw } from '@ng-icons/lucide'; import { Router } from '@angular/router'; 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 { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { LeaveServerDialogComponent } from '../../shared'; import { Room } from '../../shared-kernel'; import { VoiceWorkspaceService } from '../../domains/voice-session'; import { ThemeNodeDirective } from '../../domains/theme'; @Component({ selector: 'app-title-bar', standalone: true, imports: [ CommonModule, NgIcon, LeaveServerDialogComponent, ThemeNodeDirective ], viewProviders: [ provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu, lucideRefreshCw }) ], templateUrl: './title-bar.component.html' }) /** * Electron-style title bar with window controls, navigation, and server menu. */ export class TitleBarComponent { 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 getWindowControlsApi() { return this.electronBridge.getApi(); } isElectron = computed(() => this.platform.isElectron); showMenuState = computed(() => false); currentUser = this.store.selectSignal(selectCurrentUser); username = computed(() => this.currentUser()?.displayName || 'Guest'); serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server'); 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); inRoom = computed(() => !!this.currentRoom()); roomName = computed(() => this.currentRoom()?.name || ''); activeTextChannelName = computed(() => { const textChannels = this.textChannels(); if (textChannels.length === 0) { return 'No text channels'; } 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 || 'Voice Lounge'; }); roomContextMeta = computed(() => { if (!this.currentRoom()) { return ''; } const parts = [`${this.textChannels().length} text`]; if (this.voiceChannels().length > 0) { parts.push(`${this.voiceChannels().length} voice`); } return parts.join(' | '); }); showRoomCompatibilityNotice = computed(() => this.inRoom() && !!this.signalServerCompatibilityError() ); showRoomReconnectNotice = computed(() => this.inRoom() && !this.signalServerCompatibilityError() && ( this.isSignalServerReconnecting() || this.webrtc.shouldShowConnectionError() || this.isReconnecting() ) ); private _showMenu = signal(false); showMenu = computed(() => this._showMenu()); showLeaveConfirm = signal(false); inviteStatus = signal(null); creatingInvite = signal(false); /** 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']); } /** 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('Creating invite link…'); try { const invite = await firstValueFrom(this.serverDirectory.createInvite( room.id, { requesterUserId: user.id, requesterDisplayName: user.displayName, requesterRole: user.role }, this.toSourceSelector(room) )); await this.copyInviteLink(invite.inviteUrl); this.inviteStatus.set('Invite link copied to clipboard.'); } catch (error: unknown) { const inviteError = error as { error?: { error?: string } }; this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.'); } finally { this.creatingInvite.set(false); } } /** Leave the current server and navigate to the servers list. */ leaveServer() { this.openLeaveConfirm(); } /** 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(['/search']); } /** Cancel the leave-server confirmation dialog. */ cancelLeave() { this.showLeaveConfirm.set(false); } /** Close the server dropdown menu. */ closeMenu() { this._showMenu.set(false); } /** Log out the current user, disconnect from signaling, and navigate to login. */ logout() { this._showMenu.set(false); // Disconnect from signaling server - this broadcasts "user_left" to all // servers the user was a member of, so other users see them go offline. this.webrtc.disconnect(); try { localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID); } catch {} this.router.navigate(['/login']); } 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('Copy this invite link', inviteUrl); } private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } { return { sourceId: room.sourceId, sourceUrl: room.sourceUrl }; } }