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,438 @@
@if (isAdmin()) {
<div class="h-full flex flex-col bg-card">
<!-- Header -->
<div class="p-4 border-b border-border flex items-center gap-2">
<ng-icon
name="lucideShield"
class="w-5 h-5 text-primary"
/>
<h2 class="font-semibold text-foreground">Admin Panel</h2>
</div>
<!-- Tabs -->
<div class="flex border-b border-border">
<button
type="button"
(click)="activeTab.set('settings')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'settings'"
[class.border-b-2]="activeTab() === 'settings'"
[class.border-primary]="activeTab() === 'settings'"
[class.text-muted-foreground]="activeTab() !== 'settings'"
>
<ng-icon
name="lucideSettings"
class="w-4 h-4 inline mr-1"
/>
Settings
</button>
<button
type="button"
(click)="activeTab.set('members')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'members'"
[class.border-b-2]="activeTab() === 'members'"
[class.border-primary]="activeTab() === 'members'"
[class.text-muted-foreground]="activeTab() !== 'members'"
>
<ng-icon
name="lucideUsers"
class="w-4 h-4 inline mr-1"
/>
Members
</button>
<button
type="button"
(click)="activeTab.set('bans')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'bans'"
[class.border-b-2]="activeTab() === 'bans'"
[class.border-primary]="activeTab() === 'bans'"
[class.text-muted-foreground]="activeTab() !== 'bans'"
>
<ng-icon
name="lucideBan"
class="w-4 h-4 inline mr-1"
/>
Bans
</button>
<button
type="button"
(click)="activeTab.set('permissions')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'permissions'"
[class.border-b-2]="activeTab() === 'permissions'"
[class.border-primary]="activeTab() === 'permissions'"
[class.text-muted-foreground]="activeTab() !== 'permissions'"
>
<ng-icon
name="lucideShield"
class="w-4 h-4 inline mr-1"
/>
Perms
</button>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-y-auto p-4">
@switch (activeTab()) {
@case ('settings') {
<div class="space-y-6">
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
<!-- Room Name -->
<div>
<label
for="room-name-input"
class="block text-sm text-muted-foreground mb-1"
>Room Name</label
>
<input
type="text"
id="room-name-input"
[(ngModel)]="roomName"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Room Description -->
<div>
<label
for="room-description-input"
class="block text-sm text-muted-foreground mb-1"
>Description</label
>
<textarea
id="room-description-input"
[(ngModel)]="roomDescription"
rows="3"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
></textarea>
</div>
<!-- Private Room Toggle -->
<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
type="button"
(click)="togglePrivate()"
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>
<!-- Max Users -->
<div>
<label
for="max-users-input"
class="block text-sm text-muted-foreground mb-1"
>Max Users (0 = unlimited)</label
>
<input
type="number"
id="max-users-input"
[(ngModel)]="maxUsers"
min="0"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Save Button -->
<button
type="button"
(click)="saveSettings()"
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"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Settings
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
<button
type="button"
(click)="confirmDeleteRoom()"
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"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
</div>
}
@case ('members') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
@if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
} @else {
@for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar
[name]="user.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">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
<!-- Role actions (only for non-hosts) -->
@if (user.role !== 'host') {
<div class="flex items-center gap-1">
<select
[ngModel]="user.role"
(ngModelChange)="changeRole(user, $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>
<button
type="button"
(click)="kickMember(user)"
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>
<button
type="button"
(click)="banMember(user)"
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>
}
@case ('bans') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
@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>
<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>
}
@case ('permissions') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
<!-- Permission Toggles -->
<div class="space-y-3">
<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"
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"
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"
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"
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 chat & voice rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageRooms"
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 chat & voice rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageRooms"
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"
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"
class="w-4 h-4 accent-primary"
/>
</div>
</div>
<!-- Save Permissions -->
<button
type="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"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Permissions
</button>
</div>
}
}
</div>
</div>
<!-- Delete Confirmation 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="h-full flex items-center justify-center text-muted-foreground">
<p>You don't have admin permissions</p>
</div>
}

View File

@@ -0,0 +1,231 @@
import {
Component,
inject,
signal
} 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 {
lucideShield,
lucideBan,
lucideUserX,
lucideSettings,
lucideUsers,
lucideTrash2,
lucideCheck,
lucideX,
lucideLock,
lucideUnlock
} from '@ng-icons/lucide';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import {
selectBannedUsers,
selectIsCurrentUserAdmin,
selectCurrentUser,
selectOnlineUsers
} from '../../../store/users/users.selectors';
import { BanEntry, User } from '../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
@Component({
selector: 'app-admin-panel',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideShield,
lucideBan,
lucideUserX,
lucideSettings,
lucideUsers,
lucideTrash2,
lucideCheck,
lucideX,
lucideLock,
lucideUnlock
})
],
templateUrl: './admin-panel.component.html'
})
/**
* Admin panel for managing room settings, members, bans, and permissions.
* Only accessible to users with admin privileges.
*/
export class AdminPanelComponent {
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
bannedUsers = this.store.selectSignal(selectBannedUsers);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeTab = signal<AdminTab>('settings');
showDeleteConfirm = signal(false);
// Settings
roomName = '';
roomDescription = '';
isPrivate = signal(false);
maxUsers = 0;
// Permissions
allowVoice = true;
allowScreenShare = true;
allowFileUploads = true;
slowModeInterval = '0';
adminsManageRooms = false;
moderatorsManageRooms = false;
adminsManageIcon = false;
moderatorsManageIcon = false;
private webrtc = inject(RealtimeSessionFacade);
constructor() {
// Initialize from current room
const room = this.currentRoom();
if (room) {
this.roomName = room.name;
this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate);
this.maxUsers = room.maxUsers || 0;
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;
}
}
/** Toggle the room's private visibility setting. */
togglePrivate(): void {
this.isPrivate.update((current) => !current);
}
/** Save the current room name, description, privacy, and max-user settings. */
saveSettings(): void {
const room = this.currentRoom();
if (!room)
return;
this.store.dispatch(
RoomsActions.updateRoomSettings({
roomId: room.id,
settings: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
}
})
);
}
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
savePermissions(): void {
const room = this.currentRoom();
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
}
})
);
}
/** Remove a user's ban entry. */
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
oderId: ban.oderId }));
}
/** Show the delete-room confirmation dialog. */
confirmDeleteRoom(): void {
this.showDeleteConfirm.set(true);
}
/** Delete the current room after confirmation. */
deleteRoom(): void {
const room = this.currentRoom();
if (!room)
return;
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
this.showDeleteConfirm.set(false);
}
/** Format a ban expiry timestamp into a human-readable date/time string. */
formatExpiry(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' });
}
// Members tab: get all users except self
/** Return online users excluding the current user (for the members list). */
membersFiltered(): User[] {
const me = this.currentUser();
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
}
/** Change a member's role and notify connected peers. */
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
const roomId = this.currentRoom()?.id;
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtc.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});
}
/** Kick a member from the server. */
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
}
/** Ban a member from the server. */
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
}
}

