All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
483 lines
15 KiB
TypeScript
483 lines
15 KiB
TypeScript
/* 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<PermissionsSettingsComponent>('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<Room[]>(() => {
|
|
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<string | null>(null);
|
|
selectedServer = computed<Room | null>(() => {
|
|
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<UserRole | null>(() => {
|
|
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<void> {
|
|
await this.themeLibrary.refresh();
|
|
this.syncSavedThemeSelectionToActiveTheme();
|
|
}
|
|
|
|
async onSavedThemeSelect(event: Event): Promise<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|