/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, signal, computed, effect, untracked, HostListener, viewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideX, lucideBug, lucideBell, lucideChevronLeft, lucideDownload, lucideGlobe, lucideAudioLines, lucidePalette, lucidePackage, lucideSettings, lucideTerminal, lucideUsers, lucideBan, lucideShield } from '@ng-icons/lucide'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { ViewportService } from '../../../core/platform'; import { RealtimeSessionFacade } from '../../../core/realtime'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors'; import { Room, UserRole } from '../../../shared-kernel'; import { NotificationsSettingsComponent } from '../../../domains/notifications'; import { PluginManagerComponent } from '../../../domains/plugins/feature/plugin-manager/plugin-manager.component'; import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control'; import { GeneralSettingsComponent } from './general-settings/general-settings.component'; import { NetworkSettingsComponent } from './network-settings/network-settings.component'; import { VoiceSettingsComponent } from './voice-settings/voice-settings.component'; import { ServerSettingsComponent } from './server-settings/server-settings.component'; import { MembersSettingsComponent } from './members-settings/members-settings.component'; import { BansSettingsComponent } from './bans-settings/bans-settings.component'; import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component'; import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component'; import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component'; import { LocalApiSettingsComponent } from './local-api-settings/local-api-settings.component'; import { DataSettingsComponent } from './data-settings/data-settings.component'; import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses'; import { ThemeLibraryService, ThemeNodeDirective, ThemeService } from '../../../domains/theme'; @Component({ selector: 'app-settings-modal', standalone: true, imports: [ CommonModule, FormsModule, NgIcon, GeneralSettingsComponent, NetworkSettingsComponent, NotificationsSettingsComponent, PluginManagerComponent, VoiceSettingsComponent, UpdatesSettingsComponent, LocalApiSettingsComponent, DataSettingsComponent, DebuggingSettingsComponent, ServerSettingsComponent, MembersSettingsComponent, BansSettingsComponent, PermissionsSettingsComponent, ThemeNodeDirective ], viewProviders: [ provideIcons({ lucideX, lucideBug, lucideBell, lucideChevronLeft, lucideDownload, lucideGlobe, lucideAudioLines, lucidePalette, lucidePackage, lucideSettings, lucideTerminal, lucideUsers, lucideBan, lucideShield }) ], templateUrl: './settings-modal.component.html' }) export class SettingsModalComponent { readonly modal = inject(SettingsModalService); private store = inject(Store); private webrtc = inject(RealtimeSessionFacade); private theme = inject(ThemeService); private themeLibrary = inject(ThemeLibraryService); private viewport = inject(ViewportService); readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES; private lastRequestedServerId: string | null = null; /** True on mobile breakpoints. Drives the full-screen, page-stack layout. */ readonly isMobile = this.viewport.isMobile; /** * Active mobile sub-page within the settings flow. * 'menu' -> the section list (nav) * 'detail' -> the selected page content * Ignored on desktop. */ readonly mobilePage = signal<'menu' | 'detail'>('menu'); private permissionsComponent = viewChild('permissionsComp'); savedRooms = this.store.selectSignal(selectSavedRooms); currentRoom = this.store.selectSignal(selectCurrentRoom); currentUser = this.store.selectSignal(selectCurrentUser); isOpen = this.modal.isOpen; activePage = this.modal.activePage; themeStudioFullscreen = this.modal.themeStudioFullscreen; themeStudioMinimized = this.modal.themeStudioMinimized; isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen()); activeThemeName = this.theme.activeThemeName; savedThemesAvailable = this.themeLibrary.isAvailable; savedThemes = this.themeLibrary.entries; savedThemesBusy = this.themeLibrary.isBusy; selectedSavedTheme = this.themeLibrary.selectedEntry; readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [ { id: 'general', label: 'General', icon: 'lucideSettings' }, { id: 'plugins', label: 'Client plugins', icon: 'lucidePackage' }, { id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' }, { id: 'network', label: 'Network', icon: 'lucideGlobe' }, { id: 'notifications', label: 'Notifications', icon: 'lucideBell' }, { id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }, { id: 'updates', label: 'Updates', icon: 'lucideDownload' }, { id: 'localApi', label: 'Local API', icon: 'lucideTerminal' }, { id: 'data', label: 'Data', icon: 'lucideDownload' }, { id: 'debugging', label: 'Debugging', icon: 'lucideBug' } ]; readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [ { id: 'server', label: 'Server', icon: 'lucideSettings' }, { id: 'serverPlugins', label: 'Server plugins', icon: 'lucidePackage' }, { id: 'members', label: 'Members', icon: 'lucideUsers' }, { id: 'bans', label: 'Bans', icon: 'lucideBan' }, { id: 'permissions', label: 'Permissions', icon: 'lucideShield' } ]; manageableRooms = computed(() => { const user = this.currentUser(); if (!user) return []; const roomsById = new Map(this.savedRooms().map((room) => [room.id, room])); const currentRoom = this.currentRoom(); if (currentRoom) { roomsById.set(currentRoom.id, currentRoom); } return Array.from(roomsById.values()).filter((room) => { const viewedRoom = currentRoom?.id === room.id ? currentRoom : room; const role = resolveLegacyRole(viewedRoom, user); return ( role === 'host' || resolveRoomPermission(viewedRoom, user, 'manageServer') || resolveRoomPermission(viewedRoom, user, 'manageRoles') || resolveRoomPermission(viewedRoom, user, 'manageChannels') || resolveRoomPermission(viewedRoom, user, 'manageIcon') || resolveRoomPermission(viewedRoom, user, 'manageBans') || resolveRoomPermission(viewedRoom, user, 'kickMembers') || resolveRoomPermission(viewedRoom, user, 'banMembers') ); }); }); selectedServerId = signal(null); selectedServer = computed(() => { const id = this.selectedServerId(); const currentRoom = this.currentRoom(); if (!id) return null; return currentRoom?.id === id ? currentRoom : (this.manageableRooms().find((room) => room.id === id) ?? null); }); showServerTabs = computed(() => { return this.manageableRooms().length > 0 && !!this.selectedServerId(); }); selectedServerRole = computed(() => { const server = this.selectedServer(); const user = this.currentUser(); if (!server || !user) return null; return resolveLegacyRole(this.currentRoom()?.id === server.id ? (this.currentRoom() ?? server) : server, user); }); canAccessSelectedServer = computed(() => { const server = this.selectedServer(); const user = this.currentUser(); return ( !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageServer') || resolveRoomPermission(server, user, 'manageRoles') || resolveRoomPermission(server, user, 'manageChannels') || resolveRoomPermission(server, user, 'manageIcon') || resolveRoomPermission(server, user, 'manageBans') || resolveRoomPermission(server, user, 'kickMembers') || resolveRoomPermission(server, user, 'banMembers')) ); }); canManageSelectedMembers = computed(() => { const server = this.selectedServer(); const user = this.currentUser(); return ( !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageRoles') || resolveRoomPermission(server, user, 'kickMembers') || resolveRoomPermission(server, user, 'banMembers')) ); }); canManageSelectedBans = computed(() => { const server = this.selectedServer(); const user = this.currentUser(); return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageBans')); }); canManageSelectedPermissions = computed(() => { const server = this.selectedServer(); const user = this.currentUser(); return ( !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageRoles') || resolveRoomPermission(server, user, 'manageServer')) ); }); isSelectedServerOwner = computed(() => { return this.selectedServerRole() === 'host'; }); canManageSelectedServerSettings = computed(() => { const server = this.selectedServer(); const user = this.currentUser(); return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageServer')); }); canManageSelectedServerIcon = computed(() => { const server = this.selectedServer(); const user = this.currentUser(); return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageIcon')); }); isSelectedServerCurrent = computed(() => { const selectedServerId = this.selectedServerId(); const currentRoomId = this.currentRoom()?.id ?? null; return !!selectedServerId && selectedServerId === currentRoomId; }); animating = signal(false); showThirdPartyLicenses = signal(false); constructor() { effect(() => { if (!this.isOpen()) { this.lastRequestedServerId = null; return; } const rooms = this.manageableRooms(); const targetId = this.modal.targetServerId(); const currentRoomId = this.currentRoom()?.id ?? null; const selectedId = this.selectedServerId(); const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId); if (!hasSelected) { const fallbackId = [targetId, currentRoomId].find((candidateId) => !!candidateId && rooms.some((room) => room.id === candidateId)) ?? rooms[0]?.id ?? null; this.selectedServerId.set(fallbackId); } this.animating.set(true); // On mobile, always start on the section list so the user picks the page first. if (this.isMobile()) { this.mobilePage.set('menu'); } }); effect(() => { if (!this.isOpen() || this.activePage() !== 'theme' || !this.savedThemesAvailable()) { return; } untracked(() => { void this.refreshSavedThemes(); }); }); effect(() => { const server = this.selectedServer(); if (server) { const permsComp = this.permissionsComponent(); if (permsComp) { permsComp.loadPermissions(server); } } }); effect(() => { if (!this.isOpen()) return; const serverId = this.selectedServerId(); if (!serverId || this.lastRequestedServerId === serverId) return; this.lastRequestedServerId = serverId; for (const peerId of this.webrtc.getConnectedPeers()) { try { this.webrtc.sendToPeer(peerId, { type: 'server-state-request', roomId: serverId }); } catch { /* peer may have disconnected */ } } }); } @HostListener('document:keydown.escape') onEscapeKey(): void { if (this.showThirdPartyLicenses()) { this.closeThirdPartyLicenses(); return; } if (this.isThemeStudioFullscreen()) { this.modal.minimizeThemeStudio(); return; } if (this.isOpen()) { // On mobile, Escape on the detail page just navigates back to the menu. if (this.isMobile() && this.mobilePage() === 'detail') { this.backToMenu(); return; } this.close(); } } close(): void { this.showThirdPartyLicenses.set(false); this.animating.set(false); setTimeout(() => this.modal.close(), 200); } closeForExternalNavigation(): void { this.showThirdPartyLicenses.set(false); this.animating.set(false); this.modal.close(); } openThirdPartyLicenses(): void { this.showThirdPartyLicenses.set(true); } closeThirdPartyLicenses(): void { this.showThirdPartyLicenses.set(false); } navigate(page: SettingsPage): void { this.modal.navigate(page); // On mobile, advance to the detail page so the user sees the selected pane. if (this.isMobile()) { this.mobilePage.set('detail'); } } /** Go back to the section list on mobile. No-op on desktop. */ backToMenu(): void { this.mobilePage.set('menu'); } openThemeStudio(): void { this.modal.openThemeStudio(); } async refreshSavedThemes(): Promise { await this.themeLibrary.refresh(); this.syncSavedThemeSelectionToActiveTheme(); } async onSavedThemeSelect(event: Event): Promise { const select = event.target as HTMLSelectElement; const fileName = select.value || null; this.themeLibrary.select(fileName); if (!fileName) { return; } const applied = await this.themeLibrary.useSelectedTheme(); if (!applied) { this.syncSavedThemeSelectionToActiveTheme(); } } async editSelectedSavedTheme(): Promise { const opened = await this.themeLibrary.openSelectedThemeInDraft(); if (opened) { this.modal.openThemeStudio(); } } onBackdropClick(): void { this.close(); } onServerSelect(event: Event): void { const select = event.target as HTMLSelectElement; this.selectedServerId.set(select.value || null); } restoreDefaultTheme(): void { this.theme.resetToDefault('button'); this.syncSavedThemeSelectionToActiveTheme(); this.navigate('theme'); } private syncSavedThemeSelectionToActiveTheme(): void { const matchingTheme = this.savedThemes().find((entry) => entry.isValid && entry.themeName === this.activeThemeName()) ?? null; this.themeLibrary.select(matchingTheme?.fileName ?? null); } }