View File

@@ -0,0 +1,54 @@
<div class="h-full flex flex-col bg-background">
@if (currentRoom()) {
<!-- Channel header bar -->
@if (!isVoiceWorkspaceExpanded()) {
<div class="h-12 flex items-center gap-2 px-4 border-b border-border bg-card flex-shrink-0">
<ng-icon
[name]="isVoiceWorkspaceExpanded() ? 'lucideMonitor' : 'lucideHash'"
class="w-4 h-4 text-muted-foreground"
/>
<span class="font-medium text-foreground text-sm">{{ headerTitle() }}</span>
@if (isVoiceWorkspaceExpanded()) {
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-primary">
Voice streams
</span>
}
<div class="flex-1"></div>
</div>
}
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Chat Area -->
<main class="relative flex-1 min-w-0">
<div
class="h-full overflow-hidden"
[class.hidden]="isVoiceWorkspaceExpanded()"
>
<app-chat-messages />
</div>
<app-screen-share-workspace />
</main>
<!-- Sidebar always visible -->
<aside class="w-80 flex-shrink-0 border-l border-border">
<app-rooms-side-panel class="h-full" />
</aside>
</div>
} @else {
<!-- No Room Selected -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-muted-foreground">
<ng-icon
name="lucideHash"
class="w-16 h-16 mx-auto mb-4 opacity-30"
/>
<h2 class="text-xl font-medium mb-2">No room selected</h2>
<p class="text-sm">Select or create a room to start chatting</p>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,104 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideHash,
lucideMonitor,
lucideSettings,
lucideUsers,
lucideMenu,
lucideX,
lucideChevronLeft
} from '@ng-icons/lucide';
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
import { ScreenShareWorkspaceComponent } from '../../../domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component';
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
selectVoiceChannels
} from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
@Component({
selector: 'app-chat-room',
standalone: true,
imports: [
CommonModule,
NgIcon,
ChatMessagesComponent,
ScreenShareWorkspaceComponent,
RoomsSidePanelComponent
],
viewProviders: [
provideIcons({
lucideHash,
lucideMonitor,
lucideSettings,
lucideUsers,
lucideMenu,
lucideX,
lucideChevronLeft
})
],
templateUrl: './chat-room.component.html'
})
/**
* Main chat room view combining the messages panel, side panels, and admin controls.
*/
export class ChatRoomComponent {
private store = inject(Store);
private settingsModal = inject(SettingsModalService);
private voiceWorkspace = inject(VoiceWorkspaceService);
showMenu = signal(false);
showAdminPanel = signal(false);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
currentUser = this.store.selectSignal(selectCurrentUser);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
activeTextChannelName = computed(() => {
const id = this.activeChannelId();
const activeChannel = this.textChannels().find((channel) => channel.id === id);
return activeChannel ? activeChannel.name : id;
});
connectedVoiceChannelName = computed(() => {
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
return voiceChannel?.name || 'Voice Lounge';
});
headerTitle = computed(() =>
this.isVoiceWorkspaceExpanded()
? this.connectedVoiceChannelName()
: this.activeTextChannelName()
);
/** Open the settings modal to the Server admin page for the current room. */
toggleAdminPanel() {
const room = this.currentRoom();
if (room) {
this.settingsModal.open('server', room.id);
}
}
}

View File

