Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,42 @@
@if (server()) {
<div class="space-y-3 max-w-xl">
@if (bannedUsers().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
} @else {
@for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
{{ ban.displayName || 'Unknown User' }}
</p>
@if (ban.reason) {
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
}
@if (ban.expiresAt) {
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
} @else {
<p class="text-xs text-destructive">Permanent</p>
}
</div>
@if (isAdmin()) {
<button
type="button"
(click)="unbanUser(ban)"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
>
<ng-icon
name="lucideX"
class="w-4 h-4"
/>
</button>
}
</div>
}
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
}

View File

@@ -0,0 +1,94 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
input,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { Actions, ofType } from '@ngrx/effects';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide';
import { Room, BanEntry } from '../../../../shared-kernel';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
@Component({
selector: 'app-bans-settings',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideX
})
],
templateUrl: './bans-settings.component.html'
})
export class BansSettingsComponent {
private store = inject(Store);
private actions$ = inject(Actions);
private db = inject(DatabaseService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
bannedUsers = signal<BanEntry[]>([]);
constructor() {
effect(() => {
const roomId = this.server()?.id;
if (!roomId) {
this.bannedUsers.set([]);
return;
}
void this.loadBansForServer(roomId);
});
this.actions$
.pipe(
ofType(
UsersActions.banUserSuccess,
UsersActions.unbanUserSuccess,
UsersActions.loadBansSuccess,
RoomsActions.updateRoom
),
takeUntilDestroyed()
)
.subscribe(() => {
const roomId = this.server()?.id;
if (roomId) {
void this.loadBansForServer(roomId);
}
});
}
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
oderId: ban.oderId }));
}
private async loadBansForServer(roomId: string): Promise<void> {
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
}
formatExpiry(timestamp: number): string {
const date = new Date(timestamp);
return (
date.toLocaleDateString() +
' ' +
date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' })
);
}
}

View File

@@ -0,0 +1,106 @@
<div class="max-w-3xl space-y-6">
<section class="rounded-xl border border-border bg-card/40 p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex items-start gap-3">
<div class="rounded-xl bg-primary/10 p-2 text-primary">
<ng-icon
name="lucideBug"
class="h-5 w-5"
/>
</div>
<div>
<h4 class="text-sm font-semibold text-foreground">App-wide debugging</h4>
<p class="mt-1 text-sm text-muted-foreground">
Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.
</p>
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
[checked]="enabled()"
(change)="onEnabledChange($event)"
/>
<div
class="h-5 w-10 rounded-full bg-secondary after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full"
></div>
</label>
</div>
</section>
<section class="grid gap-3 sm:grid-cols-3">
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<div class="flex items-center gap-2 text-muted-foreground">
<ng-icon
name="lucideClock3"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Captured events</span>
</div>
<p class="mt-3 text-2xl font-semibold text-foreground">{{ entryCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Last update: {{ lastUpdatedLabel() }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<div class="flex items-center gap-2 text-muted-foreground">
<ng-icon
name="lucideCircleAlert"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Errors</span>
</div>
<p class="mt-3 text-2xl font-semibold text-destructive">{{ errorCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Unhandled runtime failures and rejected promises.</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<div class="flex items-center gap-2 text-muted-foreground">
<ng-icon
name="lucideTriangleAlert"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Warnings</span>
</div>
<p class="mt-3 text-2xl font-semibold text-yellow-400">{{ warningCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Navigation cancellations, offline events, and other warnings.</p>
</div>
</section>
<section class="rounded-xl border border-border bg-card/40 p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h5 class="text-sm font-semibold text-foreground">Floating debug console</h5>
<p class="mt-1 text-sm text-muted-foreground">
When debugging is enabled, a bug icon appears in the app so you can open the docked console without blocking the rest of the UI.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
(click)="openConsole()"
[disabled]="!enabled()"
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ isConsoleOpen() ? 'Console open' : 'Open console' }}
</button>
<button
type="button"
(click)="clearLogs()"
[disabled]="entryCount() === 0"
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4"
/>
Clear logs
</button>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,70 @@
import {
Component,
computed,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideBug,
lucideCircleAlert,
lucideClock3,
lucideTrash2,
lucideTriangleAlert
} from '@ng-icons/lucide';
import { DebuggingService } from '../../../../core/services/debugging.service';
@Component({
selector: 'app-debugging-settings',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideBug,
lucideCircleAlert,
lucideClock3,
lucideTrash2,
lucideTriangleAlert
})
],
templateUrl: './debugging-settings.component.html'
})
export class DebuggingSettingsComponent {
readonly debugging = inject(DebuggingService);
readonly enabled = this.debugging.enabled;
readonly isConsoleOpen = this.debugging.isConsoleOpen;
readonly entryCount = computed(() => {
return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0);
});
readonly errorCount = computed(() => {
return this.debugging.entries().reduce((sum, entry) => {
return sum + (entry.level === 'error' ? entry.count : 0);
}, 0);
});
readonly warningCount = computed(() => {
return this.debugging.entries().reduce((sum, entry) => {
return sum + (entry.level === 'warn' ? entry.count : 0);
}, 0);
});
readonly lastUpdatedLabel = computed(() => {
const lastEntry = this.debugging.entries().at(-1);
return lastEntry ? lastEntry.timeLabel : 'No logs yet';
});
onEnabledChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.debugging.setEnabled(input.checked);
}
openConsole(): void {
this.debugging.openConsole();
}
clearLogs(): void {
this.debugging.clear();
}
}

View File

@@ -0,0 +1,47 @@
<div class="space-y-6 max-w-xl">
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucidePower"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Application</h4>
</div>
<div
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
[class.opacity-60]="!isElectron"
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
@if (isElectron) {
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
} @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
}
</div>
<label
class="relative inline-flex items-center"
[class.cursor-pointer]="isElectron && !savingAutoStart()"
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
>
<input
type="checkbox"
[checked]="autoStart()"
[disabled]="!isElectron || savingAutoStart()"
(change)="onAutoStartChange($event)"
id="general-auto-start-toggle"
aria-label="Toggle launch on startup"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePower } from '@ng-icons/lucide';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
@Component({
selector: 'app-general-settings',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucidePower
})
],
templateUrl: './general-settings.component.html'
})
export class GeneralSettingsComponent {
private platform = inject(PlatformService);
private electronBridge = inject(ElectronBridgeService);
readonly isElectron = this.platform.isElectron;
autoStart = signal(false);
savingAutoStart = signal(false);
constructor() {
if (this.isElectron) {
void this.loadDesktopSettings();
}
}
async onAutoStartChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const enabled = !!input.checked;
const api = this.electronBridge.getApi();
if (!this.isElectron || !api) {
input.checked = this.autoStart();
return;
}
this.savingAutoStart.set(true);
try {
const snapshot = await api.setDesktopSettings({ autoStart: enabled });
this.autoStart.set(snapshot.autoStart);
} catch {
input.checked = this.autoStart();
} finally {
this.savingAutoStart.set(false);
}
}
private async loadDesktopSettings(): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
try {
const snapshot = await api.getDesktopSettings();
this.autoStart.set(snapshot.autoStart);
} catch {}
}
}

View File

