Add new settngs modal
This commit is contained in:
@@ -15,3 +15,6 @@
|
|||||||
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unified Settings Modal -->
|
||||||
|
<app-settings-modal />
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ import { ExternalLinkService } from './core/services/external-link.service';
|
|||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||||
|
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID, STORAGE_KEY_LAST_VISITED_ROUTE } from './core/constants';
|
import {
|
||||||
|
ROOM_URL_PATTERN,
|
||||||
|
STORAGE_KEY_CURRENT_USER_ID,
|
||||||
|
STORAGE_KEY_LAST_VISITED_ROUTE,
|
||||||
|
} from './core/constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root application component.
|
* Root application component.
|
||||||
@@ -24,7 +29,14 @@ import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID, STORAGE_KEY_LAST_VISITED
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterOutlet,
|
||||||
|
ServersRailComponent,
|
||||||
|
TitleBarComponent,
|
||||||
|
FloatingVoiceControlsComponent,
|
||||||
|
SettingsModalComponent,
|
||||||
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './server-directory.service';
|
|||||||
export * from './voice-session.service';
|
export * from './voice-session.service';
|
||||||
export * from './voice-activity.service';
|
export * from './voice-activity.service';
|
||||||
export * from './external-link.service';
|
export * from './external-link.service';
|
||||||
|
export * from './settings-modal.service';
|
||||||
|
|||||||
39
src/app/core/services/settings-modal.service.ts
Normal file
39
src/app/core/services/settings-modal.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages available in the unified settings modal.
|
||||||
|
* Network & Voice are always visible; server-specific pages
|
||||||
|
* require an active room and admin role.
|
||||||
|
*/
|
||||||
|
export type SettingsPage = 'network' | 'voice' | 'server' | 'members' | 'bans' | 'permissions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global service controlling the unified settings modal.
|
||||||
|
* Any component can open the modal to a specific page via `open()`.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SettingsModalService {
|
||||||
|
/** Whether the modal is currently visible. */
|
||||||
|
readonly isOpen = signal(false);
|
||||||
|
/** The currently active page within the side-nav. */
|
||||||
|
readonly activePage = signal<SettingsPage>('network');
|
||||||
|
/** Optional server/room ID to pre-select in admin tabs. */
|
||||||
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
|
||||||
|
/** Open the modal to a specific page, optionally targeting a server. */
|
||||||
|
open(page: SettingsPage = 'network', serverId?: string): void {
|
||||||
|
this.activePage.set(page);
|
||||||
|
this.targetServerId.set(serverId ?? null);
|
||||||
|
this.isOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the modal. */
|
||||||
|
close(): void {
|
||||||
|
this.isOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a different page while the modal remains open. */
|
||||||
|
navigate(page: SettingsPage): void {
|
||||||
|
this.activePage.set(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,6 @@
|
|||||||
<span class="text-muted-foreground text-lg">#</span>
|
<span class="text-muted-foreground text-lg">#</span>
|
||||||
<span class="font-medium text-foreground text-sm">{{ activeChannelName }}</span>
|
<span class="font-medium text-foreground text-sm">{{ activeChannelName }}</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
@if (isAdmin()) {
|
|
||||||
<button
|
|
||||||
(click)="toggleAdminPanel()"
|
|
||||||
class="p-1.5 rounded hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
|
|
||||||
title="Server Settings"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideSettings" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
@@ -29,19 +20,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Admin Panel (slide-over) -->
|
|
||||||
@if (showAdminPanel() && isAdmin()) {
|
|
||||||
<aside class="w-80 flex-shrink-0 border-l border-border overflow-y-auto">
|
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-card">
|
|
||||||
<span class="text-sm font-medium text-foreground">Server Settings</span>
|
|
||||||
<button (click)="toggleAdminPanel()" class="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground">
|
|
||||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<app-admin-panel />
|
|
||||||
</aside>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Sidebar always visible -->
|
<!-- Sidebar always visible -->
|
||||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
<aside class="w-80 flex-shrink-0 border-l border-border">
|
||||||
<app-rooms-side-panel class="h-full" />
|
<app-rooms-side-panel class="h-full" />
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
|
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
|
||||||
import { UserListComponent } from '../../chat/user-list/user-list.component';
|
|
||||||
import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/screen-share-viewer.component';
|
import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/screen-share-viewer.component';
|
||||||
import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
|
|
||||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
||||||
|
|
||||||
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels } from '../../../store/rooms/rooms.selectors';
|
import {
|
||||||
|
selectCurrentRoom,
|
||||||
|
selectActiveChannelId,
|
||||||
|
selectTextChannels,
|
||||||
|
} from '../../../store/rooms/rooms.selectors';
|
||||||
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
|
|
||||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||||
@@ -32,7 +35,6 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
|||||||
ChatMessagesComponent,
|
ChatMessagesComponent,
|
||||||
ScreenShareViewerComponent,
|
ScreenShareViewerComponent,
|
||||||
RoomsSidePanelComponent,
|
RoomsSidePanelComponent,
|
||||||
AdminPanelComponent,
|
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -52,6 +54,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
|||||||
export class ChatRoomComponent {
|
export class ChatRoomComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private settingsModal = inject(SettingsModalService);
|
||||||
showMenu = signal(false);
|
showMenu = signal(false);
|
||||||
showAdminPanel = signal(false);
|
showAdminPanel = signal(false);
|
||||||
|
|
||||||
@@ -63,12 +66,15 @@ export class ChatRoomComponent {
|
|||||||
/** Returns the display name of the currently active text channel. */
|
/** Returns the display name of the currently active text channel. */
|
||||||
get activeChannelName(): string {
|
get activeChannelName(): string {
|
||||||
const id = this.activeChannelId();
|
const id = this.activeChannelId();
|
||||||
const activeChannel = this.textChannels().find(channel => channel.id === id);
|
const activeChannel = this.textChannels().find((channel) => channel.id === id);
|
||||||
return activeChannel ? activeChannel.name : id;
|
return activeChannel ? activeChannel.name : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle the admin panel sidebar visibility. */
|
/** Open the settings modal to the Server admin page for the current room. */
|
||||||
toggleAdminPanel() {
|
toggleAdminPanel() {
|
||||||
this.showAdminPanel.update((current) => !current);
|
const room = this.currentRoom();
|
||||||
|
if (room) {
|
||||||
|
this.settingsModal.open('server', room.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,21 @@ import {
|
|||||||
} from '../../store/rooms/rooms.selectors';
|
} from '../../store/rooms/rooms.selectors';
|
||||||
import { Room } from '../../core/models';
|
import { Room } from '../../core/models';
|
||||||
import { ServerInfo } from '../../core/models';
|
import { ServerInfo } from '../../core/models';
|
||||||
|
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-search',
|
selector: 'app-server-search',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [CommonModule, FormsModule, NgIcon],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings }),
|
provideIcons({
|
||||||
|
lucideSearch,
|
||||||
|
lucideUsers,
|
||||||
|
lucideLock,
|
||||||
|
lucideGlobe,
|
||||||
|
lucidePlus,
|
||||||
|
lucideSettings,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
templateUrl: './server-search.component.html',
|
templateUrl: './server-search.component.html',
|
||||||
})
|
})
|
||||||
@@ -40,6 +48,7 @@ import { ServerInfo } from '../../core/models';
|
|||||||
export class ServerSearchComponent implements OnInit {
|
export class ServerSearchComponent implements OnInit {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private settingsModal = inject(SettingsModalService);
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
|
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
@@ -63,11 +72,9 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
|
|
||||||
// Setup debounced search
|
// Setup debounced search
|
||||||
this.searchSubject
|
this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe((query) => {
|
||||||
.pipe(debounceTime(300), distinctUntilChanged())
|
this.store.dispatch(RoomsActions.searchServers({ query }));
|
||||||
.subscribe((query) => {
|
});
|
||||||
this.store.dispatch(RoomsActions.searchServers({ query }));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emit a search query to the debounced search subject. */
|
/** Emit a search query to the debounced search subject. */
|
||||||
@@ -82,14 +89,16 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.store.dispatch(RoomsActions.joinRoom({
|
this.store.dispatch(
|
||||||
roomId: server.id,
|
RoomsActions.joinRoom({
|
||||||
serverInfo: {
|
roomId: server.id,
|
||||||
name: server.name,
|
serverInfo: {
|
||||||
description: server.description,
|
name: server.name,
|
||||||
hostName: server.hostName,
|
description: server.description,
|
||||||
}
|
hostName: server.hostName,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the create-server dialog. */
|
/** Open the create-server dialog. */
|
||||||
@@ -119,15 +128,15 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
topic: this.newServerTopic() || undefined,
|
topic: this.newServerTopic() || undefined,
|
||||||
isPrivate: this.newServerPrivate(),
|
isPrivate: this.newServerPrivate(),
|
||||||
password: this.newServerPrivate() ? this.newServerPassword() : undefined,
|
password: this.newServerPrivate() ? this.newServerPassword() : undefined,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.closeCreateDialog();
|
this.closeCreateDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate to the application settings page. */
|
/** Open the unified settings modal to the Network page. */
|
||||||
openSettings(): void {
|
openSettings(): void {
|
||||||
this.router.navigate(['/settings']);
|
this.settingsModal.open('network');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@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
|
||||||
|
(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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Component, inject, input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { lucideX } from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { Room, BanEntry } from '../../../../core/models';
|
||||||
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
|
import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
||||||
|
|
||||||
|
@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);
|
||||||
|
|
||||||
|
/** 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 = this.store.selectSignal(selectBannedUsers);
|
||||||
|
|
||||||
|
unbanUser(ban: BanEntry): void {
|
||||||
|
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
formatExpiry(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return (
|
||||||
|
date.toLocaleDateString() +
|
||||||
|
' ' +
|
||||||
|
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
@if (server()) {
|
||||||
|
<div class="space-y-3 max-w-xl">
|
||||||
|
@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>
|
||||||
|
@if (user.role !== 'host' && isAdmin()) {
|
||||||
|
<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
|
||||||
|
(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
|
||||||
|
(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>
|
||||||
|
} @else {
|
||||||
|
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||||
|
Select a server from the sidebar to manage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Component, 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, User } from '../../../../core/models';
|
||||||
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
|
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||||
|
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||||
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
|
|
||||||
|
@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(WebRTCService);
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
|
||||||
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
|
|
||||||
|
membersFiltered(): User[] {
|
||||||
|
const me = this.currentUser();
|
||||||
|
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||||
|
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||||
|
this.webrtcService.broadcastMessage({
|
||||||
|
type: 'role-change',
|
||||||
|
targetUserId: user.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
kickMember(user: User): void {
|
||||||
|
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||||
|
this.webrtcService.broadcastMessage({
|
||||||
|
type: 'kick',
|
||||||
|
targetUserId: user.id,
|
||||||
|
kickedBy: this.currentUser()?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
banMember(user: User): void {
|
||||||
|
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
||||||
|
this.webrtcService.broadcastMessage({
|
||||||
|
type: 'ban',
|
||||||
|
targetUserId: user.id,
|
||||||
|
bannedBy: this.currentUser()?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground mb-3">
|
||||||
|
Server directories to search for rooms. The active server is used for creating new rooms.
|
||||||
|
</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'"
|
||||||
|
></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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
@if (!server.isActive) {
|
||||||
|
<button
|
||||||
|
(click)="setActiveServer(server.id)"
|
||||||
|
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
||||||
|
title="Set as active"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideCheck"
|
||||||
|
class="w-3.5 h-3.5 text-muted-foreground hover:text-primary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!server.isDefault) {
|
||||||
|
<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
|
||||||
|
(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>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Component, inject, signal } 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,
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
templateUrl: './network-settings.component.html',
|
||||||
|
})
|
||||||
|
export class NetworkSettingsComponent {
|
||||||
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
|
|
||||||
|
servers = this.serverDirectory.servers;
|
||||||
|
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((s) => s.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
@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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 '../../../../core/models';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
@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 class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="roomName"
|
||||||
|
[readOnly]="!isAdmin()"
|
||||||
|
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 class="block text-xs font-medium text-muted-foreground mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="roomDescription"
|
||||||
|
[readOnly]="!isAdmin()"
|
||||||
|
rows="3"
|
||||||
|
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()"
|
||||||
|
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 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"
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isAdmin()) {
|
||||||
|
<button
|
||||||
|
(click)="saveServerSettings()"
|
||||||
|
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()"
|
||||||
|
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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Component, inject, input, signal, computed } 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 '../../../../core/models';
|
||||||
|
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);
|
||||||
|
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. */
|
||||||
|
serverData = computed(() => {
|
||||||
|
const room = this.server();
|
||||||
|
if (room) {
|
||||||
|
this.roomName = room.name;
|
||||||
|
this.roomDescription = room.description || '';
|
||||||
|
this.isPrivate.set(room.isPrivate);
|
||||||
|
this.maxUsers = room.maxUsers || 0;
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
});
|
||||||
|
|
||||||
|
togglePrivate(): void {
|
||||||
|
this.isPrivate.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveServerSettings(): void {
|
||||||
|
const room = this.server();
|
||||||
|
if (!room) return;
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: {
|
||||||
|
name: this.roomName,
|
||||||
|
description: this.roomDescription,
|
||||||
|
isPrivate: this.isPrivate(),
|
||||||
|
maxUsers: this.maxUsers,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.showSaveSuccess('server');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
@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()"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto 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()"
|
||||||
|
>
|
||||||
|
<!-- 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)"
|
||||||
|
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 (savedRooms().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 savedRooms(); track room.id) {
|
||||||
|
<option [value]="room.id">{{ room.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedServerId() && isSelectedServerAdmin()) {
|
||||||
|
@for (page of serverPages; track page.id) {
|
||||||
|
<button
|
||||||
|
(click)="navigate(page.id)"
|
||||||
|
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>
|
||||||
|
</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 ('network') {
|
||||||
|
Network
|
||||||
|
}
|
||||||
|
@case ('voice') {
|
||||||
|
Voice & Audio
|
||||||
|
}
|
||||||
|
@case ('server') {
|
||||||
|
Server Settings
|
||||||
|
}
|
||||||
|
@case ('members') {
|
||||||
|
Members
|
||||||
|
}
|
||||||
|
@case ('bans') {
|
||||||
|
Bans
|
||||||
|
}
|
||||||
|
@case ('permissions') {
|
||||||
|
Permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
(click)="close()"
|
||||||
|
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 ('network') {
|
||||||
|
<app-network-settings />
|
||||||
|
}
|
||||||
|
@case ('voice') {
|
||||||
|
<app-voice-settings />
|
||||||
|
}
|
||||||
|
@case ('server') {
|
||||||
|
<app-server-settings
|
||||||
|
[server]="selectedServer()"
|
||||||
|
[isAdmin]="isSelectedServerAdmin()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@case ('members') {
|
||||||
|
<app-members-settings
|
||||||
|
[server]="selectedServer()"
|
||||||
|
[isAdmin]="isSelectedServerAdmin()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@case ('bans') {
|
||||||
|
<app-bans-settings
|
||||||
|
[server]="selectedServer()"
|
||||||
|
[isAdmin]="isSelectedServerAdmin()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@case ('permissions') {
|
||||||
|
<app-permissions-settings
|
||||||
|
#permissionsComp
|
||||||
|
[server]="selectedServer()"
|
||||||
|
[isAdmin]="isSelectedServerAdmin()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
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,
|
||||||
|
lucideGlobe,
|
||||||
|
lucideAudioLines,
|
||||||
|
lucideSettings,
|
||||||
|
lucideUsers,
|
||||||
|
lucideBan,
|
||||||
|
lucideShield,
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||||
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
|
import {
|
||||||
|
selectCurrentUser,
|
||||||
|
} from '../../../store/users/users.selectors';
|
||||||
|
import { Room } from '../../../core/models';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon,
|
||||||
|
NetworkSettingsComponent,
|
||||||
|
VoiceSettingsComponent,
|
||||||
|
ServerSettingsComponent,
|
||||||
|
MembersSettingsComponent,
|
||||||
|
BansSettingsComponent,
|
||||||
|
PermissionsSettingsComponent,
|
||||||
|
],
|
||||||
|
viewProviders: [
|
||||||
|
provideIcons({
|
||||||
|
lucideX,
|
||||||
|
lucideGlobe,
|
||||||
|
lucideAudioLines,
|
||||||
|
lucideSettings,
|
||||||
|
lucideUsers,
|
||||||
|
lucideBan,
|
||||||
|
lucideShield,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
templateUrl: './settings-modal.component.html',
|
||||||
|
})
|
||||||
|
export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||||
|
readonly modal = inject(SettingsModalService);
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||||
|
|
||||||
|
// --- Selectors ---
|
||||||
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
// --- Modal state ---
|
||||||
|
isOpen = this.modal.isOpen;
|
||||||
|
activePage = this.modal.activePage;
|
||||||
|
|
||||||
|
// --- Side-nav items ---
|
||||||
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
|
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
||||||
|
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
||||||
|
];
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== SERVER SELECTOR =====
|
||||||
|
selectedServerId = signal<string | null>(null);
|
||||||
|
selectedServer = computed<Room | null>(() => {
|
||||||
|
const id = this.selectedServerId();
|
||||||
|
if (!id) return null;
|
||||||
|
return this.savedRooms().find((r) => r.id === id) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Whether the user can see server-admin tabs. */
|
||||||
|
showServerTabs = computed(() => {
|
||||||
|
return this.savedRooms().length > 0 && !!this.selectedServerId();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Whether the current user is the host/owner of the selected server. */
|
||||||
|
isSelectedServerAdmin = computed(() => {
|
||||||
|
const server = this.selectedServer();
|
||||||
|
const user = this.currentUser();
|
||||||
|
if (!server || !user) return false;
|
||||||
|
return server.hostId === user.id || server.hostId === user.oderId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
animating = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Sync selected server when modal opens with a target
|
||||||
|
effect(() => {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
const targetId = this.modal.targetServerId();
|
||||||
|
if (targetId) {
|
||||||
|
this.selectedServerId.set(targetId);
|
||||||
|
} else if (this.currentRoom()) {
|
||||||
|
this.selectedServerId.set(this.currentRoom()!.id);
|
||||||
|
}
|
||||||
|
this.animating.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When selected server changes, reload permissions data
|
||||||
|
effect(() => {
|
||||||
|
const server = this.selectedServer();
|
||||||
|
if (server) {
|
||||||
|
const permsComp = this.permissionsComponent();
|
||||||
|
if (permsComp) {
|
||||||
|
permsComp.loadPermissions(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscapeKey(): void {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MODAL CONTROLS =====
|
||||||
|
close(): void {
|
||||||
|
this.animating.set(false);
|
||||||
|
setTimeout(() => this.modal.close(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<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 class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label>
|
||||||
|
<select
|
||||||
|
(change)="onInputDeviceChange($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"
|
||||||
|
>
|
||||||
|
@for (device of inputDevices(); track device.deviceId) {
|
||||||
|
<option
|
||||||
|
[value]="device.deviceId"
|
||||||
|
[selected]="device.deviceId === selectedInputDevice()"
|
||||||
|
>
|
||||||
|
{{ device.label || 'Microphone ' + $index }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label>
|
||||||
|
<select
|
||||||
|
(change)="onOutputDeviceChange($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"
|
||||||
|
>
|
||||||
|
@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 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"
|
||||||
|
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label 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="100"
|
||||||
|
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</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 class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label>
|
||||||
|
<select
|
||||||
|
(change)="onLatencyProfileChange($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"
|
||||||
|
>
|
||||||
|
<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 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"
|
||||||
|
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</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()"
|
||||||
|
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">Include system audio when sharing screen</p>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="includeSystemAudio()"
|
||||||
|
(change)="onIncludeSystemAudioChange($event)"
|
||||||
|
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>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Component, inject, 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 } from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||||
|
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
||||||
|
|
||||||
|
interface AudioDevice {
|
||||||
|
deviceId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-voice-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, NgIcon],
|
||||||
|
viewProviders: [
|
||||||
|
provideIcons({
|
||||||
|
lucideMic,
|
||||||
|
lucideHeadphones,
|
||||||
|
lucideAudioLines,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
templateUrl: './voice-settings.component.html',
|
||||||
|
})
|
||||||
|
export class VoiceSettingsComponent {
|
||||||
|
private webrtcService = inject(WebRTCService);
|
||||||
|
|
||||||
|
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(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadVoiceSettings();
|
||||||
|
this.loadAudioDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAudioDevices(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) return;
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
this.inputDevices.set(
|
||||||
|
devices
|
||||||
|
.filter((d) => d.kind === 'audioinput')
|
||||||
|
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
|
||||||
|
);
|
||||||
|
this.outputDevices.set(
|
||||||
|
devices
|
||||||
|
.filter((d) => d.kind === 'audiooutput')
|
||||||
|
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVoiceSettings(): void {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||||
|
if (!raw) return;
|
||||||
|
const s = JSON.parse(raw);
|
||||||
|
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
|
||||||
|
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
|
||||||
|
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
|
||||||
|
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
|
||||||
|
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
|
||||||
|
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
|
||||||
|
if (typeof s.includeSystemAudio === 'boolean')
|
||||||
|
this.includeSystemAudio.set(s.includeSystemAudio);
|
||||||
|
if (typeof s.noiseReduction === 'boolean') this.noiseReduction.set(s.noiseReduction);
|
||||||
|
} catch {}
|
||||||
|
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) {
|
||||||
|
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveVoiceSettings(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY_VOICE_SETTINGS,
|
||||||
|
JSON.stringify({
|
||||||
|
inputDevice: this.selectedInputDevice(),
|
||||||
|
outputDevice: this.selectedOutputDevice(),
|
||||||
|
inputVolume: this.inputVolume(),
|
||||||
|
outputVolume: this.outputVolume(),
|
||||||
|
audioBitrate: this.audioBitrate(),
|
||||||
|
latencyProfile: this.latencyProfile(),
|
||||||
|
includeSystemAudio: this.includeSystemAudio(),
|
||||||
|
noiseReduction: this.noiseReduction(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||||
|
this.saveVoiceSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputVolumeChange(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.inputVolume.set(parseInt(input.value, 10));
|
||||||
|
this.saveVoiceSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutputVolumeChange(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.outputVolume.set(parseInt(input.value, 10));
|
||||||
|
this.webrtcService.setOutputVolume(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.webrtcService.setLatencyProfile(profile);
|
||||||
|
this.saveVoiceSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAudioBitrateChange(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.audioBitrate.set(parseInt(input.value, 10));
|
||||||
|
this.webrtcService.setAudioBitrate(this.audioBitrate());
|
||||||
|
this.saveVoiceSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
onIncludeSystemAudioChange(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.includeSystemAudio.set(!!input.checked);
|
||||||
|
this.saveVoiceSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onNoiseReductionChange(): Promise<void> {
|
||||||
|
this.noiseReduction.update((v) => !v);
|
||||||
|
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||||
|
this.saveVoiceSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,152 +71,4 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
|
||||||
@if (showSettings()) {
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
(click)="closeSettings()"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<h2 class="text-xl font-semibold text-foreground mb-4">Voice Settings</h2>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">Microphone</label>
|
|
||||||
<select
|
|
||||||
(change)="onInputDeviceChange($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"
|
|
||||||
>
|
|
||||||
@for (device of inputDevices(); track device.deviceId) {
|
|
||||||
<option
|
|
||||||
[value]="device.deviceId"
|
|
||||||
[selected]="device.deviceId === selectedInputDevice()"
|
|
||||||
>
|
|
||||||
{{ device.label || 'Microphone ' + $index }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">Speaker</label>
|
|
||||||
<select
|
|
||||||
(change)="onOutputDeviceChange($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"
|
|
||||||
>
|
|
||||||
@for (device of outputDevices(); track device.deviceId) {
|
|
||||||
<option
|
|
||||||
[value]="device.deviceId"
|
|
||||||
[selected]="device.deviceId === selectedOutputDevice()"
|
|
||||||
>
|
|
||||||
{{ device.label || 'Speaker ' + $index }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">
|
|
||||||
Input Volume: {{ inputVolume() }}%
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
[value]="inputVolume()"
|
|
||||||
(input)="onInputVolumeChange($event)"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">
|
|
||||||
Output Volume: {{ outputVolume() }}%
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
[value]="outputVolume()"
|
|
||||||
(input)="onOutputVolumeChange($event)"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">Latency</label>
|
|
||||||
<select
|
|
||||||
(change)="onLatencyProfileChange($event)"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm"
|
|
||||||
>
|
|
||||||
<option value="low">Low (fast)</option>
|
|
||||||
<option value="balanced" selected>Balanced</option>
|
|
||||||
<option value="high">High (quality)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1"
|
|
||||||
>Include system audio when sharing screen</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[checked]="includeSystemAudio()"
|
|
||||||
(change)="onIncludeSystemAudioChange($event)"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Off by default; viewers will still hear your mic.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground">Noise reduction</label>
|
|
||||||
<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($event)"
|
|
||||||
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>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">
|
|
||||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
[value]="audioBitrate()"
|
|
||||||
(input)="onAudioBitrateChange($event)"
|
|
||||||
min="32"
|
|
||||||
max="256"
|
|
||||||
step="8"
|
|
||||||
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 mt-6">
|
|
||||||
<button
|
|
||||||
(click)="closeSettings()"
|
|
||||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { UsersActions } from '../../../store/users/users.actions';
|
|||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||||
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import { UserAvatarComponent } from '../../../shared';
|
import { UserAvatarComponent } from '../../../shared';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
@@ -62,6 +63,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
private voiceSessionService = inject(VoiceSessionService);
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
private voiceActivity = inject(VoiceActivityService);
|
private voiceActivity = inject(VoiceActivityService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private settingsModal = inject(SettingsModalService);
|
||||||
private remoteStreamSubscription: Subscription | null = null;
|
private remoteStreamSubscription: Subscription | null = null;
|
||||||
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
||||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||||
@@ -432,7 +434,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleSettings(): void {
|
toggleSettings(): void {
|
||||||
this.showSettings.update((current) => !current);
|
this.settingsModal.open('voice');
|
||||||
}
|
}
|
||||||
|
|
||||||
closeSettings(): void {
|
closeSettings(): void {
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ function buildSignalingUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true when the message's server ID does not match the viewed server. */
|
/** Returns true when the message's server ID does not match the viewed server. */
|
||||||
function isWrongServer(msgServerId: string | undefined, viewedServerId: string | undefined): boolean {
|
function isWrongServer(
|
||||||
|
msgServerId: string | undefined,
|
||||||
|
viewedServerId: string | undefined,
|
||||||
|
): boolean {
|
||||||
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +66,10 @@ export class RoomsEffects {
|
|||||||
switchMap(() =>
|
switchMap(() =>
|
||||||
from(this.db.getAllRooms()).pipe(
|
from(this.db.getAllRooms()).pipe(
|
||||||
map((rooms) => RoomsActions.loadRoomsSuccess({ rooms })),
|
map((rooms) => RoomsActions.loadRoomsSuccess({ rooms })),
|
||||||
catchError((error) =>
|
catchError((error) => of(RoomsActions.loadRoomsFailure({ error: error.message }))),
|
||||||
of(RoomsActions.loadRoomsFailure({ error: error.message }))
|
),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Searches the server directory with debounced input. */
|
/** Searches the server directory with debounced input. */
|
||||||
@@ -79,12 +80,10 @@ export class RoomsEffects {
|
|||||||
switchMap(({ query }) =>
|
switchMap(({ query }) =>
|
||||||
this.serverDirectory.searchServers(query).pipe(
|
this.serverDirectory.searchServers(query).pipe(
|
||||||
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
||||||
catchError((error) =>
|
catchError((error) => of(RoomsActions.searchServersFailure({ error: error.message }))),
|
||||||
of(RoomsActions.searchServersFailure({ error: error.message }))
|
),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
||||||
@@ -116,7 +115,7 @@ export class RoomsEffects {
|
|||||||
// Register with central server (using the same room ID for discoverability)
|
// Register with central server (using the same room ID for discoverability)
|
||||||
this.serverDirectory
|
this.serverDirectory
|
||||||
.registerServer({
|
.registerServer({
|
||||||
id: room.id, // Use the same ID as the local room
|
id: room.id, // Use the same ID as the local room
|
||||||
name: room.name,
|
name: room.name,
|
||||||
description: room.description,
|
description: room.description,
|
||||||
ownerId: currentUser.id,
|
ownerId: currentUser.id,
|
||||||
@@ -131,10 +130,8 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
return of(RoomsActions.createRoomSuccess({ room }));
|
return of(RoomsActions.createRoomSuccess({ room }));
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) => of(RoomsActions.createRoomFailure({ error: error.message }))),
|
||||||
of(RoomsActions.createRoomFailure({ error: error.message }))
|
),
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Joins an existing room by ID, resolving room data from local DB or server directory. */
|
/** Joins an existing room by ID, resolving room data from local DB or server directory. */
|
||||||
@@ -192,15 +189,13 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
return of(RoomsActions.joinRoomFailure({ error: 'Room not found' }));
|
return of(RoomsActions.joinRoomFailure({ error: 'Room not found' }));
|
||||||
}),
|
}),
|
||||||
catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' })))
|
catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' }))),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message }))),
|
||||||
of(RoomsActions.joinRoomFailure({ error: error.message }))
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Navigates to the room view and establishes or reuses a signaling connection. */
|
/** Navigates to the room view and establishes or reuses a signaling connection. */
|
||||||
@@ -232,9 +227,9 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
this.router.navigate(['/room', room.id]);
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Switches the UI view to an already-joined server without leaving others. */
|
/** Switches the UI view to an already-joined server without leaving others. */
|
||||||
@@ -252,8 +247,8 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
this.router.navigate(['/room', room.id]);
|
||||||
return of(RoomsActions.viewServerSuccess({ room }));
|
return of(RoomsActions.viewServerSuccess({ room }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Reloads messages and users when the viewed server changes. */
|
/** Reloads messages and users when the viewed server changes. */
|
||||||
@@ -264,8 +259,8 @@ export class RoomsEffects {
|
|||||||
UsersActions.clearUsers(),
|
UsersActions.clearUsers(),
|
||||||
MessagesActions.loadMessages({ roomId: room.id }),
|
MessagesActions.loadMessages({ roomId: room.id }),
|
||||||
UsersActions.loadBans(),
|
UsersActions.loadBans(),
|
||||||
])
|
]),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Handles leave-room dispatches (navigation only, peers stay connected). */
|
/** Handles leave-room dispatches (navigation only, peers stay connected). */
|
||||||
@@ -280,12 +275,9 @@ export class RoomsEffects {
|
|||||||
deleteRoom$ = createEffect(() =>
|
deleteRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.deleteRoom),
|
ofType(RoomsActions.deleteRoom),
|
||||||
withLatestFrom(
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
this.store.select(selectCurrentUser),
|
filter(
|
||||||
this.store.select(selectCurrentRoom)
|
([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id,
|
||||||
),
|
|
||||||
filter(([, currentUser, currentRoom]) =>
|
|
||||||
!!currentUser && currentRoom?.hostId === currentUser.id,
|
|
||||||
),
|
),
|
||||||
switchMap(([{ roomId }]) => {
|
switchMap(([{ roomId }]) => {
|
||||||
this.db.deleteRoom(roomId);
|
this.db.deleteRoom(roomId);
|
||||||
@@ -309,34 +301,26 @@ export class RoomsEffects {
|
|||||||
this.webrtc.leaveRoom(roomId);
|
this.webrtc.leaveRoom(roomId);
|
||||||
|
|
||||||
return of(RoomsActions.forgetRoomSuccess({ roomId }));
|
return of(RoomsActions.forgetRoomSuccess({ roomId }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
|
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
|
||||||
updateRoomSettings$ = createEffect(() =>
|
updateRoomSettings$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateRoomSettings),
|
ofType(RoomsActions.updateRoomSettings),
|
||||||
withLatestFrom(
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
this.store.select(selectCurrentUser),
|
|
||||||
this.store.select(selectCurrentRoom)
|
|
||||||
),
|
|
||||||
mergeMap(([{ settings }, currentUser, currentRoom]) => {
|
mergeMap(([{ settings }, currentUser, currentRoom]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser || !currentRoom) {
|
||||||
return of(
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' }));
|
||||||
RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only host/admin can update settings
|
// Only host/admin can update settings
|
||||||
if (
|
if (currentRoom.hostId !== currentUser.id && currentUser.role !== 'admin') {
|
||||||
currentRoom.hostId !== currentUser.id &&
|
|
||||||
currentUser.role !== 'admin'
|
|
||||||
) {
|
|
||||||
return of(
|
return of(
|
||||||
RoomsActions.updateRoomSettingsFailure({
|
RoomsActions.updateRoomSettingsFailure({
|
||||||
error: 'Permission denied',
|
error: 'Permission denied',
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,14 +342,10 @@ export class RoomsEffects {
|
|||||||
settings: updatedSettings,
|
settings: updatedSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
return of(
|
return of(RoomsActions.updateRoomSettingsSuccess({ settings: updatedSettings }));
|
||||||
RoomsActions.updateRoomSettingsSuccess({ settings: updatedSettings })
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message }))),
|
||||||
of(RoomsActions.updateRoomSettingsFailure({ error: error.message }))
|
),
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Persists room field changes to the local database. */
|
/** Persists room field changes to the local database. */
|
||||||
@@ -378,9 +358,9 @@ export class RoomsEffects {
|
|||||||
if (currentRoom && currentRoom.id === roomId) {
|
if (currentRoom && currentRoom.id === roomId) {
|
||||||
this.db.updateRoom(roomId, changes);
|
this.db.updateRoom(roomId, changes);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Updates room permission grants (host-only) and broadcasts to peers. */
|
/** Updates room permission grants (host-only) and broadcasts to peers. */
|
||||||
@@ -388,8 +368,12 @@ export class RoomsEffects {
|
|||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateRoomPermissions),
|
ofType(RoomsActions.updateRoomPermissions),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
filter(([{ roomId }, currentUser, currentRoom]) =>
|
filter(
|
||||||
!!currentUser && !!currentRoom && currentRoom.id === roomId && currentRoom.hostId === currentUser.id,
|
([{ roomId }, currentUser, currentRoom]) =>
|
||||||
|
!!currentUser &&
|
||||||
|
!!currentRoom &&
|
||||||
|
currentRoom.id === roomId &&
|
||||||
|
currentRoom.hostId === currentUser.id,
|
||||||
),
|
),
|
||||||
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
|
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
|
||||||
const updated: Partial<Room> = {
|
const updated: Partial<Room> = {
|
||||||
@@ -397,10 +381,13 @@ export class RoomsEffects {
|
|||||||
};
|
};
|
||||||
this.db.updateRoom(roomId, updated);
|
this.db.updateRoom(roomId, updated);
|
||||||
// Broadcast to peers
|
// Broadcast to peers
|
||||||
this.webrtc.broadcastMessage({ type: 'room-permissions-update', permissions: updated.permissions } as any);
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'room-permissions-update',
|
||||||
|
permissions: updated.permissions,
|
||||||
|
} as any);
|
||||||
return of(RoomsActions.updateRoom({ roomId, changes: updated }));
|
return of(RoomsActions.updateRoom({ roomId, changes: updated }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Updates the server icon (permission-enforced) and broadcasts to peers. */
|
/** Updates the server icon (permission-enforced) and broadcasts to peers. */
|
||||||
@@ -416,7 +403,9 @@ export class RoomsEffects {
|
|||||||
const role = currentUser.role;
|
const role = currentUser.role;
|
||||||
const perms = currentRoom.permissions || {};
|
const perms = currentRoom.permissions || {};
|
||||||
const isOwner = currentRoom.hostId === currentUser.id;
|
const isOwner = currentRoom.hostId === currentUser.id;
|
||||||
const canByRole = (role === 'admin' && perms.adminsManageIcon) || (role === 'moderator' && perms.moderatorsManageIcon);
|
const canByRole =
|
||||||
|
(role === 'admin' && perms.adminsManageIcon) ||
|
||||||
|
(role === 'moderator' && perms.moderatorsManageIcon);
|
||||||
if (!isOwner && !canByRole) {
|
if (!isOwner && !canByRole) {
|
||||||
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
||||||
}
|
}
|
||||||
@@ -425,10 +414,15 @@ export class RoomsEffects {
|
|||||||
const changes: Partial<Room> = { icon, iconUpdatedAt };
|
const changes: Partial<Room> = { icon, iconUpdatedAt };
|
||||||
this.db.updateRoom(roomId, changes);
|
this.db.updateRoom(roomId, changes);
|
||||||
// Broadcast to peers
|
// Broadcast to peers
|
||||||
this.webrtc.broadcastMessage({ type: 'server-icon-update', roomId, icon, iconUpdatedAt } as any);
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'server-icon-update',
|
||||||
|
roomId,
|
||||||
|
icon,
|
||||||
|
iconUpdatedAt,
|
||||||
|
} as any);
|
||||||
return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt }));
|
return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Persists newly created room to the local database. */
|
/** Persists newly created room to the local database. */
|
||||||
@@ -438,9 +432,27 @@ export class RoomsEffects {
|
|||||||
ofType(RoomsActions.createRoomSuccess),
|
ofType(RoomsActions.createRoomSuccess),
|
||||||
tap(({ room }) => {
|
tap(({ room }) => {
|
||||||
this.db.saveRoom(room);
|
this.db.saveRoom(room);
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Set the creator's role to 'host' after creating a room. */
|
||||||
|
setHostRoleOnCreate$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.createRoomSuccess),
|
||||||
|
map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Set the user's role to 'host' when rejoining a room they own. */
|
||||||
|
setHostRoleOnJoin$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.joinRoomSuccess),
|
||||||
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
|
filter(([{ room }, user]) => !!user && !!room.hostId && room.hostId === user.id),
|
||||||
|
map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } })),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Loads messages and bans when joining a room. */
|
/** Loads messages and bans when joining a room. */
|
||||||
@@ -452,28 +464,22 @@ export class RoomsEffects {
|
|||||||
// Don't load users from database - they come from signaling server
|
// Don't load users from database - they come from signaling server
|
||||||
// UsersActions.loadRoomUsers({ roomId: room.id }),
|
// UsersActions.loadRoomUsers({ roomId: room.id }),
|
||||||
UsersActions.loadBans(),
|
UsersActions.loadBans(),
|
||||||
])
|
]),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Clears messages and users from the store when leaving a room. */
|
/** Clears messages and users from the store when leaving a room. */
|
||||||
onLeaveRoom$ = createEffect(() =>
|
onLeaveRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.leaveRoomSuccess),
|
ofType(RoomsActions.leaveRoomSuccess),
|
||||||
mergeMap(() => [
|
mergeMap(() => [MessagesActions.clearMessages(), UsersActions.clearUsers()]),
|
||||||
MessagesActions.clearMessages(),
|
),
|
||||||
UsersActions.clearUsers(),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
|
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
|
||||||
signalingMessages$ = createEffect(() =>
|
signalingMessages$ = createEffect(() =>
|
||||||
this.webrtc.onSignalingMessage.pipe(
|
this.webrtc.onSignalingMessage.pipe(
|
||||||
withLatestFrom(
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
this.store.select(selectCurrentUser),
|
|
||||||
this.store.select(selectCurrentRoom),
|
|
||||||
),
|
|
||||||
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
|
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
|
||||||
const myId = currentUser?.oderId || currentUser?.id;
|
const myId = currentUser?.oderId || currentUser?.id;
|
||||||
const viewedServerId = currentRoom?.id;
|
const viewedServerId = currentRoom?.id;
|
||||||
@@ -487,7 +493,8 @@ export class RoomsEffects {
|
|||||||
return [UsersActions.clearUsers(), ...joinActions];
|
return [UsersActions.clearUsers(), ...joinActions];
|
||||||
}
|
}
|
||||||
case 'user_joined': {
|
case 'user_joined': {
|
||||||
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) return EMPTY;
|
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId)
|
||||||
|
return EMPTY;
|
||||||
return [UsersActions.userJoined({ user: buildSignalingUser(message) })];
|
return [UsersActions.userJoined({ user: buildSignalingUser(message) })];
|
||||||
}
|
}
|
||||||
case 'user_left': {
|
case 'user_left': {
|
||||||
@@ -504,10 +511,7 @@ export class RoomsEffects {
|
|||||||
/** Processes incoming P2P room and icon-sync events. */
|
/** Processes incoming P2P room and icon-sync events. */
|
||||||
incomingRoomEvents$ = createEffect(() =>
|
incomingRoomEvents$ = createEffect(() =>
|
||||||
this.webrtc.onMessageReceived.pipe(
|
this.webrtc.onMessageReceived.pipe(
|
||||||
withLatestFrom(
|
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectAllUsers)),
|
||||||
this.store.select(selectCurrentRoom),
|
|
||||||
this.store.select(selectAllUsers),
|
|
||||||
),
|
|
||||||
filter(([, room]) => !!room),
|
filter(([, room]) => !!room),
|
||||||
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
|
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
|
||||||
const room = currentRoom as Room;
|
const room = currentRoom as Room;
|
||||||
@@ -532,11 +536,7 @@ export class RoomsEffects {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
private handleVoiceOrScreenState(
|
private handleVoiceOrScreenState(event: any, allUsers: any[], kind: 'voice' | 'screen') {
|
||||||
event: any,
|
|
||||||
allUsers: any[],
|
|
||||||
kind: 'voice' | 'screen',
|
|
||||||
) {
|
|
||||||
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
||||||
if (!userId) return EMPTY;
|
if (!userId) return EMPTY;
|
||||||
|
|
||||||
@@ -547,23 +547,25 @@ export class RoomsEffects {
|
|||||||
if (!vs) return EMPTY;
|
if (!vs) return EMPTY;
|
||||||
|
|
||||||
if (!userExists) {
|
if (!userExists) {
|
||||||
return of(UsersActions.userJoined({
|
return of(
|
||||||
user: buildSignalingUser(
|
UsersActions.userJoined({
|
||||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
user: buildSignalingUser(
|
||||||
{
|
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||||
voiceState: {
|
{
|
||||||
isConnected: vs.isConnected ?? false,
|
voiceState: {
|
||||||
isMuted: vs.isMuted ?? false,
|
isConnected: vs.isConnected ?? false,
|
||||||
isDeafened: vs.isDeafened ?? false,
|
isMuted: vs.isMuted ?? false,
|
||||||
isSpeaking: vs.isSpeaking ?? false,
|
isDeafened: vs.isDeafened ?? false,
|
||||||
isMutedByAdmin: vs.isMutedByAdmin,
|
isSpeaking: vs.isSpeaking ?? false,
|
||||||
volume: vs.volume,
|
isMutedByAdmin: vs.isMutedByAdmin,
|
||||||
roomId: vs.roomId,
|
volume: vs.volume,
|
||||||
serverId: vs.serverId,
|
roomId: vs.roomId,
|
||||||
|
serverId: vs.serverId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
),
|
}),
|
||||||
}));
|
);
|
||||||
}
|
}
|
||||||
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
||||||
}
|
}
|
||||||
@@ -573,17 +575,21 @@ export class RoomsEffects {
|
|||||||
if (isSharing === undefined) return EMPTY;
|
if (isSharing === undefined) return EMPTY;
|
||||||
|
|
||||||
if (!userExists) {
|
if (!userExists) {
|
||||||
return of(UsersActions.userJoined({
|
return of(
|
||||||
user: buildSignalingUser(
|
UsersActions.userJoined({
|
||||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
user: buildSignalingUser(
|
||||||
{ screenShareState: { isSharing } },
|
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||||
),
|
{ screenShareState: { isSharing } },
|
||||||
}));
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return of(UsersActions.updateScreenShareState({
|
return of(
|
||||||
userId,
|
UsersActions.updateScreenShareState({
|
||||||
screenShareState: { isSharing },
|
userId,
|
||||||
}));
|
screenShareState: { isSharing },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRoomSettingsUpdate(event: any, room: Room) {
|
private handleRoomSettingsUpdate(event: any, room: Room) {
|
||||||
@@ -655,8 +661,8 @@ export class RoomsEffects {
|
|||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
iconUpdatedAt,
|
iconUpdatedAt,
|
||||||
} as any);
|
} as any);
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user