@@ -0,0 +1,489 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
<aside class="w-80 bg-card h-full flex flex-col">
<!-- Minimalistic header with tabs -->
<div class="border-b border-border">
<div class="flex items-center">
<!-- Tab buttons -->
<button
(click)="activeTab.set('channels')"
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
[class.border-primary]="activeTab() === 'channels'"
[class.text-foreground]="activeTab() === 'channels'"
[class.border-transparent]="activeTab() !== 'channels'"
[class.text-muted-foreground]="activeTab() !== 'channels'"
[class.hover:text-foreground]="activeTab() !== 'channels'"
>
<ng-icon
name="lucideHash"
class="w-4 h-4"
/>
<span>Channels</span>
</button>
<button
(click)="activeTab.set('users')"
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
[class.border-primary]="activeTab() === 'users'"
[class.text-foreground]="activeTab() === 'users'"
[class.border-transparent]="activeTab() !== 'users'"
[class.text-muted-foreground]="activeTab() !== 'users'"
[class.hover:text-foreground]="activeTab() !== 'users'"
>
<ng-icon
name="lucideUsers"
class="w-4 h-4"
/>
<span>Users</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ knownUserCount() }}</span>
</button>
</div>
</div>
<!-- Channels View -->
@if (activeTab() === 'channels') {
<div class="flex-1 overflow-auto">
<!-- Text Channels -->
<div class="p-3">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('text')"
class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Text Channel"
>
<ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
</button>
}
</div>
<div class="space-y-0.5">
@for (ch of textChannels(); track ch.id) {
<button
class="w-full px-2 py-1.5 text-sm rounded flex items-center gap-2 text-left transition-colors"
[class.bg-secondary]="activeChannelId() === ch.id"
[class.text-foreground]="activeChannelId() === ch.id"
[class.font-medium]="activeChannelId() === ch.id"
[class.text-foreground/60]="activeChannelId() !== ch.id"
[class.hover:bg-secondary/60]="activeChannelId() !== ch.id"
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
(click)="selectTextChannel(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
>
<span class="text-muted-foreground text-base">#</span>
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span class="truncate">{{ ch.name }}</span>
}
</button>
}
</div>
</div>
<!-- Voice Channels -->
<div class="p-3 pt-0">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('voice')"
class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Voice Channel"
>
<ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
</button>
}
</div>
@if (!voiceEnabled()) {
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
}
<div class="space-y-1">
@for (ch of voiceChannels(); track ch.id) {
<div>
<button
class="w-full px-2 py-1.5 text-sm rounded hover:bg-secondary/60 flex items-center justify-between text-left transition-colors"
(click)="joinVoice(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()"
[title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'"
>
<span class="flex items-center gap-2 text-foreground/80">
<ng-icon
name="lucideMic"
class="w-4 h-4 text-muted-foreground"
/>
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span>{{ ch.name }}</span>
}
</span>
@if (isCurrentRoom(ch.id)) {
<span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
{{ isVoiceWorkspaceExpanded() ? 'Open' : 'View' }}
</span>
} @else if (voiceOccupancy(ch.id) > 0) {
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
}
</button>
<!-- Voice users connected to this channel -->
@if (voiceUsersInRoom(ch.id).length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom(ch.id); track u.id) {
<div
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40"
(contextmenu)="openVoiceUserVolumeMenu($event, u)"
>
<app-user-avatar
[name]="u.displayName"
[avatarUrl]="u.avatarUrl"
size="xs"
[ringClass]="
u.voiceState?.isDeafened
? 'ring-2 ring-red-500'
: u.voiceState?.isMuted
? 'ring-2 ring-yellow-500'
: voiceActivity.isSpeaking(u.oderId || u.id)()
? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]'
: 'ring-2 ring-green-500/40'
"
/>
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
<!-- Ping latency indicator -->
@if (u.id !== currentUser()?.id) {
<span
class="w-2 h-2 rounded-full shrink-0"
[class]="getPingColorClass(u)"
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
></span>
}
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
<button
(click)="viewStream(u.oderId || u.id); $event.stopPropagation()"
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
>
LIVE
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon
name="lucideMicOff"
class="w-4 h-4 text-muted-foreground"
/>
}
@if (isUserLocallyMuted(u)) {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4 text-destructive"
title="Muted by you"
/>
}
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
}
<!-- Users View -->
@if (activeTab() === 'users') {
<div class="flex-1 overflow-auto p-3">
<!-- Current User (You) -->
@if (currentUser()) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
<div class="relative">
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
[avatarUrl]="currentUser()?.avatarUrl"
size="sm"
/>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
<div class="flex items-center gap-2">
@if (currentUser()?.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice
</p>
}
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
<button
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse hover:bg-red-600 transition-colors"
(click)="viewStream(currentUser()!.oderId || currentUser()!.id); $event.stopPropagation()"
>
<ng-icon
name="lucideMonitor"
class="w-2.5 h-2.5"
/>
LIVE
</button>
}
</div>
</div>
</div>
</div>
}
<!-- Other Online Users -->
@if (onlineRoomUsers().length > 0) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineRoomUsers().length }}</h4>
<div class="space-y-1">
@for (user of onlineRoomUsers(); track user.id) {
<div
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
<app-user-avatar
[name]="user.displayName"
[avatarUrl]="user.avatarUrl"
size="sm"
/>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
<div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice
</p>
}
@if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
<button
(click)="viewStream(user.oderId || user.id); $event.stopPropagation()"
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
>
<ng-icon
name="lucideMonitor"
class="w-2.5 h-2.5"
/>
LIVE
</button>
}
</div>
</div>
</div>
}
</div>
</div>
}
<!-- Offline Users -->
@if (offlineRoomMembers().length > 0) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
<div class="space-y-1">
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded opacity-80">
<div class="relative">
<app-user-avatar
[name]="member.displayName"
[avatarUrl]="member.avatarUrl"
size="sm"
/>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-gray-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground/80 truncate">{{ member.displayName }}</p>
@if (member.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
<p class="text-[10px] text-muted-foreground">Offline</p>
</div>
</div>
}
</div>
</div>
}
<!-- No other users message -->
@if (onlineRoomUsers().length === 0 && offlineRoomMembers().length === 0) {
<div class="text-center py-4 text-muted-foreground">
<p class="text-sm">No other users in this server</p>
</div>
}
</div>
}
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
@if (voiceEnabled()) {
<div [class.invisible]="showFloatingControls()">
<app-voice-controls />
</div>
}
</aside>
<!-- Channel context menu -->
@if (showChannelMenu()) {
<app-context-menu
[x]="channelMenuX()"
[y]="channelMenuY()"
(closed)="closeChannelMenu()"
[width]="'w-44'"
>
<button
(click)="resyncMessages()"
class="context-menu-item"
>
Resync Messages
</button>
@if (canManageChannels()) {
<div class="context-menu-divider"></div>
<button
(click)="startRename()"
class="context-menu-item"
>
Rename Channel
</button>
<button
(click)="deleteChannel()"
class="context-menu-item-danger"
>
Delete Channel
</button>
}
</app-context-menu>
}
<!-- User context menu (kick / role management) -->
@if (showUserMenu()) {
<app-context-menu
[x]="userMenuX()"
[y]="userMenuY()"
(closed)="closeUserMenu()"
>
@if (isAdmin()) {
@if (contextMenuUser()?.role === 'member') {
<button
(click)="changeUserRole('moderator')"
class="context-menu-item"
>
Promote to Moderator
</button>
<button
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin
</button>
}
@if (contextMenuUser()?.role === 'moderator') {
<button
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin
</button>
<button
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member
</button>
}
@if (contextMenuUser()?.role === 'admin') {
<button
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member
</button>
}
<div class="context-menu-divider"></div>
<button
(click)="kickUserAction()"
class="context-menu-item-danger"
>
Kick User
</button>
} @else {
<div class="context-menu-empty">No actions available</div>
}
</app-context-menu>
}
<!-- Per-user volume context menu -->
@if (showVolumeMenu()) {
<app-user-volume-menu
[x]="volumeMenuX()"
[y]="volumeMenuY()"
[peerId]="volumeMenuPeerId()"
[displayName]="volumeMenuDisplayName()"
(closed)="showVolumeMenu.set(false)"
/>
}
<!-- Create channel dialog -->
@if (showCreateChannelDialog()) {
<app-confirm-dialog
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
confirmLabel="Create"
(confirmed)="confirmCreateChannel()"
(cancelled)="cancelCreateChannel()"
>
<input
type="text"
[(ngModel)]="newChannelName"
placeholder="Channel name"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
(keydown.enter)="confirmCreateChannel()"
/>
</app-confirm-dialog>
}