@@ -0,0 +1,74 @@
@if (server()) {
<div class="space-y-3 max-w-xl">
@if (members().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
} @else {
@for (member of members(); track member.oderId || member.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar
[name]="member.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">
{{ member.displayName }}
</p>
@if (member.isOnline) {
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span>
}
@if (member.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
@if (member.role !== 'host' && isAdmin()) {
<div class="flex items-center gap-1">
@if (canChangeRoles()) {
<select
[ngModel]="member.role"
(ngModelChange)="changeRole(member, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
}
@if (canKickMembers()) {
<button
(click)="kickMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
}
@if (canBanMembers()) {
<button
(click)="banMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
}
</div>
}
</div>
}
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
}

View File

@@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideUserX, lucideBan } from '@ng-icons/lucide';
import {
Room,
RoomMember,
UserRole
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
interface ServerMemberView extends RoomMember {
isOnline: boolean;
}
@Component({
selector: 'app-members-settings',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideUserX,
lucideBan
})
],
templateUrl: './members-settings.component.html'
})
export class MembersSettingsComponent {
private store = inject(Store);
private webrtcService = inject(RealtimeSessionFacade);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
accessRole = input<UserRole | null>(null);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
usersEntities = this.store.selectSignal(selectUsersEntities);
members = computed<ServerMemberView[]>(() => {
const room = this.server();
const me = this.currentUser();
const currentRoom = this.currentRoom();
const usersEntities = this.usersEntities();
if (!room)
return [];
return (room.members ?? [])
.filter((member) => member.id !== me?.id && member.oderId !== me?.oderId)
.map((member) => {
const liveUser = currentRoom?.id === room.id
? (usersEntities[member.id]
|| Object.values(usersEntities).find((user) => !!user && user.oderId === member.oderId)
|| null)
: null;
return {
...member,
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
displayName: liveUser?.displayName || member.displayName,
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')
};
});
});
canChangeRoles(): boolean {
const role = this.accessRole();
return role === 'host' || role === 'admin';
}
canKickMembers(): boolean {
const role = this.accessRole();
return role === 'host' || role === 'admin' || role === 'moderator';
}
canBanMembers(): boolean {
const role = this.accessRole();
return role === 'host' || role === 'admin';
}
changeRole(member: ServerMemberView, role: 'admin' | 'moderator' | 'member'): void {
const room = this.server();
if (!room)
return;
const members = (room.members ?? []).map((existingMember) =>
existingMember.id === member.id || existingMember.oderId === member.oderId
? { ...existingMember,
role }
: existingMember
);
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
changes: { members } }));
if (this.currentRoom()?.id === room.id) {
this.store.dispatch(UsersActions.updateUserRole({ userId: member.id,
role }));
}
this.webrtcService.broadcastMessage({
type: 'role-change',
roomId: room.id,
targetUserId: member.id,
role
});
}
kickMember(member: ServerMemberView): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(UsersActions.kickUser({ userId: member.id,
roomId: room.id }));
}
banMember(member: ServerMemberView): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(UsersActions.banUser({ userId: member.id,
roomId: room.id,
displayName: member.displayName }));
}
}

View File

@@ -0,0 +1,202 @@
<div class="space-y-6 max-w-xl">
<!-- Server Endpoints -->
<section>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<ng-icon
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
</div>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
<button
type="button"
(click)="restoreDefaultServers()"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Restore Defaults
</button>
}
<button
type="button"
(click)="testAllServers()"
[disabled]="isTesting()"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon
name="lucideRefreshCw"
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
</div>
</div>
<p class="text-xs text-muted-foreground mb-3">
Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.
</p>
<!-- Server List -->
<div class="space-y-2 mb-3">
@for (server of servers(); track server.id) {
<div
class="flex items-center gap-3 p-2.5 rounded-lg border transition-colors"
[class.border-primary]="server.isActive"
[class.bg-primary/5]="server.isActive"
[class.border-border]="!server.isActive"
[class.bg-secondary/30]="!server.isActive"
>
<div
class="w-2.5 h-2.5 rounded-full flex-shrink-0"
[class.bg-green-500]="server.status === 'online'"
[class.bg-red-500]="server.status === 'offline'"
[class.bg-yellow-500]="server.status === 'checking'"
[class.bg-muted]="server.status === 'unknown'"
[class.bg-orange-500]="server.status === 'incompatible'"
></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
}
</div>
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
@if (server.latency !== undefined && server.status === 'online') {
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-[10px] text-destructive">Update the client in order to connect to other users</p>
}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
@if (!server.isActive && server.status !== 'incompatible') {
<button
type="button"
(click)="setActiveServer(server.id)"
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
title="Activate"
>
<ng-icon
name="lucideCheck"
class="w-3.5 h-3.5 text-muted-foreground hover:text-primary"
/>
</button>
}
@if (server.isActive && hasMultipleActiveServers()) {
<button
type="button"
(click)="deactivateServer(server.id)"
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
title="Deactivate"
>
<ng-icon
name="lucideX"
class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground"
/>
</button>
}
@if (hasMultipleServers()) {
<button
type="button"
(click)="removeServer(server.id)"
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
title="Remove"
>
<ng-icon
name="lucideTrash2"
class="w-3.5 h-3.5 text-muted-foreground hover:text-destructive"
/>
</button>
}
</div>
</div>
}
</div>
<!-- Add New Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add New Server</h4>
<div class="flex gap-2">
<div class="flex-1 space-y-1.5">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
type="button"
(click)="addServer()"
[disabled]="!newServerName || !newServerUrl"
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
>
<ng-icon
name="lucidePlus"
class="w-4 h-4"
/>
</button>
</div>
@if (addError()) {
<p class="text-xs text-destructive mt-1.5">{{ addError() }}</p>
}
</div>
</section>
<!-- Connection Settings -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Auto-reconnect</p>
<p class="text-xs text-muted-foreground">Reconnect when connection is lost</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="autoReconnect"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Search all servers</p>
<p class="text-xs text-muted-foreground">Search across all server directories</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="searchAllServers"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,138 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideGlobe,
lucideServer,
lucideRefreshCw,
lucidePlus,
lucideTrash2,
lucideCheck,
lucideX
} from '@ng-icons/lucide';
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
@Component({
selector: 'app-network-settings',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideGlobe,
lucideServer,
lucideRefreshCw,
lucidePlus,
lucideTrash2,
lucideCheck,
lucideX
})
],
templateUrl: './network-settings.component.html'
})
export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryFacade);
servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers;
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
hasMultipleServers = computed(() => this.servers().length > 1);
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
isTesting = signal(false);
addError = signal<string | null>(null);
newServerName = '';
newServerUrl = '';
autoReconnect = true;
searchAllServers = true;
constructor() {
this.loadConnectionSettings();
}
addServer(): void {
this.addError.set(null);
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
return;
}
if (this.servers().some((serverEntry) => serverEntry.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
return;
}
this.serverDirectory.addServer({
name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, '')
});
this.newServerName = '';
this.newServerUrl = '';
const servers = this.servers();
const newServer = servers[servers.length - 1];
if (newServer)
this.serverDirectory.testServer(newServer.id);
}
removeServer(id: string): void {
this.serverDirectory.removeServer(id);
}
setActiveServer(id: string): void {
this.serverDirectory.setActiveServer(id);
}
deactivateServer(id: string): void {
this.serverDirectory.deactivateServer(id);
}
restoreDefaultServers(): void {
this.serverDirectory.restoreDefaultServers();
}
async testAllServers(): Promise<void> {
this.isTesting.set(true);
await this.serverDirectory.testAllServers();
this.isTesting.set(false);
}
loadConnectionSettings(): void {
const raw = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (raw) {
const parsed = JSON.parse(raw);
this.autoReconnect = parsed.autoReconnect ?? true;
this.searchAllServers = parsed.searchAllServers ?? true;
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
}
saveConnectionSettings(): void {
localStorage.setItem(
STORAGE_KEY_CONNECTION_SETTINGS,
JSON.stringify({
autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers
})
);
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
}

View File

@@ -0,0 +1,129 @@
@if (server()) {
<div class="space-y-4 max-w-xl">
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
}
<div class="space-y-2.5">
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowVoice"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
<p class="text-xs text-muted-foreground">Users can share their screen</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowScreenShare"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
<p class="text-xs text-muted-foreground">Users can upload files</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowFileUploads"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Limit message frequency</p>
</div>
<select
[(ngModel)]="slowModeInterval"
[disabled]="!isAdmin()"
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
</select>
</div>
<!-- Management permissions -->
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow admins to create/modify rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageRooms"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow moderators to create/modify rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageRooms"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageIcon"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageIcon"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
</div>
@if (isAdmin()) {
<button
(click)="savePermissions()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm"
[class.bg-green-600]="saveSuccess() === 'permissions'"
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
</button>
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
}

View File

@@ -0,0 +1,99 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
input,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideCheck } from '@ng-icons/lucide';
import { Room } from '../../../../shared-kernel';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@Component({
selector: 'app-permissions-settings',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideCheck
})
],
templateUrl: './permissions-settings.component.html'
})
export class PermissionsSettingsComponent {
private store = inject(Store);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
allowVoice = true;
allowScreenShare = true;
allowFileUploads = true;
slowModeInterval = '0';
adminsManageRooms = false;
moderatorsManageRooms = false;
adminsManageIcon = false;
moderatorsManageIcon = false;
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
/** Load permissions from the server input. Called by parent via effect or on init. */
loadPermissions(room: Room): void {
const perms = room.permissions || {};
this.allowVoice = perms.allowVoice !== false;
this.allowScreenShare = perms.allowScreenShare !== false;
this.allowFileUploads = perms.allowFileUploads !== false;
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
this.adminsManageRooms = !!perms.adminsManageRooms;
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
this.adminsManageIcon = !!perms.adminsManageIcon;
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
}
savePermissions(): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(
RoomsActions.updateRoomPermissions({
roomId: room.id,
permissions: {
allowVoice: this.allowVoice,
allowScreenShare: this.allowScreenShare,
allowFileUploads: this.allowFileUploads,
slowModeInterval: parseInt(this.slowModeInterval, 10),
adminsManageRooms: this.adminsManageRooms,
moderatorsManageRooms: this.moderatorsManageRooms,
adminsManageIcon: this.adminsManageIcon,
moderatorsManageIcon: this.moderatorsManageIcon
}
})
);
this.showSaveSuccess('permissions');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
}
}

View File

@@ -0,0 +1,227 @@
@if (serverData()) {
<div class="space-y-5 max-w-xl">
<section>
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3">
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
</p>
}
<div class="space-y-4">
<div>
<label
for="room-name"
class="block text-xs font-medium text-muted-foreground mb-1"
>Room Name</label
>
<input
type="text"
[(ngModel)]="roomName"
[readOnly]="!isAdmin()"
id="room-name"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()"
/>
</div>
<div>
<label
for="room-description"
class="block text-xs font-medium text-muted-foreground mb-1"
>Description</label
>
<textarea
[(ngModel)]="roomDescription"
[readOnly]="!isAdmin()"
rows="3"
id="room-description"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
[class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()"
></textarea>
</div>
@if (isAdmin()) {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
</div>
<button
(click)="togglePrivate()"
type="button"
class="p-2 rounded-lg transition-colors"
[class.bg-primary]="isPrivate()"
[class.text-primary-foreground]="isPrivate()"
[class.bg-secondary]="!isPrivate()"
[class.text-muted-foreground]="!isPrivate()"
>
@if (isPrivate()) {
<ng-icon
name="lucideLock"
class="w-4 h-4"
/>
} @else {
<ng-icon
name="lucideUnlock"
class="w-4 h-4"
/>
}
</button>
</div>
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
</div>
<span class="text-sm text-muted-foreground">{{ isPrivate() ? 'Yes' : 'No' }}</span>
</div>
}
<div>
<label
for="room-max-users"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Max Users (0 = unlimited)
</label>
<input
type="number"
[(ngModel)]="maxUsers"
[readOnly]="!isAdmin()"
min="0"
id="room-max-users"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()"
/>
</div>
@if (isAdmin()) {
<div class="rounded-lg border border-border bg-secondary/40 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Joined members stay whitelisted until they are kicked or banned.
} @else {
Add an optional password so new members need it to join.
}
</p>
</div>
@if (hasPassword() && passwordAction() !== 'remove') {
<button
type="button"
(click)="markPasswordForRemoval()"
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
>
Remove Password
</button>
} @else if (hasPassword() && passwordAction() === 'remove') {
<button
type="button"
(click)="keepCurrentPassword()"
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
>
Keep Password
</button>
}
</div>
<div class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Password protection is currently enabled.
} @else if (hasPassword() && passwordAction() === 'remove') {
Password protection will be removed when you save.
} @else {
Password protection is currently disabled.
}
</div>
<div>
<label
for="room-password"
class="block text-xs font-medium text-muted-foreground mb-1"
>
{{ hasPassword() ? 'Set New Password' : 'Set Password' }}
</label>
<input
type="password"
id="room-password"
[ngModel]="roomPassword"
(ngModelChange)="onPasswordInput($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="hasPassword() ? 'Leave blank to keep the current password' : 'Optional password required for new joins'"
/>
@if (passwordAction() === 'update') {
<p class="mt-2 text-xs text-muted-foreground">The new password will replace the current one when you save.</p>
}
@if (passwordError()) {
<p class="mt-2 text-xs text-destructive">{{ passwordError() }}</p>
}
</div>
</div>
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-xs text-muted-foreground">Invite links bypass the password, but bans still apply.</p>
</div>
<span class="text-sm text-muted-foreground">{{ hasPassword() ? 'Enabled' : 'Disabled' }}</span>
</div>
}
</div>
</section>
@if (isAdmin()) {
<button
(click)="saveServerSettings()"
type="button"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm"
[class.bg-green-600]="saveSuccess() === 'server'"
[class.hover:bg-green-600]="saveSuccess() === 'server'"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(click)="confirmDeleteRoom()"
type="button"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
}
</div>
<!-- Delete Confirmation (sub-modal) -->
@if (showDeleteConfirm()) {
<app-confirm-dialog
title="Delete Room"
confirmLabel="Delete Room"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="deleteRoom()"
(cancelled)="showDeleteConfirm.set(false)"
>
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
</app-confirm-dialog>
}
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
}

View File