View File

@@ -0,0 +1,663 @@
/* 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 { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMessageSquare,
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideMonitor,
lucideHash,
lucideUsers,
lucidePlus,
lucideVolumeX
} from '@ng-icons/lucide';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors';
import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
selectVoiceChannels
} from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareFacade } from '../../../domains/screen-share';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import {
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent,
UserVolumeMenuComponent
} from '../../../shared';
import {
Channel,
ChatEvent,
RoomMember,
Room,
User
} from '../../../shared-kernel';
import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
VoiceControlsComponent,
ContextMenuComponent,
UserVolumeMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideMessageSquare,
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideMonitor,
lucideHash,
lucideUsers,
lucidePlus,
lucideVolumeX
})
],
templateUrl: './rooms-side-panel.component.html'
})
export class RoomsSidePanelComponent {
private store = inject(Store);
private realtime = inject(RealtimeSessionFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private screenShare = inject(ScreenShareFacade);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
voiceActivity = inject(VoiceActivityService);
activeTab = signal<TabView>('channels');
showFloatingControls = this.voiceSessionService.showFloatingControls;
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
roomMembers = computed(() => this.currentRoom()?.members ?? []);
roomMemberIdentifiers = computed(() => {
const identifiers = new Set<string>();
for (const member of this.roomMembers()) {
this.addIdentifiers(identifiers, member);
}
return identifiers;
});
onlineRoomUsers = computed(() => {
const memberIdentifiers = this.roomMemberIdentifiers();
return this.onlineUsers().filter((user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user));
});
offlineRoomMembers = computed(() => {
const onlineIdentifiers = new Set<string>();
for (const user of this.onlineRoomUsers()) {
this.addIdentifiers(onlineIdentifiers, user);
}
this.addIdentifiers(onlineIdentifiers, this.currentUser());
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
});
knownUserCount = computed(() => {
const memberIds = new Set(
this.roomMembers()
.map((member) => this.roomMemberKey(member))
.filter(Boolean)
);
const current = this.currentUser();
if (current) {
memberIds.add(current.oderId || current.id);
}
return memberIds.size;
});
showChannelMenu = signal(false);
channelMenuX = signal(0);
channelMenuY = signal(0);
contextChannel = signal<Channel | null>(null);
renamingChannelId = signal<string | null>(null);
showCreateChannelDialog = signal(false);
createChannelType = signal<'text' | 'voice'>('text');
newChannelName = '';
showUserMenu = signal(false);
userMenuX = signal(0);
userMenuY = signal(0);
contextMenuUser = signal<User | null>(null);
showVolumeMenu = signal(false);
volumeMenuX = signal(0);
volumeMenuY = signal(0);
volumeMenuPeerId = signal('');
volumeMenuDisplayName = signal('');
private roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
if (!entity)
return;
if (entity.id) {
identifiers.add(entity.id);
}
if (entity.oderId) {
identifiers.add(entity.oderId);
}
}
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
}
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
const current = this.currentUser();
return !!current && (
(typeof entity.id === 'string' && entity.id === current.id)
|| (typeof entity.oderId === 'string' && entity.oderId === current.oderId)
);
}
canManageChannels(): boolean {
const room = this.currentRoom();
const user = this.currentUser();
if (!room || !user)
return false;
if (room.hostId === user.id)
return true;
const perms = room.permissions || {};
if (user.role === 'admin' && perms.adminsManageRooms)
return true;
if (user.role === 'moderator' && perms.moderatorsManageRooms)
return true;
return false;
}
selectTextChannel(channelId: string) {
if (this.renamingChannelId())
return;
this.voiceWorkspace.showChat();
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
}
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
evt.preventDefault();
this.contextChannel.set(channel);
this.channelMenuX.set(evt.clientX);
this.channelMenuY.set(evt.clientY);
this.showChannelMenu.set(true);
}
closeChannelMenu() {
this.showChannelMenu.set(false);
}
startRename() {
const ch = this.contextChannel();
this.closeChannelMenu();
if (ch) {
this.renamingChannelId.set(ch.id);
}
}
confirmRename(event: Event) {
const input = event.target as HTMLInputElement;
const name = input.value.trim();
const channelId = this.renamingChannelId();
if (channelId && name) {
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
}
this.renamingChannelId.set(null);
}
cancelRename() {
this.renamingChannelId.set(null);
}
deleteChannel() {
const ch = this.contextChannel();
this.closeChannelMenu();
if (ch) {
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
}
}
resyncMessages() {
this.closeChannelMenu();
const room = this.currentRoom();
if (!room) {
return;
}
this.store.dispatch(MessagesActions.startSync());
const peers = this.realtime.getConnectedPeers();
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
peers.forEach((pid) => {
try {
this.realtime.sendToPeer(pid, inventoryRequest);
} catch {
return;
}
});
}
createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type);
this.newChannelName = '';
this.showCreateChannelDialog.set(true);
}
confirmCreateChannel() {
const name = this.newChannelName.trim();
if (!name)
return;
const type = this.createChannelType();
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
const channel: Channel = {
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
name,
type,
position: existing.length
};
this.store.dispatch(RoomsActions.addChannel({ channel }));
this.showCreateChannelDialog.set(false);
}
cancelCreateChannel() {
this.showCreateChannelDialog.set(false);
}
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
if (!this.isAdmin())
return;
this.contextMenuUser.set(user);
this.userMenuX.set(evt.clientX);
this.userMenuY.set(evt.clientY);
this.showUserMenu.set(true);
}
closeUserMenu() {
this.showUserMenu.set(false);
}
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
const me = this.currentUser();
if (user.id === me?.id || user.oderId === me?.oderId)
return;
this.volumeMenuPeerId.set(user.oderId || user.id);
this.volumeMenuDisplayName.set(user.displayName);
this.volumeMenuX.set(evt.clientX);
this.volumeMenuY.set(evt.clientY);
this.showVolumeMenu.set(true);
}
changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser();
const roomId = this.currentRoom()?.id;
this.closeUserMenu();
if (user) {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.realtime.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});
}
}
kickUserAction() {
const user = this.contextMenuUser();
this.closeUserMenu();
if (user) {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
}
}
joinVoice(roomId: string) {
const room = this.currentRoom();
const current = this.currentUser();
if (
room
&& current?.voiceState?.isConnected
&& current.voiceState.roomId === roomId
&& current.voiceState.serverId === room.id
) {
this.voiceWorkspace.open(null, { connectRemoteShares: true });
return;
}
if (room && room.permissions && room.permissions.allowVoice === false) {
return;
}
if (!room)
return;
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.voiceConnection.isVoiceConnected()) {
if (current.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
}
} else {
return;
}
}
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice();
enableVoicePromise
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch(() => undefined);
}
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
this.updateVoiceStateStore(roomId, room, current);
this.trackCurrentUserMic();
this.startVoiceHeartbeat(roomId, room);
this.broadcastVoiceConnected(roomId, room, current);
this.startVoiceSession(roomId, room);
}
private trackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
const micStream = this.voiceConnection.getRawMicStream();
if (userId && micStream) {
this.voiceActivity.trackLocalMic(userId, micStream);
}
}
private untrackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
}
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
if (!current?.id)
return;
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: true,
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
}
})
);
}
private startVoiceHeartbeat(roomId: string, room: Room): void {
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
}
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
}
});
}
private startVoiceSession(roomId: string, room: Room): void {
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
}
leaveVoice(roomId: string) {
const current = this.currentUser();
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
return;
this.voiceConnection.stopVoiceHeartbeat();
this.untrackCurrentUserMic();
this.voiceConnection.disableVoice();
if (current?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
}
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
});
this.voiceSessionService.endSession();
}
voiceOccupancy(roomId: string): number {
return this.voiceUsersInRoom(roomId).length;
}
viewShare(userId: string) {
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
}
viewStream(userId: string) {
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
}
isUserLocallyMuted(user: User): boolean {
const peerId = user.oderId || user.id;
return this.voicePlayback.isUserMuted(peerId);
}
isUserSharing(userId: string): boolean {
const me = this.currentUser();
if (me?.id === userId) {
return this.screenShare.isScreenSharing();
}
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
if (user?.screenShareState?.isSharing === false) {
return false;
}
const peerKeys = [
user?.oderId,
user?.id,
userId
].filter(
(candidate): candidate is string => !!candidate
);
const stream = peerKeys
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
return !!stream && stream.getVideoTracks().length > 0;
}
voiceUsersInRoom(roomId: string) {
const room = this.currentRoom();
const me = this.currentUser();
const remoteUsers = this.onlineUsers().filter(
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
);
if (
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id
) {
const meId = me.id;
const meOderId = me.oderId;
const alreadyIncluded = remoteUsers.some(
(user) => user.id === meId || user.oderId === meOderId
);
if (!alreadyIncluded) {
return [me, ...remoteUsers];
}
}
return remoteUsers;
}
isCurrentRoom(roomId: string): boolean {
const me = this.currentUser();
const room = this.currentRoom();
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
}
voiceEnabled(): boolean {
const room = this.currentRoom();
return room?.permissions?.allowVoice !== false;
}
getPeerLatency(user: User): number | null {
const latencies = this.voiceConnection.peerLatencies();
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
}
getPingColorClass(user: User): string {
const ms = this.getPeerLatency(user);
if (ms === null)
return 'bg-gray-500';
if (ms < 100)
return 'bg-green-500';
if (ms < 200)
return 'bg-yellow-500';
if (ms < 350)
return 'bg-orange-500';
return 'bg-red-500';
}
}

View File

@@ -0,0 +1,115 @@
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
<!-- Create button -->
<button
type="button"
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
title="Create Server"
(click)="createServer()"
>
<ng-icon
name="lucidePlus"
class="w-5 h-5"
/>
</button>
<!-- Saved servers icons -->
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
@for (room of visibleSavedRooms(); track room.id) {
<button
type="button"
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
[title]="room.name"
(click)="joinSavedRoom(room)"
(contextmenu)="openContextMenu($event, room)"
>
@if (room.icon) {
<img
[ngSrc]="room.icon"
[alt]="room.name"
class="w-full h-full object-cover"
/>
} @else {
<div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
</div>
}
</button>
}
</div>
</nav>
<!-- Context menu -->
@if (showMenu()) {
<app-context-menu
[x]="menuX()"
[y]="menuY()"
(closed)="closeMenu()"
[width]="'w-44'"
>
<button
type="button"
(click)="openLeaveConfirm()"
class="context-menu-item"
>
Leave Server
</button>
</app-context-menu>
}
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"
confirmLabel="OK"
cancelLabel="Close"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="closeBannedDialog()"
(cancelled)="closeBannedDialog()"
>
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
</app-confirm-dialog>
}
@if (showPasswordDialog() && passwordPromptRoom()) {
<app-confirm-dialog
title="Password required"
confirmLabel="Join server"
cancelLabel="Cancel"
[widthClass]="'w-[420px] max-w-[92vw]'"
(confirmed)="confirmPasswordJoin()"
(cancelled)="closePasswordDialog()"
>
<div class="space-y-3 text-left">
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
<div>
<label
for="rail-join-password"
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Server password
</label>
<input
id="rail-join-password"
type="password"
[(ngModel)]="joinPassword"
placeholder="Enter password"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@if (joinPasswordError()) {
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
}
</div>
</app-confirm-dialog>
}
@if (showLeaveConfirm() && contextRoom()) {
<app-leave-server-dialog
[room]="contextRoom()!"
[currentUser]="currentUser() ?? null"
(confirmed)="confirmLeave($event)"
(cancelled)="cancelLeave()"
/>
}

View File

@@ -0,0 +1,349 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
signal
} from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs';
import { Room, User } from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime';
import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../shared';
@Component({
selector: 'app-servers-rail',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent,
NgOptimizedImage
],
viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html'
})
export class ServersRailComponent {
private store = inject(Store);
private router = inject(Router);
private voiceSession = inject(VoiceSessionFacade);
private webrtc = inject(RealtimeSessionFacade);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private banLookupRequestVersion = 0;
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
showMenu = signal(false);
menuX = signal(72);
menuY = signal(100);
contextRoom = signal<Room | null>(null);
showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser);
bannedRoomLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptRoom = signal<Room | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
constructor() {
effect(() => {
const rooms = this.savedRooms();
const currentUser = this.currentUser();
void this.refreshBannedLookup(rooms, currentUser ?? null);
});
}
initial(name?: string): string {
if (!name)
return '?';
const ch = name.trim()[0]?.toUpperCase();
return ch || '?';
}
trackRoomId = (index: number, room: Room) => room.id;
createServer(): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId) {
this.voiceSession.setViewingVoiceServer(false);
}
this.router.navigate(['/search']);
}
async joinSavedRoom(room: Room): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
if (await this.isRoomBanned(room)) {
this.bannedServerName.set(room.name);
this.showBannedDialog.set(true);
return;
}
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
});
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
this.prepareVoiceContext(room);
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
this.store.dispatch(RoomsActions.viewServer({ room,
skipBanCheck: true }));
} else {
await this.attemptJoinRoom(room);
}
}
closeBannedDialog(): void {
this.showBannedDialog.set(false);
this.bannedServerName.set('');
}
closePasswordDialog(): void {
this.showPasswordDialog.set(false);
this.passwordPromptRoom.set(null);
this.joinPassword.set('');
this.joinPasswordError.set(null);
}
async confirmPasswordJoin(): Promise<void> {
const room = this.passwordPromptRoom();
if (!room)
return;
await this.attemptJoinRoom(room, this.joinPassword());
}
isRoomMarkedBanned(room: Room): boolean {
return !!this.bannedRoomLookup()[room.id];
}
openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault();
this.contextRoom.set(room);
this.menuX.set(Math.max(evt.clientX + 8, 72));
this.menuY.set(evt.clientY);
this.showMenu.set(true);
}
closeMenu(): void {
this.showMenu.set(false);
}
isCurrentContextRoom(): boolean {
const ctx = this.contextRoom();
const cur = this.currentRoom();
return !!ctx && !!cur && ctx.id === cur.id;
}
openLeaveConfirm(): void {
this.closeMenu();
if (this.contextRoom()) {
this.showLeaveConfirm.set(true);
}
}
confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom();
if (!ctx)
return;
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
this.store.dispatch(RoomsActions.forgetRoom({
roomId: ctx.id,
nextOwnerKey: result.nextOwnerKey
}));
if (isCurrentRoom) {
this.router.navigate(['/search']);
}
this.showLeaveConfirm.set(false);
this.contextRoom.set(null);
}
cancelLeave(): void {
this.showLeaveConfirm.set(false);
}
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;
if (!currentUser || rooms.length === 0) {
this.bannedRoomLookup.set({});
return;
}
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
const entries = await Promise.all(
rooms.map(async (room) => {
const bans = await this.db.getBansForRoom(room.id);
return [room.id, hasRoomBanForUser(bans, currentUser, persistedUserId)] as const;
})
);
if (requestVersion !== this.banLookupRequestVersion) {
return;
}
this.bannedRoomLookup.set(Object.fromEntries(entries));
}
private async isRoomBanned(room: Room): Promise<boolean> {
const currentUser = this.currentUser();
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !persistedUserId) {
return false;
}
const bans = await this.db.getBansForRoom(room.id);
return hasRoomBanForUser(bans, currentUser, persistedUserId);
}
private prepareVoiceContext(room: Room): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) {
this.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) {
this.voiceSession.setViewingVoiceServer(true);
}
}
private async attemptJoinRoom(room: Room, password?: string): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId)
return;
this.joinPasswordError.set(null);
try {
const response = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: room.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}));
this.closePasswordDialog();
this.store.dispatch(
RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {
...this.toServerInfo(room),
...response.server
}
})
);
} catch (error: unknown) {
const serverError = error as {
error?: { error?: string; errorCode?: string };
};
const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
if (errorCode === 'PASSWORD_REQUIRED') {
this.passwordPromptRoom.set(room);
this.showPasswordDialog.set(true);
this.joinPasswordError.set(message);
return;
}
if (errorCode === 'BANNED') {
this.bannedServerName.set(room.name);
this.showBannedDialog.set(true);
return;
}
if (this.shouldFallbackToOfflineView(error)) {
this.closePasswordDialog();
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
this.store.dispatch(RoomsActions.viewServer({ room,
skipBanCheck: true }));
}
}
}
private shouldFallbackToOfflineView(error: unknown): boolean {
const serverError = error as {
error?: { errorCode?: string };
status?: number;
};
const errorCode = serverError?.error?.errorCode;
const status = serverError?.status;
return errorCode === 'SERVER_NOT_FOUND'
|| status === 0
|| status === 404
|| (typeof status === 'number' && status >= 500);
}
private toServerInfo(room: Room) {
return {
id: room.id,
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
createdAt: room.createdAt,
ownerId: room.hostId,
sourceId: room.sourceId,
sourceName: room.sourceName,
sourceUrl: room.sourceUrl
};
}
}

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);
}
}

View File

@@ -0,0 +1,171 @@
<div
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
style="-webkit-app-region: drag"
>
<div
class="flex items-center gap-2 min-w-0 relative"
style="-webkit-app-region: no-drag"
>
@if (inRoom()) {
<ng-icon
name="lucideHash"
class="w-5 h-5 text-muted-foreground"
/>
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
@if (showRoomCompatibilityNotice()) {
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
{{ signalServerCompatibilityError() }}
</span>
}
@if (showRoomReconnectNotice()) {
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
<ng-icon
name="lucideRefreshCw"
class="h-3.5 w-3.5 animate-spin"
/>
Reconnecting to signal server…
</span>
}
@if (roomDescription()) {
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
{{ roomDescription() }}
</span>
}
} @else {
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
<span
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
[class.hidden]="!isReconnecting()"
>Reconnecting…</span
>
</div>
}
</div>
<div
class="flex items-center gap-2"
style="-webkit-app-region: no-drag"
>
<button
type="button"
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
[class.hidden]="isAuthed()"
(click)="goLogin()"
title="Login"
>
Login
</button>
<button
type="button"
(click)="toggleMenu()"
class="ml-2 p-2 hover:bg-secondary rounded"
title="Menu"
>
<ng-icon
name="lucideMenu"
class="w-5 h-5 text-muted-foreground"
/>
</button>
<!-- Anchored dropdown under the menu button -->
@if (showMenu()) {
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card shadow-lg">
@if (inRoom()) {
<button
type="button"
(click)="createInviteLink()"
[disabled]="creatingInvite()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
@if (creatingInvite()) {
Creating Invite Link…
} @else {
Create Invite Link
}
</button>
<button
type="button"
(click)="leaveServer()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
>
Leave Server
</button>
}
<div
class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground"
[class.hidden]="!inviteStatus()"
>
{{ inviteStatus() }}
</div>
<div class="border-t border-border"></div>
<button
type="button"
(click)="logout()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
>
Logout
</button>
</div>
}
@if (isElectron()) {
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
title="Minimize"
(click)="minimize()"
>
<ng-icon
name="lucideMinus"
class="w-4 h-4"
/>
</button>
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
title="Maximize"
(click)="maximize()"
>
<ng-icon
name="lucideSquare"
class="w-4 h-4"
/>
</button>
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
title="Close"
(click)="close()"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-destructive"
/>
</button>
}
</div>
</div>
<!-- Click-away overlay to close dropdown -->
@if (showMenu()) {
<div
class="fixed inset-0 z-40"
(click)="closeMenu()"
(keydown.enter)="closeMenu()"
(keydown.space)="closeMenu()"
tabindex="0"
role="button"
aria-label="Close menu overlay"
style="-webkit-app-region: no-drag"
></div>
}
@if (showLeaveConfirm() && currentRoom()) {
<app-leave-server-dialog
[room]="currentRoom()!"
[currentUser]="currentUser() ?? null"
(confirmed)="confirmLeave($event)"
(cancelled)="cancelLeave()"
/>
}

View File

@@ -0,0 +1,264 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
computed,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { firstValueFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMinus,
lucideSquare,
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu,
lucideRefreshCw
} from '@ng-icons/lucide';
import { Router } from '@angular/router';
import {
selectCurrentRoom,
selectIsSignalServerReconnecting,
selectSignalServerCompatibilityError
} from '../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../core/realtime';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import { PlatformService } from '../../core/platform';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared';
import { Room } from '../../shared-kernel';
@Component({
selector: 'app-title-bar',
standalone: true,
imports: [
CommonModule,
NgIcon,
LeaveServerDialogComponent
],
viewProviders: [
provideIcons({ lucideMinus,
lucideSquare,
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu,
lucideRefreshCw })
],
templateUrl: './title-bar.component.html'
})
/**
* Electron-style title bar with window controls, navigation, and server menu.
*/
export class TitleBarComponent {
private store = inject(Store);
private electronBridge = inject(ElectronBridgeService);
private serverDirectory = inject(ServerDirectoryFacade);
private router = inject(Router);
private webrtc = inject(RealtimeSessionFacade);
private platform = inject(PlatformService);
private getWindowControlsApi() {
return this.electronBridge.getApi();
}
isElectron = computed(() => this.platform.isElectron);
showMenuState = computed(() => false);
currentUser = this.store.selectSignal(selectCurrentUser);
username = computed(() => this.currentUser()?.displayName || 'Guest');
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
isConnected = computed(() => this.webrtc.isConnected());
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUser());
currentRoom = this.store.selectSignal(selectCurrentRoom);
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
inRoom = computed(() => !!this.currentRoom());
roomName = computed(() => this.currentRoom()?.name || '');
roomDescription = computed(() => this.currentRoom()?.description || '');
showRoomCompatibilityNotice = computed(() =>
this.inRoom() && !!this.signalServerCompatibilityError()
);
showRoomReconnectNotice = computed(() =>
this.inRoom()
&& !this.signalServerCompatibilityError()
&& (
this.isSignalServerReconnecting()
|| this.webrtc.shouldShowConnectionError()
|| this.isReconnecting()
)
);
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
showLeaveConfirm = signal(false);
inviteStatus = signal<string | null>(null);
creatingInvite = signal(false);
/** Minimize the Electron window. */
minimize() {
const api = this.getWindowControlsApi();
api?.minimizeWindow?.();
}
/** Maximize or restore the Electron window. */
maximize() {
const api = this.getWindowControlsApi();
api?.maximizeWindow?.();
}
/** Close the Electron window. */
close() {
const api = this.getWindowControlsApi();
api?.closeWindow?.();
}
/** Navigate to the login page. */
goLogin() {
this.router.navigate(['/login']);
}
/** Open the unified leave-server confirmation dialog. */
private openLeaveConfirm() {
this._showMenu.set(false);
if (this.currentRoom()) {
this.showLeaveConfirm.set(true);
}
}
/** Toggle the server dropdown menu. */
toggleMenu() {
this.inviteStatus.set(null);
this._showMenu.set(!this._showMenu());
}
/** Create a new invite link for the active room and copy it to the clipboard. */
async createInviteLink(): Promise<void> {
const room = this.currentRoom();
const user = this.currentUser();
if (!room || !user || this.creatingInvite()) {
return;
}
this.creatingInvite.set(true);
this.inviteStatus.set('Creating invite link…');
try {
const invite = await firstValueFrom(this.serverDirectory.createInvite(
room.id,
{
requesterUserId: user.id,
requesterDisplayName: user.displayName,
requesterRole: user.role
},
this.toSourceSelector(room)
));
await this.copyInviteLink(invite.inviteUrl);
this.inviteStatus.set('Invite link copied to clipboard.');
} catch (error: unknown) {
const inviteError = error as { error?: { error?: string } };
this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.');
} finally {
this.creatingInvite.set(false);
}
}
/** Leave the current server and navigate to the servers list. */
leaveServer() {
this.openLeaveConfirm();
}
/** Confirm the unified leave action and remove the server locally. */
confirmLeave(result: { nextOwnerKey?: string }) {
const roomId = this.currentRoom()?.id;
this.showLeaveConfirm.set(false);
if (!roomId)
return;
this.store.dispatch(RoomsActions.forgetRoom({
roomId,
nextOwnerKey: result.nextOwnerKey
}));
this.router.navigate(['/search']);
}
/** Cancel the leave-server confirmation dialog. */
cancelLeave() {
this.showLeaveConfirm.set(false);
}
/** Close the server dropdown menu. */
closeMenu() {
this._showMenu.set(false);
}
/** Log out the current user, disconnect from signaling, and navigate to login. */
logout() {
this._showMenu.set(false);
// Disconnect from signaling server - this broadcasts "user_left" to all
// servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect();
try {
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
} catch {}
this.router.navigate(['/login']);
}
private async copyInviteLink(inviteUrl: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(inviteUrl);
return;
} catch {}
}
const textarea = document.createElement('textarea');
textarea.value = inviteUrl;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
try {
const copied = document.execCommand('copy');
if (copied) {
return;
}
} catch {
/* fall through to prompt fallback */
} finally {
document.body.removeChild(textarea);
}
window.prompt('Copy this invite link', inviteUrl);
}
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
}