@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
input,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import {
lucideCheck,
lucideTrash2,
lucideLock,
lucideUnlock
} from '@ng-icons/lucide';
import { Room } from '../../../../shared-kernel';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
@Component({
selector: 'app-server-settings',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideCheck,
lucideTrash2,
lucideLock,
lucideUnlock
})
],
templateUrl: './server-settings.component.html'
})
export class ServerSettingsComponent {
private store = inject(Store);
private modal = inject(SettingsModalService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
roomName = '';
roomDescription = '';
isPrivate = signal(false);
hasPassword = signal(false);
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
passwordError = signal<string | null>(null);
roomPassword = '';
maxUsers = 0;
showDeleteConfirm = signal(false);
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
/** Reload form fields whenever the server input changes. */
readonly serverData = this.server;
constructor() {
effect(() => {
const room = this.server();
if (!room)
return;
this.roomName = room.name;
this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate);
this.hasPassword.set(typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password);
this.passwordAction.set('keep');
this.passwordError.set(null);
this.roomPassword = '';
this.maxUsers = room.maxUsers || 0;
});
}
togglePrivate(): void {
this.isPrivate.update((currentValue) => !currentValue);
}
saveServerSettings(): void {
const room = this.server();
if (!room)
return;
const normalizedPassword = this.roomPassword.trim();
const settings: {
description: string;
hasPassword?: boolean;
isPrivate: boolean;
maxUsers: number;
name: string;
password?: string;
} = {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
};
if (this.passwordAction() === 'remove') {
settings.password = '';
settings.hasPassword = false;
} else if (normalizedPassword) {
settings.password = normalizedPassword;
settings.hasPassword = true;
}
this.store.dispatch(
RoomsActions.updateRoomSettings({
roomId: room.id,
settings
})
);
this.hasPassword.set(settings.hasPassword ?? this.hasPassword());
this.passwordAction.set('keep');
this.passwordError.set(null);
this.roomPassword = '';
this.showSaveSuccess('server');
}
markPasswordForRemoval(): void {
this.passwordAction.set('remove');
this.passwordError.set(null);
this.roomPassword = '';
}
keepCurrentPassword(): void {
this.passwordAction.set('keep');
this.passwordError.set(null);
this.roomPassword = '';
}
onPasswordInput(value: string): void {
this.roomPassword = value;
this.passwordError.set(null);
if (value.trim().length > 0) {
this.passwordAction.set('update');
return;
}
this.passwordAction.set('keep');
}
confirmDeleteRoom(): void {
this.showDeleteConfirm.set(true);
}
deleteRoom(): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
this.showDeleteConfirm.set(false);
this.modal.navigate('network');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
}
}

View File

@@ -0,0 +1,271 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
@if (isOpen()) {
<!-- Backdrop -->
<div
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200"
[class.opacity-100]="animating()"
[class.opacity-0]="!animating()"
(click)="onBackdropClick()"
(keydown.enter)="onBackdropClick()"
(keydown.space)="onBackdropClick()"
role="button"
tabindex="0"
aria-label="Close settings"
></div>
<!-- Modal -->
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
[class.scale-100]="animating()"
[class.opacity-100]="animating()"
[class.scale-95]="!animating()"
[class.opacity-0]="!animating()"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Side Navigation -->
<nav class="w-52 flex-shrink-0 bg-secondary/40 border-r border-border flex flex-col">
<div class="p-4 border-b border-border">
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
</div>
<div class="flex-1 overflow-y-auto py-2">
<!-- Global section -->
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">General</p>
@for (page of globalPages; track page.id) {
<button
(click)="navigate(page.id)"
type="button"
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
[class.bg-primary/10]="activePage() === page.id"
[class.text-primary]="activePage() === page.id"
[class.font-medium]="activePage() === page.id"
[class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id"
>
<ng-icon
[name]="page.icon"
class="w-4 h-4"
/>
{{ page.label }}
</button>
}
<!-- Server section -->
@if (manageableRooms().length > 0) {
<div class="mt-3 pt-3 border-t border-border">
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
<!-- Server selector -->
<div class="px-3 pb-2">
<select
class="w-full px-2 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-primary"
[value]="selectedServerId() || ''"
(change)="onServerSelect($event)"
>
<option value="">Select a server…</option>
@for (room of manageableRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select>
</div>
@if (selectedServerId() && canAccessSelectedServer()) {
@for (page of serverPages; track page.id) {
<button
(click)="navigate(page.id)"
type="button"
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
[class.bg-primary/10]="activePage() === page.id"
[class.text-primary]="activePage() === page.id"
[class.font-medium]="activePage() === page.id"
[class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id"
>
<ng-icon
[name]="page.icon"
class="w-4 h-4"
/>
{{ page.label }}
</button>
}
}
</div>
}
</div>
<div class="mt-auto border-t border-border px-4 py-3">
<button
type="button"
(click)="openThirdPartyLicenses()"
class="text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline underline-offset-4"
>
Third-party licenses
</button>
</div>
</nav>
<!-- Content -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h3 class="text-lg font-semibold text-foreground">
@switch (activePage()) {
@case ('general') {
General
}
@case ('network') {
Network
}
@case ('voice') {
Voice & Audio
}
@case ('updates') {
Updates
}
@case ('debugging') {
Debugging
}
@case ('server') {
Server Settings
}
@case ('members') {
Members
}
@case ('bans') {
Bans
}
@case ('permissions') {
Permissions
}
}
</h3>
<button
(click)="close()"
type="button"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
>
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
</div>
<!-- Scrollable Content Area -->
<div class="flex-1 overflow-y-auto p-6">
@switch (activePage()) {
@case ('general') {
<app-general-settings />
}
@case ('network') {
<app-network-settings />
}
@case ('voice') {
<app-voice-settings />
}
@case ('updates') {
<app-updates-settings />
}
@case ('debugging') {
<app-debugging-settings />
}
@case ('server') {
<app-server-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerOwner()"
/>
}
@case ('members') {
<app-members-settings
[server]="selectedServer()"
[isAdmin]="canManageSelectedMembers()"
[accessRole]="selectedServerRole()"
/>
}
@case ('bans') {
<app-bans-settings
[server]="selectedServer()"
[isAdmin]="canManageSelectedBans()"
/>
}
@case ('permissions') {
<app-permissions-settings
#permissionsComp
[server]="selectedServer()"
[isAdmin]="isSelectedServerOwner()"
/>
}
}
</div>
</div>
@if (showThirdPartyLicenses()) {
<div
class="absolute inset-0 z-10 bg-background/70 backdrop-blur-sm"
(click)="closeThirdPartyLicenses()"
(keydown.enter)="closeThirdPartyLicenses()"
(keydown.space)="closeThirdPartyLicenses()"
role="button"
tabindex="0"
aria-label="Close third-party licenses"
></div>
<div class="pointer-events-none absolute inset-0 z-[11] flex items-center justify-center p-4 sm:p-6">
<div class="pointer-events-auto w-full max-w-2xl max-h-full overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h4 class="text-base font-semibold text-foreground">Third-party licenses</h4>
<p class="mt-1 text-sm text-muted-foreground">License notices for bundled third-party libraries used by the app.</p>
</div>
<button
type="button"
(click)="closeThirdPartyLicenses()"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
aria-label="Close third-party licenses"
>
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
</div>
<div class="max-h-[min(70vh,42rem)] overflow-y-auto px-5 py-4 space-y-4">
@for (license of thirdPartyLicenses; track license.id) {
<section class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h5 class="text-sm font-semibold text-foreground">{{ license.name }}</h5>
<p class="text-xs text-muted-foreground">{{ license.licenseName }}</p>
</div>
<a
[href]="license.sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-xs font-medium text-primary hover:underline underline-offset-4"
>
Source
</a>
</div>
<pre
class="mt-4 whitespace-pre-wrap break-words rounded-md bg-background/80 px-3 py-2 text-[11px] leading-5 text-muted-foreground"
>{{ license.text }}</pre
>
</section>
}
</div>
</div>
</div>
}
</div>
</div>
}

View File

@@ -0,0 +1,308 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal,
computed,
effect,
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,
lucideDownload,
lucideGlobe,
lucideAudioLines,
lucideSettings,
lucideUsers,
lucideBan,
lucideShield
} from '@ng-icons/lucide';
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
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 { findRoomMember } from '../../../store/rooms/room-members.helpers';
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 { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
@Component({
selector: 'app-settings-modal',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
GeneralSettingsComponent,
NetworkSettingsComponent,
VoiceSettingsComponent,
UpdatesSettingsComponent,
DebuggingSettingsComponent,
ServerSettingsComponent,
MembersSettingsComponent,
BansSettingsComponent,
PermissionsSettingsComponent
],
viewProviders: [
provideIcons({
lucideX,
lucideBug,
lucideDownload,
lucideGlobe,
lucideAudioLines,
lucideSettings,
lucideUsers,
lucideBan,
lucideShield
})
],
templateUrl: './settings-modal.component.html'
})
export class SettingsModalComponent {
readonly modal = inject(SettingsModalService);
private store = inject(Store);
private webrtc = inject(RealtimeSessionFacade);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null;
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;
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'general',
label: 'General',
icon: 'lucideSettings' },
{ id: 'network',
label: 'Network',
icon: 'lucideGlobe' },
{ id: 'voice',
label: 'Voice & Audio',
icon: 'lucideAudioLines' },
{ id: 'updates',
label: 'Updates',
icon: 'lucideDownload' },
{ id: 'debugging',
label: 'Debugging',
icon: 'lucideBug' }
];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server',
label: 'Server',
icon: 'lucideSettings' },
{ 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 [];
return this.savedRooms().filter((room) => {
const role = this.getUserRoleForRoom(room, user.id, user.oderId, this.currentRoom()?.id === room.id ? user.role : null);
return role === 'host' || role === 'admin' || role === 'moderator';
});
});
selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId();
if (!id)
return null;
return 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 this.getUserRoleForRoom(
server,
user.id,
user.oderId,
this.currentRoom()?.id === server.id ? user.role : null
);
});
canAccessSelectedServer = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin' || role === 'moderator';
});
canManageSelectedMembers = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin' || role === 'moderator';
});
canManageSelectedBans = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin';
});
isSelectedServerOwner = computed(() => {
return this.selectedServerRole() === 'host';
});
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);
});
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 */
}
}
});
}
private getUserRoleForRoom(
room: Room,
userId: string,
userOderId: string,
currentRole: UserRole | null
): UserRole | null {
if (room.hostId === userId || room.hostId === userOderId)
return 'host';
if (currentRole)
return currentRole;
return findRoomMember(room.members ?? [], userId)?.role
|| findRoomMember(room.members ?? [], userOderId)?.role
|| null;
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.showThirdPartyLicenses()) {
this.closeThirdPartyLicenses();
return;
}
if (this.isOpen()) {
this.close();
}
}
close(): void {
this.showThirdPartyLicenses.set(false);
this.animating.set(false);
setTimeout(() => this.modal.close(), 200);
}
openThirdPartyLicenses(): void {
this.showThirdPartyLicenses.set(true);
}
closeThirdPartyLicenses(): void {
this.showThirdPartyLicenses.set(false);
}
navigate(page: SettingsPage): void {
this.modal.navigate(page);
}
onBackdropClick(): void {
this.close();
}
onServerSelect(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedServerId.set(select.value || null);
}
}

View File

@@ -0,0 +1,44 @@
export interface ThirdPartyLicense {
id: string;
name: string;
licenseName: string;
sourceUrl: string;
text: string;
}
export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
{
id: 'wavesurfer-js',
name: 'wavesurfer.js',
licenseName: 'BSD 3-Clause License',
sourceUrl: 'https://github.com/katspaugh/wavesurfer.js/blob/main/LICENSE',
text: [
'BSD 3-Clause License',
'',
'Copyright (c) 2012-2023, katspaugh and contributors',
'All rights reserved.',
'',
'Redistribution and use in source and binary forms, with or without modification, are permitted provided',
'that the following conditions are met:',
'',
'* Redistributions of source code must retain the above copyright notice, this list of conditions and',
' the following disclaimer.',
'',
'* Redistributions in binary form must reproduce the above copyright notice, this list of conditions',
' and the following disclaimer in the documentation and/or other materials provided with the',
' distribution.',
'',
'* Neither the name of the copyright holder nor the names of its contributors may be used to endorse',
' or promote products derived from this software without specific prior written permission.',
'',
'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR',
'IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND',
'FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR',
'CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL',
'DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,',
'DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER',
'IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT',
'OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'
].join('\n')
}
];

View File

@@ -0,0 +1,185 @@
<div class="space-y-6">
<section class="rounded-xl border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Desktop app updates</h4>
<p class="mt-1 text-sm text-muted-foreground">
Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart.
</p>
</div>
<span class="inline-flex items-center rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
{{ statusLabel() }}
</span>
</div>
</section>
@if (!isElectron) {
<section class="rounded-xl border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app.</p>
</section>
} @else {
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().currentVersion }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Latest in manifest</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || 'Unknown' }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Target version</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || 'Automatic' }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
</p>
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Update policy</h5>
<p class="mt-1 text-sm text-muted-foreground">
Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely.
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Mode</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="state().autoUpdateMode"
(change)="onModeChange($event)"
>
<option value="auto">Newest release</option>
<option value="version">Specific version</option>
<option value="off">Turn off auto updates</option>
</select>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Pinned version</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="state().autoUpdateMode !== 'version' || state().availableVersions.length === 0"
[value]="state().preferredVersion || ''"
(change)="onVersionChange($event)"
>
<option value="">Choose a release…</option>
@for (version of state().availableVersions; track version) {
<option [value]="version">{{ version }}</option>
}
</select>
</label>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-sm font-medium text-foreground">Status</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ state().statusMessage || 'Waiting for release information from the active server.' }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<button
type="button"
(click)="refreshReleaseInfo()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Refresh release info
</button>
@if (state().restartRequired) {
<button
type="button"
(click)="restartNow()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart to update
</button>
}
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Manifest URL priority</h5>
<p class="mt-1 text-sm text-muted-foreground">
Add one manifest URL per line. The app tries them from top to bottom and falls back to the next URL when a manifest cannot be loaded or is
invalid.
</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
<p class="font-medium text-foreground">
{{ isUsingConnectedServerDefaults() ? 'Using connected server defaults' : 'Using saved manifest URLs' }}
</p>
<p class="mt-1">When this list is empty, the app automatically uses manifest URLs reported by your configured servers.</p>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Manifest URLs</span>
<textarea
rows="6"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="manifestUrlsText()"
(input)="onManifestUrlsInput($event)"
placeholder="https://example.com/releases/latest/download/release-manifest.json"
></textarea>
</label>
@if (!state().defaultManifestUrls.length && isUsingConnectedServerDefaults()) {
<p class="text-sm text-muted-foreground">None of your configured servers currently report a manifest URL.</p>
}
<div class="flex flex-wrap gap-3">
<button
type="button"
(click)="saveManifestUrls()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Save manifest URLs
</button>
<button
type="button"
(click)="useConnectedServerDefaults()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Use connected server defaults
</button>
</div>
</section>
@if (state().serverBlocked) {
<section class="rounded-xl border border-red-500/30 bg-red-500/10 p-5">
<h5 class="text-sm font-semibold text-foreground">Server update required</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ state().serverBlockMessage }}</p>
<div class="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-1">{{ state().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-1">{{ state().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
</section>
}
<section class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Resolved manifest URL</p>
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || 'No working manifest URL has been resolved yet.' }}</p>
</section>
}
</div>

View File

@@ -0,0 +1,142 @@
import {
Component,
computed,
effect,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { DesktopAppUpdateService } from '../../../../core/services/desktop-app-update.service';
type AutoUpdateMode = 'auto' | 'off' | 'version';
type DesktopUpdateStatus =
| 'idle'
| 'disabled'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
@Component({
selector: 'app-updates-settings',
standalone: true,
imports: [CommonModule],
templateUrl: './updates-settings.component.html'
})
export class UpdatesSettingsComponent {
readonly updates = inject(DesktopAppUpdateService);
readonly isElectron = this.updates.isElectron;
readonly state = this.updates.state;
readonly hasPendingManifestUrlChanges = signal(false);
readonly manifestUrlsText = signal('');
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
readonly isUsingConnectedServerDefaults = computed(() => {
return this.state().configuredManifestUrls.length === 0;
});
constructor() {
effect(() => {
if (this.hasPendingManifestUrlChanges()) {
return;
}
this.manifestUrlsText.set(this.stringifyManifestUrls(
this.isUsingConnectedServerDefaults()
? this.state().defaultManifestUrls
: this.state().configuredManifestUrls
));
});
}
async onModeChange(event: Event): Promise<void> {
const select = event.target as HTMLSelectElement;
const mode = select.value as AutoUpdateMode;
const preferredVersion = mode === 'version'
? this.state().preferredVersion ?? this.state().availableVersions[0] ?? null
: this.state().preferredVersion;
await this.updates.saveUpdatePreferences(mode, preferredVersion);
}
async onVersionChange(event: Event): Promise<void> {
const select = event.target as HTMLSelectElement;
await this.updates.saveUpdatePreferences('version', select.value || null);
}
async refreshReleaseInfo(): Promise<void> {
await this.updates.refreshServerContext();
await this.updates.checkForUpdates();
}
onManifestUrlsInput(event: Event): void {
const textarea = event.target as HTMLTextAreaElement;
this.hasPendingManifestUrlChanges.set(true);
this.manifestUrlsText.set(textarea.value);
}
async saveManifestUrls(): Promise<void> {
await this.updates.saveManifestUrls(
this.parseManifestUrls(this.manifestUrlsText())
);
this.hasPendingManifestUrlChanges.set(false);
}
async useConnectedServerDefaults(): Promise<void> {
await this.updates.saveManifestUrls([]);
this.hasPendingManifestUrlChanges.set(false);
}
async restartNow(): Promise<void> {
await this.updates.restartToApplyUpdate();
}
private parseManifestUrls(rawValue: string): string[] {
return [
...new Set(
rawValue
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
)
];
}
private stringifyManifestUrls(manifestUrls: string[]): string {
return manifestUrls.join('\n');
}
private getStatusLabel(status: DesktopUpdateStatus): string {
switch (status) {
case 'checking':
return 'Checking';
case 'downloading':
return 'Downloading';
case 'restart-required':
return 'Restart required';
case 'up-to-date':
return 'Up to date';
case 'disabled':
return 'Disabled';
case 'unsupported':
return 'Unsupported';
case 'no-manifest':
return 'Manifest missing';
case 'target-unavailable':
return 'Version unavailable';
case 'target-older-than-installed':
return 'Pinned below current';
case 'error':
return 'Error';
default:
return 'Idle';
}
}
}

View File

@@ -0,0 +1,327 @@
<div class="space-y-6 max-w-xl">
<!-- Devices -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucideMic"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
</div>
<div class="space-y-3">
<div>
<label
for="input-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Microphone</label
>
<select
(change)="onInputDeviceChange($event)"
id="input-device-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (device of inputDevices(); track device.deviceId) {
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedInputDevice()"
>
{{ device.label || 'Microphone ' + $index }}
</option>
}
</select>
</div>
<div>
<label
for="output-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Speaker</label
>
<select
(change)="onOutputDeviceChange($event)"
id="output-device-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (device of outputDevices(); track device.deviceId) {
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedOutputDevice()"
>
{{ device.label || 'Speaker ' + $index }}
</option>
}
</select>
</div>
</div>
</section>
<!-- Volume -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucideHeadphones"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
</div>
<div class="space-y-3">
<div>
<label
for="input-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Input Volume: {{ inputVolume() }}%
</label>
<input
type="range"
[value]="inputVolume()"
(input)="onInputVolumeChange($event)"
min="0"
max="100"
id="input-volume-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label
for="output-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Output Volume: {{ outputVolume() }}%
</label>
<input
type="range"
[value]="outputVolume()"
(input)="onOutputVolumeChange($event)"
min="0"
max="200"
id="output-volume-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label
for="notification-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
</label>
<div class="flex items-center gap-2">
<input
type="range"
[value]="audioService.notificationVolume()"
(input)="onNotificationVolumeChange($event)"
min="0"
max="1"
step="0.01"
id="notification-volume-slider"
class="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
<button
(click)="previewNotificationSound()"
type="button"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors flex-shrink-0"
title="Preview notification sound"
>
Test
</button>
</div>
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave &amp; notification sounds</p>
</div>
</div>
</section>
<!-- Quality & Processing -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
</div>
<div class="space-y-3">
<div>
<label
for="latency-profile-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Latency Profile</label
>
<select
(change)="onLatencyProfileChange($event)"
id="latency-profile-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option
value="low"
[selected]="latencyProfile() === 'low'"
>
Low (fast)
</option>
<option
value="balanced"
[selected]="latencyProfile() === 'balanced'"
>
Balanced
</option>
<option
value="high"
[selected]="latencyProfile() === 'high'"
>
High (quality)
</option>
</select>
</div>
<div>
<label
for="audio-bitrate-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Audio Bitrate: {{ audioBitrate() }} kbps
</label>
<input
type="range"
[value]="audioBitrate()"
(input)="onAudioBitrateChange($event)"
min="32"
max="256"
step="8"
id="audio-bitrate-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label
for="screen-share-quality-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Screen share quality</label
>
<select
(change)="onScreenShareQualityChange($event)"
id="screen-share-quality-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (option of screenShareQualityOptions; track option.id) {
<option
[value]="option.id"
[selected]="screenShareQuality() === option.id"
>
{{ option.label }}
</option>
}
</select>
<p class="text-[10px] text-muted-foreground/60 mt-1">
{{ selectedScreenShareQualityDescription() }}
</p>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Ask before screen sharing</p>
<p class="text-xs text-muted-foreground">Let the user confirm quality before each new screen share</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="askScreenShareQuality()"
(change)="onAskScreenShareQualityChange($event)"
id="ask-screen-share-quality-toggle"
aria-label="Toggle screen share quality prompt"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Noise reduction</p>
<p class="text-xs text-muted-foreground">Suppress background noise using RNNoise</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="noiseReduction()"
(change)="onNoiseReductionChange()"
id="noise-reduction-toggle"
aria-label="Toggle noise reduction"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Screen share system audio</p>
<p class="text-xs text-muted-foreground">Share other computer audio while filtering MeToYou audio when supported</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
id="system-audio-toggle"
aria-label="Toggle system audio in screen share"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
<p class="text-[10px] text-muted-foreground/60 -mt-1">
Your microphone stays on the normal voice channel. The shared screen audio should only contain desktop sound.
</p>
</div>
</section>
@if (isElectron) {
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucideCpu"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Desktop Performance</h4>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Hardware acceleration</p>
<p class="text-xs text-muted-foreground">Use GPU acceleration for rendering and WebRTC when available</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="hardwareAcceleration()"
(change)="onHardwareAccelerationChange($event)"
id="hardware-acceleration-toggle"
aria-label="Toggle hardware acceleration"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
@if (hardwareAccelerationRestartRequired()) {
<div class="rounded-lg border border-primary/30 bg-primary/10 p-3 flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">Restart required</p>
<p class="text-xs text-muted-foreground">Restart MeToYou to apply the new hardware acceleration setting.</p>
</div>
<button
type="button"
(click)="restartDesktopApp()"
class="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors"
>
Restart app
</button>
</div>
}
</div>
</section>
}
</div>

View File

@@ -0,0 +1,281 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
computed,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideHeadphones,
lucideAudioLines,
lucideMonitor,
lucideCpu
} from '@ng-icons/lucide';
import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session';
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/platform';
interface AudioDevice {
deviceId: string;
label: string;
}
@Component({
selector: 'app-voice-settings',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideMic,
lucideHeadphones,
lucideAudioLines,
lucideMonitor,
lucideCpu
})
],
templateUrl: './voice-settings.component.html'
})
export class VoiceSettingsComponent {
private voiceConnection = inject(VoiceConnectionFacade);
private voicePlayback = inject(VoicePlaybackService);
private electronBridge = inject(ElectronBridgeService);
private platform = inject(PlatformService);
readonly audioService = inject(NotificationAudioService);
readonly isElectron = this.platform.isElectron;
readonly screenShareQualityOptions = SCREEN_SHARE_QUALITY_OPTIONS;
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>('');
inputVolume = signal(100);
outputVolume = signal(100);
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(true);
screenShareQuality = signal<ScreenShareQuality>('balanced');
askScreenShareQuality = signal(true);
hardwareAcceleration = signal(true);
hardwareAccelerationRestartRequired = signal(false);
readonly selectedScreenShareQualityDescription = computed(
() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? ''
);
constructor() {
this.loadVoiceSettings();
this.loadAudioDevices();
if (this.isElectron) {
void this.loadDesktopSettings();
}
}
async loadAudioDevices(): Promise<void> {
try {
if (!navigator.mediaDevices?.enumerateDevices)
return;
const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set(
devices
.filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId,
label: device.label }))
);
this.outputDevices.set(
devices
.filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId,
label: device.label }))
);
} catch {}
}
loadVoiceSettings(): void {
const settings = loadVoiceSettingsFromStorage();
this.selectedInputDevice.set(settings.inputDevice);
this.selectedOutputDevice.set(settings.outputDevice);
this.inputVolume.set(settings.inputVolume);
this.outputVolume.set(settings.outputVolume);
this.audioBitrate.set(settings.audioBitrate);
this.latencyProfile.set(settings.latencyProfile);
this.includeSystemAudio.set(settings.includeSystemAudio);
this.noiseReduction.set(settings.noiseReduction);
this.screenShareQuality.set(settings.screenShareQuality);
this.askScreenShareQuality.set(settings.askScreenShareQuality);
if (this.noiseReduction() !== this.voiceConnection.isNoiseReductionEnabled()) {
this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
}
// Apply persisted volume levels to the live audio pipelines
this.voiceConnection.setInputVolume(this.inputVolume() / 100);
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
}
saveVoiceSettings(): void {
saveVoiceSettingsToStorage({
inputDevice: this.selectedInputDevice(),
outputDevice: this.selectedOutputDevice(),
inputVolume: this.inputVolume(),
outputVolume: this.outputVolume(),
audioBitrate: this.audioBitrate(),
latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(),
noiseReduction: this.noiseReduction(),
screenShareQuality: this.screenShareQuality(),
askScreenShareQuality: this.askScreenShareQuality()
});
}
onInputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedInputDevice.set(select.value);
this.saveVoiceSettings();
}
onOutputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value);
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings();
}
onInputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10));
this.voiceConnection.setInputVolume(this.inputVolume() / 100);
this.saveVoiceSettings();
}
onOutputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.outputVolume.set(parseInt(input.value, 10));
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings();
}
onLatencyProfileChange(event: Event): void {
const select = event.target as HTMLSelectElement;
const profile = select.value as 'low' | 'balanced' | 'high';
this.latencyProfile.set(profile);
this.voiceConnection.setLatencyProfile(profile);
this.saveVoiceSettings();
}
onAudioBitrateChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.audioBitrate.set(parseInt(input.value, 10));
this.voiceConnection.setAudioBitrate(this.audioBitrate());
this.saveVoiceSettings();
}
onIncludeSystemAudioChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.includeSystemAudio.set(!!input.checked);
this.saveVoiceSettings();
}
onScreenShareQualityChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.screenShareQuality.set(select.value as ScreenShareQuality);
this.saveVoiceSettings();
}
onAskScreenShareQualityChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.askScreenShareQuality.set(!!input.checked);
this.saveVoiceSettings();
}
async onNoiseReductionChange(): Promise<void> {
this.noiseReduction.update((currentValue) => !currentValue);
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
this.saveVoiceSettings();
}
onNotificationVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.audioService.setNotificationVolume(parseFloat(input.value));
}
previewNotificationSound(): void {
this.audioService.play(AppSound.Notification);
}
async onHardwareAccelerationChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const enabled = !!input.checked;
const api = this.electronBridge.getApi();
if (!api) {
this.hardwareAcceleration.set(enabled);
return;
}
try {
const snapshot = await api.setDesktopSettings({ hardwareAcceleration: enabled });
this.applyDesktopSettings(snapshot);
} catch {
input.checked = this.hardwareAcceleration();
}
}
async restartDesktopApp(): Promise<void> {
const api = this.electronBridge.getApi();
if (api) {
await api.relaunchApp();
}
}
private async loadDesktopSettings(): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
try {
const snapshot = await api.getDesktopSettings();
this.applyDesktopSettings(snapshot);
} catch {}
}
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
}
}

View File

@@ -0,0 +1,292 @@
<div class="p-6 max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<button
type="button"
(click)="goBack()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Go back"
>
<ng-icon
name="lucideArrowLeft"
class="w-5 h-5 text-muted-foreground"
/>
</button>
<ng-icon
name="lucideSettings"
class="w-6 h-6 text-primary"
/>
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
</div>
<!-- Server Endpoints Section -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<ng-icon
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
</div>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
<button
type="button"
(click)="restoreDefaultServers()"
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Restore Defaults
</button>
}
<button
type="button"
(click)="testAllServers()"
[disabled]="isTesting()"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon
name="lucideRefreshCw"
class="w-4 h-4"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
</div>
</div>
<p class="text-sm text-muted-foreground mb-4">
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
</p>
<!-- Server List -->
<div class="space-y-3 mb-4">
@for (server of servers(); track server.id) {
<div
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
[class.border-primary]="server.isActive"
[class.bg-primary/5]="server.isActive"
[class.border-border]="!server.isActive"
[class.bg-secondary/30]="!server.isActive"
>
<!-- Status Indicator -->
<div
class="w-3 h-3 rounded-full flex-shrink-0"
[class.bg-green-500]="server.status === 'online'"
[class.bg-red-500]="server.status === 'offline'"
[class.bg-yellow-500]="server.status === 'checking'"
[class.bg-muted]="server.status === 'unknown'"
[class.bg-orange-500]="server.status === 'incompatible'"
[title]="server.status"
></div>
<!-- Server Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
}
</div>
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
@if (server.latency !== undefined && server.status === 'online') {
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-xs text-destructive">Update the client in order to connect to other users</p>
}
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
@if (!server.isActive && server.status !== 'incompatible') {
<button
type="button"
(click)="setActiveServer(server.id)"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Activate"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4 text-muted-foreground hover:text-primary"
/>
</button>
}
@if (server.isActive && hasMultipleActiveServers()) {
<button
type="button"
(click)="deactivateServer(server.id)"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Deactivate"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-muted-foreground hover:text-foreground"
/>
</button>
}
@if (hasMultipleServers()) {
<button
type="button"
(click)="removeServer(server.id)"
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
title="Remove server"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4 text-muted-foreground hover:text-destructive"
/>
</button>
}
</div>
</div>
}
</div>
<!-- Add New Server -->
<div class="border-t border-border pt-4">
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
<div class="flex gap-3">
<div class="flex-1 space-y-2">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name (e.g., My Server)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
type="button"
(click)="addServer()"
[disabled]="!newServerName || !newServerUrl"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
>
<ng-icon
name="lucidePlus"
class="w-5 h-5"
/>
</button>
</div>
@if (addError()) {
<p class="text-sm text-destructive mt-2">{{ addError() }}</p>
}
</div>
</div>
<!-- Connection Settings -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center gap-2 mb-4">
<ng-icon
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Auto-reconnect</p>
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="autoReconnect"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Search all servers</p>
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="searchAllServers"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
</div>
</div>
<!-- Voice Settings -->
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<ng-icon
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
</div>
<div class="space-y-4">
<!-- Notification Volume -->
<div>
<div class="flex items-center justify-between mb-2">
<div>
<p class="font-medium text-foreground">Notification volume</p>
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
</div>
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
</span>
</div>
<div class="flex items-center gap-3">
<input
type="range"
min="0"
max="1"
step="0.01"
[ngModel]="audioService.notificationVolume()"
(ngModelChange)="onNotificationVolumeChange($event)"
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
/>
<button
type="button"
(click)="previewNotificationSound()"
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
title="Preview sound"
>
Test
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Noise reduction</p>
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="noiseReduction"
(change)="saveVoiceSettings()"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,222 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal,
OnInit,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideServer,
lucidePlus,
lucideTrash2,
lucideCheck,
lucideX,
lucideSettings,
lucideRefreshCw,
lucideGlobe,
lucideArrowLeft,
lucideAudioLines
} from '@ng-icons/lucide';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import { VoiceConnectionFacade } from '../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
@Component({
selector: 'app-settings',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideServer,
lucidePlus,
lucideTrash2,
lucideCheck,
lucideX,
lucideSettings,
lucideRefreshCw,
lucideGlobe,
lucideArrowLeft,
lucideAudioLines
})
],
templateUrl: './settings.component.html'
})
/**
* Settings page for managing signaling servers and connection preferences.
*/
export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private router = inject(Router);
audioService = inject(NotificationAudioService);
servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers;
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
hasMultipleServers = computed(() => this.servers().length > 1);
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
isTesting = signal(false);
addError = signal<string | null>(null);
newServerName = '';
newServerUrl = '';
autoReconnect = true;
searchAllServers = true;
noiseReduction = true;
/** Load persisted connection settings on component init. */
ngOnInit(): void {
this.loadConnectionSettings();
this.loadVoiceSettings();
}
/** Add a new signaling server after URL validation and duplicate checking. */
addServer(): void {
this.addError.set(null);
// Validate URL
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
return;
}
// Check for duplicates
if (this.servers().some((server) => server.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
return;
}
this.serverDirectory.addServer({
name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, '') // Remove trailing slash
});
// Clear form
this.newServerName = '';
this.newServerUrl = '';
// Test the new server
const servers = this.servers();
const newServer = servers[servers.length - 1];
if (newServer) {
this.serverDirectory.testServer(newServer.id);
}
}
/** Remove a signaling server by its ID. */
removeServer(id: string): void {
this.serverDirectory.removeServer(id);
}
/** Set the active signaling server used for connections. */
setActiveServer(id: string): void {
this.serverDirectory.setActiveServer(id);
}
deactivateServer(id: string): void {
this.serverDirectory.deactivateServer(id);
}
restoreDefaultServers(): void {
this.serverDirectory.restoreDefaultServers();
}
/** Test connectivity to all configured servers. */
async testAllServers(): Promise<void> {
this.isTesting.set(true);
await this.serverDirectory.testAllServers();
this.isTesting.set(false);
}
/** Load connection settings (auto-reconnect, search scope) from localStorage. */
loadConnectionSettings(): void {
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (settings) {
const parsed = JSON.parse(settings);
this.autoReconnect = parsed.autoReconnect ?? true;
this.searchAllServers = parsed.searchAllServers ?? true;
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
}
/** Persist current connection settings to localStorage. */
saveConnectionSettings(): void {
localStorage.setItem(
STORAGE_KEY_CONNECTION_SETTINGS,
JSON.stringify({
autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers
})
);
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
/** Navigate back to the main page. */
goBack(): void {
this.router.navigate(['/']);
}
/** Load voice settings (noise reduction) from localStorage. */
loadVoiceSettings(): void {
const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (settings) {
const parsed = JSON.parse(settings);
this.noiseReduction = parsed.noiseReduction ?? false;
}
// Sync the live WebRTC state with the persisted preference
if (this.noiseReduction !== this.voiceConnection.isNoiseReductionEnabled()) {
this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
}
}
/** Called when the notification volume slider changes. */
onNotificationVolumeChange(value: number): void {
this.audioService.setNotificationVolume(value);
}
/** Play a preview of the notification sound at the current volume. */
previewNotificationSound(): void {
this.audioService.play(AppSound.Notification);
}
/** Persist noise reduction preference (merged into existing voice settings) and apply immediately. */
async saveVoiceSettings(): Promise<void> {
// Merge into existing voice settings so we don't overwrite device/volume prefs
let existing: Record<string, unknown> = {};
try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (raw)
existing = JSON.parse(raw);
} catch {}
localStorage.setItem(
STORAGE_KEY_VOICE_SETTINGS,
JSON.stringify({ ...existing,
noiseReduction: this.noiseReduction })
);
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
}
}