Move toju-app into own its folder
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
@if (isAdmin()) {
|
||||
<div class="h-full flex flex-col bg-card">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border flex items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('settings')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'settings'"
|
||||
[class.border-b-2]="activeTab() === 'settings'"
|
||||
[class.border-primary]="activeTab() === 'settings'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('members')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'members'"
|
||||
[class.border-b-2]="activeTab() === 'members'"
|
||||
[class.border-primary]="activeTab() === 'members'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'members'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Members
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('bans')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'bans'"
|
||||
[class.border-b-2]="activeTab() === 'bans'"
|
||||
[class.border-primary]="activeTab() === 'bans'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Bans
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('permissions')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'permissions'"
|
||||
[class.border-b-2]="activeTab() === 'permissions'"
|
||||
[class.border-primary]="activeTab() === 'permissions'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Perms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
@switch (activeTab()) {
|
||||
@case ('settings') {
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
|
||||
|
||||
<!-- Room Name -->
|
||||
<div>
|
||||
<label
|
||||
for="room-name-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Room Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="room-name-input"
|
||||
[(ngModel)]="roomName"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Room Description -->
|
||||
<div>
|
||||
<label
|
||||
for="room-description-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
id="room-description-input"
|
||||
[(ngModel)]="roomDescription"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Private Room Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Private Room</p>
|
||||
<p class="text-xs text-muted-foreground">Require approval to join</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePrivate()"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
[class.bg-primary]="isPrivate()"
|
||||
[class.text-primary-foreground]="isPrivate()"
|
||||
[class.bg-secondary]="!isPrivate()"
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideUnlock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Max Users -->
|
||||
<div>
|
||||
<label
|
||||
for="max-users-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Max Users (0 = unlimited)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="max-users-input"
|
||||
[(ngModel)]="maxUsers"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveSettings()"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirmDeleteRoom()"
|
||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('members') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
||||
|
||||
@if (membersFiltered().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<app-user-avatar
|
||||
[name]="user.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
||||
@if (user.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Role actions (only for non-hosts) -->
|
||||
@if (user.role !== 'host') {
|
||||
<div class="flex items-center gap-1">
|
||||
<select
|
||||
[ngModel]="user.role"
|
||||
(ngModelChange)="changeRole(user, $event)"
|
||||
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="banMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('bans') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||
|
||||
@if (bannedUsers().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
|
||||
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ ban.displayName || 'Unknown User' }}
|
||||
</p>
|
||||
@if (ban.reason) {
|
||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||
}
|
||||
@if (ban.expiresAt) {
|
||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('permissions') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
|
||||
|
||||
<!-- Permission Toggles -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
|
||||
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowVoice"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
|
||||
<p class="text-xs text-muted-foreground">Users can share their screen</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowScreenShare"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
|
||||
<p class="text-xs text-muted-foreground">Users can upload files</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowFileUploads"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Slow Mode</p>
|
||||
<p class="text-xs text-muted-foreground">Limit message frequency</p>
|
||||
</div>
|
||||
<select
|
||||
[(ngModel)]="slowModeInterval"
|
||||
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="0">Off</option>
|
||||
<option value="5">5 seconds</option>
|
||||
<option value="10">10 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
<option value="60">1 minute</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Management Permissions -->
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
|
||||
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="adminsManageRooms"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
|
||||
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="moderatorsManageRooms"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
|
||||
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="adminsManageIcon"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
|
||||
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="moderatorsManageIcon"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Permissions -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="savePermissions()"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (showDeleteConfirm()) {
|
||||
<app-confirm-dialog
|
||||
title="Delete Room"
|
||||
confirmLabel="Delete Room"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="deleteRoom()"
|
||||
(cancelled)="showDeleteConfirm.set(false)"
|
||||
>
|
||||
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
} @else {
|
||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>You don't have admin permissions</p>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
selectOnlineUsers
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, User } from '../../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
})
|
||||
],
|
||||
templateUrl: './admin-panel.component.html'
|
||||
})
|
||||
/**
|
||||
* Admin panel for managing room settings, members, bans, and permissions.
|
||||
* Only accessible to users with admin privileges.
|
||||
*/
|
||||
export class AdminPanelComponent {
|
||||
store = inject(Store);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
activeTab = signal<AdminTab>('settings');
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
// Settings
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
isPrivate = signal(false);
|
||||
maxUsers = 0;
|
||||
|
||||
// Permissions
|
||||
allowVoice = true;
|
||||
allowScreenShare = true;
|
||||
allowFileUploads = true;
|
||||
slowModeInterval = '0';
|
||||
adminsManageRooms = false;
|
||||
moderatorsManageRooms = false;
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
const perms = room.permissions || {};
|
||||
|
||||
this.allowVoice = perms.allowVoice !== false;
|
||||
this.allowScreenShare = perms.allowScreenShare !== false;
|
||||
this.allowFileUploads = perms.allowFileUploads !== false;
|
||||
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
|
||||
this.adminsManageRooms = !!perms.adminsManageRooms;
|
||||
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
|
||||
this.adminsManageIcon = !!perms.adminsManageIcon;
|
||||
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the room's private visibility setting. */
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((current) => !current);
|
||||
}
|
||||
|
||||
/** Save the current room name, description, privacy, and max-user settings. */
|
||||
saveSettings(): void {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomSettings({
|
||||
roomId: room.id,
|
||||
settings: {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
roomId: room.id,
|
||||
permissions: {
|
||||
allowVoice: this.allowVoice,
|
||||
allowScreenShare: this.allowScreenShare,
|
||||
allowFileUploads: this.allowFileUploads,
|
||||
slowModeInterval: parseInt(this.slowModeInterval, 10),
|
||||
adminsManageRooms: this.adminsManageRooms,
|
||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
||||
adminsManageIcon: this.adminsManageIcon,
|
||||
moderatorsManageIcon: this.moderatorsManageIcon
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove a user's ban entry. */
|
||||
unbanUser(ban: BanEntry): void {
|
||||
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
|
||||
oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
/** Show the delete-room confirmation dialog. */
|
||||
confirmDeleteRoom(): void {
|
||||
this.showDeleteConfirm.set(true);
|
||||
}
|
||||
|
||||
/** Delete the current room after confirmation. */
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
}
|
||||
|
||||
/** Format a ban expiry timestamp into a human-readable date/time string. */
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Members tab: get all users except self
|
||||
/** Return online users excluding the current user (for the members list). */
|
||||
membersFiltered(): User[] {
|
||||
const me = this.currentUser();
|
||||
|
||||
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||
}
|
||||
|
||||
/** Change a member's role and notify connected peers. */
|
||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
||||
role }));
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
roomId,
|
||||
targetUserId: user.id,
|
||||
role
|
||||
});
|
||||
}
|
||||
|
||||
/** Kick a member from the server. */
|
||||
kickMember(user: User): void {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
}
|
||||
|
||||
/** Ban a member from the server. */
|
||||
banMember(user: User): void {
|
||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<div class="h-full flex flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
<!-- Channel header bar -->
|
||||
@if (!isVoiceWorkspaceExpanded()) {
|
||||
<div class="h-12 flex items-center gap-2 px-4 border-b border-border bg-card flex-shrink-0">
|
||||
<ng-icon
|
||||
[name]="isVoiceWorkspaceExpanded() ? 'lucideMonitor' : 'lucideHash'"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="font-medium text-foreground text-sm">{{ headerTitle() }}</span>
|
||||
|
||||
@if (isVoiceWorkspaceExpanded()) {
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-primary">
|
||||
Voice streams
|
||||
</span>
|
||||
}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Chat Area -->
|
||||
<main class="relative flex-1 min-w-0">
|
||||
<div
|
||||
class="h-full overflow-hidden"
|
||||
[class.hidden]="isVoiceWorkspaceExpanded()"
|
||||
>
|
||||
<app-chat-messages />
|
||||
</div>
|
||||
|
||||
<app-screen-share-workspace />
|
||||
</main>
|
||||
|
||||
<!-- Sidebar always visible -->
|
||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
||||
<app-rooms-side-panel class="h-full" />
|
||||
</aside>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- No Room Selected -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-30"
|
||||
/>
|
||||
<h2 class="text-xl font-medium mb-2">No room selected</h2>
|
||||
<p class="text-sm">Select or create a room to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
104
toju-app/src/app/features/room/chat-room/chat-room.component.ts
Normal file
104
toju-app/src/app/features/room/chat-room/chat-room.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideHash,
|
||||
lucideMonitor,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
|
||||
import { ScreenShareWorkspaceComponent } from '../../../domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component';
|
||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
||||
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
selectTextChannels,
|
||||
selectVoiceChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
ScreenShareWorkspaceComponent,
|
||||
RoomsSidePanelComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideHash,
|
||||
lucideMonitor,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-room.component.html'
|
||||
})
|
||||
/**
|
||||
* Main chat room view combining the messages panel, side panels, and admin controls.
|
||||
*/
|
||||
export class ChatRoomComponent {
|
||||
private store = inject(Store);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
showMenu = signal(false);
|
||||
showAdminPanel = signal(false);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
activeTextChannelName = computed(() => {
|
||||
const id = this.activeChannelId();
|
||||
const activeChannel = this.textChannels().find((channel) => channel.id === id);
|
||||
|
||||
return activeChannel ? activeChannel.name : id;
|
||||
});
|
||||
|
||||
connectedVoiceChannelName = computed(() => {
|
||||
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
|
||||
|
||||
return voiceChannel?.name || 'Voice Lounge';
|
||||
});
|
||||
|
||||
headerTitle = computed(() =>
|
||||
this.isVoiceWorkspaceExpanded()
|
||||
? this.connectedVoiceChannelName()
|
||||
: this.activeTextChannelName()
|
||||
);
|
||||
|
||||
/** Open the settings modal to the Server admin page for the current room. */
|
||||
toggleAdminPanel() {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room) {
|
||||
this.settingsModal.open('server', room.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
|
||||
<aside class="w-80 bg-card h-full flex flex-col">
|
||||
<!-- Minimalistic header with tabs -->
|
||||
<div class="border-b border-border">
|
||||
<div class="flex items-center">
|
||||
<!-- Tab buttons -->
|
||||
<button
|
||||
(click)="activeTab.set('channels')"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
|
||||
[class.border-primary]="activeTab() === 'channels'"
|
||||
[class.text-foreground]="activeTab() === 'channels'"
|
||||
[class.border-transparent]="activeTab() !== 'channels'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'channels'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'channels'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Channels</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('users')"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
|
||||
[class.border-primary]="activeTab() === 'users'"
|
||||
[class.text-foreground]="activeTab() === 'users'"
|
||||
[class.border-transparent]="activeTab() !== 'users'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'users'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'users'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Users</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ knownUserCount() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channels View -->
|
||||
@if (activeTab() === 'channels') {
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Text Channels -->
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button
|
||||
(click)="createChannel('text')"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Create Text Channel"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
@for (ch of textChannels(); track ch.id) {
|
||||
<button
|
||||
class="w-full px-2 py-1.5 text-sm rounded flex items-center gap-2 text-left transition-colors"
|
||||
[class.bg-secondary]="activeChannelId() === ch.id"
|
||||
[class.text-foreground]="activeChannelId() === ch.id"
|
||||
[class.font-medium]="activeChannelId() === ch.id"
|
||||
[class.text-foreground/60]="activeChannelId() !== ch.id"
|
||||
[class.hover:bg-secondary/60]="activeChannelId() !== ch.id"
|
||||
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
|
||||
(click)="selectTextChannel(ch.id)"
|
||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||
>
|
||||
<span class="text-muted-foreground text-base">#</span>
|
||||
@if (renamingChannelId() === ch.id) {
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
[value]="ch.name"
|
||||
(keydown.enter)="confirmRename($event)"
|
||||
(keydown.escape)="cancelRename()"
|
||||
(blur)="confirmRename($event)"
|
||||
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
} @else {
|
||||
<span class="truncate">{{ ch.name }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Channels -->
|
||||
<div class="p-3 pt-0">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button
|
||||
(click)="createChannel('voice')"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Create Voice Channel"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (!voiceEnabled()) {
|
||||
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
@for (ch of voiceChannels(); track ch.id) {
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-1.5 text-sm rounded hover:bg-secondary/60 flex items-center justify-between text-left transition-colors"
|
||||
(click)="joinVoice(ch.id)"
|
||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
|
||||
[disabled]="!voiceEnabled()"
|
||||
[title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
@if (renamingChannelId() === ch.id) {
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
[value]="ch.name"
|
||||
(keydown.enter)="confirmRename($event)"
|
||||
(keydown.escape)="cancelRename()"
|
||||
(blur)="confirmRename($event)"
|
||||
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
} @else {
|
||||
<span>{{ ch.name }}</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@if (isCurrentRoom(ch.id)) {
|
||||
<span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
{{ isVoiceWorkspaceExpanded() ? 'Open' : 'View' }}
|
||||
</span>
|
||||
} @else if (voiceOccupancy(ch.id) > 0) {
|
||||
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
|
||||
}
|
||||
</button>
|
||||
<!-- Voice users connected to this channel -->
|
||||
@if (voiceUsersInRoom(ch.id).length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40"
|
||||
(contextmenu)="openVoiceUserVolumeMenu($event, u)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="u.displayName"
|
||||
[avatarUrl]="u.avatarUrl"
|
||||
size="xs"
|
||||
[ringClass]="
|
||||
u.voiceState?.isDeafened
|
||||
? 'ring-2 ring-red-500'
|
||||
: u.voiceState?.isMuted
|
||||
? 'ring-2 ring-yellow-500'
|
||||
: voiceActivity.isSpeaking(u.oderId || u.id)()
|
||||
? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]'
|
||||
: 'ring-2 ring-green-500/40'
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
<!-- Ping latency indicator -->
|
||||
@if (u.id !== currentUser()?.id) {
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
[class]="getPingColorClass(u)"
|
||||
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
|
||||
></span>
|
||||
}
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.oderId || u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
@if (isUserLocallyMuted(u)) {
|
||||
<ng-icon
|
||||
name="lucideVolumeX"
|
||||
class="w-4 h-4 text-destructive"
|
||||
title="Muted by you"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Users View -->
|
||||
@if (activeTab() === 'users') {
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
<!-- Current User (You) -->
|
||||
@if (currentUser()) {
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
[avatarUrl]="currentUser()?.avatarUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (currentUser()?.voiceState?.isConnected) {
|
||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
|
||||
<button
|
||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse hover:bg-red-600 transition-colors"
|
||||
(click)="viewStream(currentUser()!.oderId || currentUser()!.id); $event.stopPropagation()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Other Online Users -->
|
||||
@if (onlineRoomUsers().length > 0) {
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineRoomUsers().length }}</h4>
|
||||
<div class="space-y-1">
|
||||
@for (user of onlineRoomUsers(); track user.id) {
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
|
||||
(contextmenu)="openUserContextMenu($event, user)"
|
||||
>
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
[name]="user.displayName"
|
||||
[avatarUrl]="user.avatarUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
||||
@if (user.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
|
||||
<button
|
||||
(click)="viewStream(user.oderId || user.id); $event.stopPropagation()"
|
||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Offline Users -->
|
||||
@if (offlineRoomMembers().length > 0) {
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
|
||||
<div class="space-y-1">
|
||||
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded opacity-80">
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
[name]="member.displayName"
|
||||
[avatarUrl]="member.avatarUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-gray-500 ring-2 ring-card"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm text-foreground/80 truncate">{{ member.displayName }}</p>
|
||||
@if (member.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
|
||||
} @else if (member.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
|
||||
} @else if (member.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-[10px] text-muted-foreground">Offline</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- No other users message -->
|
||||
@if (onlineRoomUsers().length === 0 && offlineRoomMembers().length === 0) {
|
||||
<div class="text-center py-4 text-muted-foreground">
|
||||
<p class="text-sm">No other users in this server</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||
@if (voiceEnabled()) {
|
||||
<div [class.invisible]="showFloatingControls()">
|
||||
<app-voice-controls />
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
<!-- Channel context menu -->
|
||||
@if (showChannelMenu()) {
|
||||
<app-context-menu
|
||||
[x]="channelMenuX()"
|
||||
[y]="channelMenuY()"
|
||||
(closed)="closeChannelMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
<button
|
||||
(click)="resyncMessages()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Resync Messages
|
||||
</button>
|
||||
@if (canManageChannels()) {
|
||||
<div class="context-menu-divider"></div>
|
||||
<button
|
||||
(click)="startRename()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Rename Channel
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteChannel()"
|
||||
class="context-menu-item-danger"
|
||||
>
|
||||
Delete Channel
|
||||
</button>
|
||||
}
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
<!-- User context menu (kick / role management) -->
|
||||
@if (showUserMenu()) {
|
||||
<app-context-menu
|
||||
[x]="userMenuX()"
|
||||
[y]="userMenuY()"
|
||||
(closed)="closeUserMenu()"
|
||||
>
|
||||
@if (isAdmin()) {
|
||||
@if (contextMenuUser()?.role === 'member') {
|
||||
<button
|
||||
(click)="changeUserRole('moderator')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Moderator
|
||||
</button>
|
||||
<button
|
||||
(click)="changeUserRole('admin')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'moderator') {
|
||||
<button
|
||||
(click)="changeUserRole('admin')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
<button
|
||||
(click)="changeUserRole('member')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'admin') {
|
||||
<button
|
||||
(click)="changeUserRole('member')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
<div class="context-menu-divider"></div>
|
||||
<button
|
||||
(click)="kickUserAction()"
|
||||
class="context-menu-item-danger"
|
||||
>
|
||||
Kick User
|
||||
</button>
|
||||
} @else {
|
||||
<div class="context-menu-empty">No actions available</div>
|
||||
}
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
<!-- Per-user volume context menu -->
|
||||
@if (showVolumeMenu()) {
|
||||
<app-user-volume-menu
|
||||
[x]="volumeMenuX()"
|
||||
[y]="volumeMenuY()"
|
||||
[peerId]="volumeMenuPeerId()"
|
||||
[displayName]="volumeMenuDisplayName()"
|
||||
(closed)="showVolumeMenu.set(false)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Create channel dialog -->
|
||||
@if (showCreateChannelDialog()) {
|
||||
<app-confirm-dialog
|
||||
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
|
||||
confirmLabel="Create"
|
||||
(confirmed)="confirmCreateChannel()"
|
||||
(cancelled)="cancelCreateChannel()"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newChannelName"
|
||||
placeholder="Channel name"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
||||
(keydown.enter)="confirmCreateChannel()"
|
||||
/>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMessageSquare,
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideChevronLeft,
|
||||
lucideMonitor,
|
||||
lucideHash,
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
selectTextChannels,
|
||||
selectVoiceChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
||||
import {
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent,
|
||||
UserVolumeMenuComponent
|
||||
} from '../../../shared';
|
||||
import {
|
||||
Channel,
|
||||
ChatEvent,
|
||||
RoomMember,
|
||||
Room,
|
||||
User
|
||||
} from '../../../shared-kernel';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type TabView = 'channels' | 'users';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
VoiceControlsComponent,
|
||||
ContextMenuComponent,
|
||||
UserVolumeMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMessageSquare,
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideChevronLeft,
|
||||
lucideMonitor,
|
||||
lucideHash,
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './rooms-side-panel.component.html'
|
||||
})
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private realtime = inject(RealtimeSessionFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private screenShare = inject(ScreenShareFacade);
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
voiceActivity = inject(VoiceActivityService);
|
||||
|
||||
activeTab = signal<TabView>('channels');
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||
roomMemberIdentifiers = computed(() => {
|
||||
const identifiers = new Set<string>();
|
||||
|
||||
for (const member of this.roomMembers()) {
|
||||
this.addIdentifiers(identifiers, member);
|
||||
}
|
||||
|
||||
return identifiers;
|
||||
});
|
||||
onlineRoomUsers = computed(() => {
|
||||
const memberIdentifiers = this.roomMemberIdentifiers();
|
||||
|
||||
return this.onlineUsers().filter((user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user));
|
||||
});
|
||||
offlineRoomMembers = computed(() => {
|
||||
const onlineIdentifiers = new Set<string>();
|
||||
|
||||
for (const user of this.onlineRoomUsers()) {
|
||||
this.addIdentifiers(onlineIdentifiers, user);
|
||||
}
|
||||
|
||||
this.addIdentifiers(onlineIdentifiers, this.currentUser());
|
||||
|
||||
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
|
||||
});
|
||||
knownUserCount = computed(() => {
|
||||
const memberIds = new Set(
|
||||
this.roomMembers()
|
||||
.map((member) => this.roomMemberKey(member))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current) {
|
||||
memberIds.add(current.oderId || current.id);
|
||||
}
|
||||
|
||||
return memberIds.size;
|
||||
});
|
||||
|
||||
showChannelMenu = signal(false);
|
||||
channelMenuX = signal(0);
|
||||
channelMenuY = signal(0);
|
||||
contextChannel = signal<Channel | null>(null);
|
||||
|
||||
renamingChannelId = signal<string | null>(null);
|
||||
|
||||
showCreateChannelDialog = signal(false);
|
||||
createChannelType = signal<'text' | 'voice'>('text');
|
||||
newChannelName = '';
|
||||
|
||||
showUserMenu = signal(false);
|
||||
userMenuX = signal(0);
|
||||
userMenuY = signal(0);
|
||||
contextMenuUser = signal<User | null>(null);
|
||||
|
||||
showVolumeMenu = signal(false);
|
||||
volumeMenuX = signal(0);
|
||||
volumeMenuY = signal(0);
|
||||
volumeMenuPeerId = signal('');
|
||||
volumeMenuDisplayName = signal('');
|
||||
|
||||
private roomMemberKey(member: RoomMember): string {
|
||||
return member.oderId || member.id;
|
||||
}
|
||||
|
||||
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
|
||||
if (!entity)
|
||||
return;
|
||||
|
||||
if (entity.id) {
|
||||
identifiers.add(entity.id);
|
||||
}
|
||||
|
||||
if (entity.oderId) {
|
||||
identifiers.add(entity.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
|
||||
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
|
||||
}
|
||||
|
||||
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
return !!current && (
|
||||
(typeof entity.id === 'string' && entity.id === current.id)
|
||||
|| (typeof entity.oderId === 'string' && entity.oderId === current.oderId)
|
||||
);
|
||||
}
|
||||
|
||||
canManageChannels(): boolean {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!room || !user)
|
||||
return false;
|
||||
|
||||
if (room.hostId === user.id)
|
||||
return true;
|
||||
|
||||
const perms = room.permissions || {};
|
||||
|
||||
if (user.role === 'admin' && perms.adminsManageRooms)
|
||||
return true;
|
||||
|
||||
if (user.role === 'moderator' && perms.moderatorsManageRooms)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
selectTextChannel(channelId: string) {
|
||||
if (this.renamingChannelId())
|
||||
return;
|
||||
|
||||
this.voiceWorkspace.showChat();
|
||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||
}
|
||||
|
||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||
evt.preventDefault();
|
||||
this.contextChannel.set(channel);
|
||||
this.channelMenuX.set(evt.clientX);
|
||||
this.channelMenuY.set(evt.clientY);
|
||||
this.showChannelMenu.set(true);
|
||||
}
|
||||
|
||||
closeChannelMenu() {
|
||||
this.showChannelMenu.set(false);
|
||||
}
|
||||
|
||||
startRename() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
this.closeChannelMenu();
|
||||
|
||||
if (ch) {
|
||||
this.renamingChannelId.set(ch.id);
|
||||
}
|
||||
}
|
||||
|
||||
confirmRename(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const name = input.value.trim();
|
||||
const channelId = this.renamingChannelId();
|
||||
|
||||
if (channelId && name) {
|
||||
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
|
||||
}
|
||||
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
cancelRename() {
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
deleteChannel() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
this.closeChannelMenu();
|
||||
|
||||
if (ch) {
|
||||
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
|
||||
}
|
||||
}
|
||||
|
||||
resyncMessages() {
|
||||
this.closeChannelMenu();
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(MessagesActions.startSync());
|
||||
|
||||
const peers = this.realtime.getConnectedPeers();
|
||||
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
||||
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.realtime.sendToPeer(pid, inventoryRequest);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createChannel(type: 'text' | 'voice') {
|
||||
this.createChannelType.set(type);
|
||||
this.newChannelName = '';
|
||||
this.showCreateChannelDialog.set(true);
|
||||
}
|
||||
|
||||
confirmCreateChannel() {
|
||||
const name = this.newChannelName.trim();
|
||||
|
||||
if (!name)
|
||||
return;
|
||||
|
||||
const type = this.createChannelType();
|
||||
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
|
||||
const channel: Channel = {
|
||||
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
|
||||
name,
|
||||
type,
|
||||
position: existing.length
|
||||
};
|
||||
|
||||
this.store.dispatch(RoomsActions.addChannel({ channel }));
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
cancelCreateChannel() {
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
|
||||
if (!this.isAdmin())
|
||||
return;
|
||||
|
||||
this.contextMenuUser.set(user);
|
||||
this.userMenuX.set(evt.clientX);
|
||||
this.userMenuY.set(evt.clientY);
|
||||
this.showUserMenu.set(true);
|
||||
}
|
||||
|
||||
closeUserMenu() {
|
||||
this.showUserMenu.set(false);
|
||||
}
|
||||
|
||||
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
const me = this.currentUser();
|
||||
|
||||
if (user.id === me?.id || user.oderId === me?.oderId)
|
||||
return;
|
||||
|
||||
this.volumeMenuPeerId.set(user.oderId || user.id);
|
||||
this.volumeMenuDisplayName.set(user.displayName);
|
||||
this.volumeMenuX.set(evt.clientX);
|
||||
this.volumeMenuY.set(evt.clientY);
|
||||
this.showVolumeMenu.set(true);
|
||||
}
|
||||
|
||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||
const user = this.contextMenuUser();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
this.closeUserMenu();
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
this.realtime.broadcastMessage({
|
||||
type: 'role-change',
|
||||
roomId,
|
||||
targetUserId: user.id,
|
||||
role
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
kickUserAction() {
|
||||
const user = this.contextMenuUser();
|
||||
|
||||
this.closeUserMenu();
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
}
|
||||
}
|
||||
|
||||
joinVoice(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
const current = this.currentUser();
|
||||
|
||||
if (
|
||||
room
|
||||
&& current?.voiceState?.isConnected
|
||||
&& current.voiceState.roomId === roomId
|
||||
&& current.voiceState.serverId === room.id
|
||||
) {
|
||||
this.voiceWorkspace.open(null, { connectRemoteShares: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
if (!this.voiceConnection.isVoiceConnected()) {
|
||||
if (current.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice();
|
||||
|
||||
enableVoicePromise
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
this.updateVoiceStateStore(roomId, room, current);
|
||||
this.trackCurrentUserMic();
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
this.broadcastVoiceConnected(roomId, room, current);
|
||||
this.startVoiceSession(roomId, room);
|
||||
}
|
||||
|
||||
private trackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
const micStream = this.voiceConnection.getRawMicStream();
|
||||
|
||||
if (userId && micStream) {
|
||||
this.voiceActivity.trackLocalMic(userId, micStream);
|
||||
}
|
||||
}
|
||||
|
||||
private untrackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
||||
if (!current?.id)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startVoiceSession(roomId: string, room: Room): void {
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
||||
return;
|
||||
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
|
||||
this.untrackCurrentUserMic();
|
||||
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
if (current?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
voiceOccupancy(roomId: string): number {
|
||||
return this.voiceUsersInRoom(roomId).length;
|
||||
}
|
||||
|
||||
viewShare(userId: string) {
|
||||
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
|
||||
}
|
||||
|
||||
viewStream(userId: string) {
|
||||
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
|
||||
}
|
||||
|
||||
isUserLocallyMuted(user: User): boolean {
|
||||
const peerId = user.oderId || user.id;
|
||||
|
||||
return this.voicePlayback.isUserMuted(peerId);
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
|
||||
if (me?.id === userId) {
|
||||
return this.screenShare.isScreenSharing();
|
||||
}
|
||||
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const peerKeys = [
|
||||
user?.oderId,
|
||||
user?.id,
|
||||
userId
|
||||
].filter(
|
||||
(candidate): candidate is string => !!candidate
|
||||
);
|
||||
const stream = peerKeys
|
||||
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
|
||||
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
|
||||
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
const me = this.currentUser();
|
||||
const remoteUsers = this.onlineUsers().filter(
|
||||
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
||||
);
|
||||
|
||||
if (
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
me.voiceState?.serverId === room?.id
|
||||
) {
|
||||
const meId = me.id;
|
||||
const meOderId = me.oderId;
|
||||
const alreadyIncluded = remoteUsers.some(
|
||||
(user) => user.id === meId || user.oderId === meOderId
|
||||
);
|
||||
|
||||
if (!alreadyIncluded) {
|
||||
return [me, ...remoteUsers];
|
||||
}
|
||||
}
|
||||
|
||||
return remoteUsers;
|
||||
}
|
||||
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||
}
|
||||
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
}
|
||||
|
||||
getPeerLatency(user: User): number | null {
|
||||
const latencies = this.voiceConnection.peerLatencies();
|
||||
|
||||
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
|
||||
}
|
||||
|
||||
getPingColorClass(user: User): string {
|
||||
const ms = this.getPeerLatency(user);
|
||||
|
||||
if (ms === null)
|
||||
return 'bg-gray-500';
|
||||
|
||||
if (ms < 100)
|
||||
return 'bg-green-500';
|
||||
|
||||
if (ms < 200)
|
||||
return 'bg-yellow-500';
|
||||
|
||||
if (ms < 350)
|
||||
return 'bg-orange-500';
|
||||
|
||||
return 'bg-red-500';
|
||||
}
|
||||
}
|
||||
115
toju-app/src/app/features/servers/servers-rail.component.html
Normal file
115
toju-app/src/app/features/servers/servers-rail.component.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
||||
<!-- Create button -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Create Server"
|
||||
(click)="createServer()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||
@for (room of visibleSavedRooms(); track room.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||
[title]="room.name"
|
||||
(click)="joinSavedRoom(room)"
|
||||
(contextmenu)="openContextMenu($event, room)"
|
||||
>
|
||||
@if (room.icon) {
|
||||
<img
|
||||
[ngSrc]="room.icon"
|
||||
[alt]="room.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Context menu -->
|
||||
@if (showMenu()) {
|
||||
<app-context-menu
|
||||
[x]="menuX()"
|
||||
[y]="menuY()"
|
||||
(closed)="closeMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
(click)="openLeaveConfirm()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@if (showBannedDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Banned"
|
||||
confirmLabel="OK"
|
||||
cancelLabel="Close"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="closeBannedDialog()"
|
||||
(cancelled)="closeBannedDialog()"
|
||||
>
|
||||
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@if (showPasswordDialog() && passwordPromptRoom()) {
|
||||
<app-confirm-dialog
|
||||
title="Password required"
|
||||
confirmLabel="Join server"
|
||||
cancelLabel="Cancel"
|
||||
[widthClass]="'w-[420px] max-w-[92vw]'"
|
||||
(confirmed)="confirmPasswordJoin()"
|
||||
(cancelled)="closePasswordDialog()"
|
||||
>
|
||||
<div class="space-y-3 text-left">
|
||||
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="rail-join-password"
|
||||
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Server password
|
||||
</label>
|
||||
<input
|
||||
id="rail-join-password"
|
||||
type="password"
|
||||
[(ngModel)]="joinPassword"
|
||||
placeholder="Enter password"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (joinPasswordError()) {
|
||||
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@if (showLeaveConfirm() && contextRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="contextRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeave($event)"
|
||||
(cancelled)="cancelLeave()"
|
||||
/>
|
||||
}
|
||||
349
toju-app/src/app/features/servers/servers-rail.component.ts
Normal file
349
toju-app/src/app/features/servers/servers-rail.component.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { VoiceSessionFacade } from '../../domains/voice-session';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent
|
||||
} from '../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent,
|
||||
NgOptimizedImage
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
export class ServersRailComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private banLookupRequestVersion = 0;
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
showMenu = signal(false);
|
||||
menuX = signal(72);
|
||||
menuY = signal(100);
|
||||
contextRoom = signal<Room | null>(null);
|
||||
showLeaveConfirm = signal(false);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||
bannedServerName = signal('');
|
||||
showBannedDialog = signal(false);
|
||||
showPasswordDialog = signal(false);
|
||||
passwordPromptRoom = signal<Room | null>(null);
|
||||
joinPassword = signal('');
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const rooms = this.savedRooms();
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
void this.refreshBannedLookup(rooms, currentUser ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name)
|
||||
return '?';
|
||||
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
|
||||
return ch || '?';
|
||||
}
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
createServer(): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
}
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
async joinSavedRoom(room: Room): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.isRoomBanned(room)) {
|
||||
this.bannedServerName.set(room.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
});
|
||||
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
|
||||
|
||||
this.prepareVoiceContext(room);
|
||||
|
||||
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
|
||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||
skipBanCheck: true }));
|
||||
} else {
|
||||
await this.attemptJoinRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
closeBannedDialog(): void {
|
||||
this.showBannedDialog.set(false);
|
||||
this.bannedServerName.set('');
|
||||
}
|
||||
|
||||
closePasswordDialog(): void {
|
||||
this.showPasswordDialog.set(false);
|
||||
this.passwordPromptRoom.set(null);
|
||||
this.joinPassword.set('');
|
||||
this.joinPasswordError.set(null);
|
||||
}
|
||||
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const room = this.passwordPromptRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
await this.attemptJoinRoom(room, this.joinPassword());
|
||||
}
|
||||
|
||||
isRoomMarkedBanned(room: Room): boolean {
|
||||
return !!this.bannedRoomLookup()[room.id];
|
||||
}
|
||||
|
||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
this.menuX.set(Math.max(evt.clientX + 8, 72));
|
||||
this.menuY.set(evt.clientY);
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.showMenu.set(false);
|
||||
}
|
||||
|
||||
isCurrentContextRoom(): boolean {
|
||||
const ctx = this.contextRoom();
|
||||
const cur = this.currentRoom();
|
||||
|
||||
return !!ctx && !!cur && ctx.id === cur.id;
|
||||
}
|
||||
|
||||
openLeaveConfirm(): void {
|
||||
this.closeMenu();
|
||||
|
||||
if (this.contextRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||
const ctx = this.contextRoom();
|
||||
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({
|
||||
roomId: ctx.id,
|
||||
nextOwnerKey: result.nextOwnerKey
|
||||
}));
|
||||
|
||||
if (isCurrentRoom) {
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
this.showLeaveConfirm.set(false);
|
||||
this.contextRoom.set(null);
|
||||
}
|
||||
|
||||
cancelLeave(): void {
|
||||
this.showLeaveConfirm.set(false);
|
||||
}
|
||||
|
||||
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
|
||||
const requestVersion = ++this.banLookupRequestVersion;
|
||||
|
||||
if (!currentUser || rooms.length === 0) {
|
||||
this.bannedRoomLookup.set({});
|
||||
return;
|
||||
}
|
||||
|
||||
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const entries = await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const bans = await this.db.getBansForRoom(room.id);
|
||||
|
||||
return [room.id, hasRoomBanForUser(bans, currentUser, persistedUserId)] as const;
|
||||
})
|
||||
);
|
||||
|
||||
if (requestVersion !== this.banLookupRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bannedRoomLookup.set(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
private async isRoomBanned(room: Room): Promise<boolean> {
|
||||
const currentUser = this.currentUser();
|
||||
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUser && !persistedUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bans = await this.db.getBansForRoom(room.id);
|
||||
|
||||
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
||||
}
|
||||
|
||||
private prepareVoiceContext(room: Room): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId && voiceServerId !== room.id) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
} else if (voiceServerId === room.id) {
|
||||
this.voiceSession.setViewingVoiceServer(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptJoinRoom(room: Room, password?: string): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUserId)
|
||||
return;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: room.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
password: password?.trim() || undefined
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
}));
|
||||
|
||||
this.closePasswordDialog();
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
...this.toServerInfo(room),
|
||||
...response.server
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as {
|
||||
error?: { error?: string; errorCode?: string };
|
||||
};
|
||||
const errorCode = serverError?.error?.errorCode;
|
||||
const message = serverError?.error?.error || 'Failed to join server';
|
||||
|
||||
if (errorCode === 'PASSWORD_REQUIRED') {
|
||||
this.passwordPromptRoom.set(room);
|
||||
this.showPasswordDialog.set(true);
|
||||
this.joinPasswordError.set(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.bannedServerName.set(room.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldFallbackToOfflineView(error)) {
|
||||
this.closePasswordDialog();
|
||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||
skipBanCheck: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldFallbackToOfflineView(error: unknown): boolean {
|
||||
const serverError = error as {
|
||||
error?: { errorCode?: string };
|
||||
status?: number;
|
||||
};
|
||||
const errorCode = serverError?.error?.errorCode;
|
||||
const status = serverError?.status;
|
||||
|
||||
return errorCode === 'SERVER_NOT_FOUND'
|
||||
|| status === 0
|
||||
|| status === 404
|
||||
|| (typeof status === 'number' && status >= 500);
|
||||
}
|
||||
|
||||
private toServerInfo(room: Room) {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? 50,
|
||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||
isPrivate: room.isPrivate,
|
||||
createdAt: room.createdAt,
|
||||
ownerId: room.hostId,
|
||||
sourceId: room.sourceId,
|
||||
sourceName: room.sourceName,
|
||||
sourceUrl: room.sourceUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@if (server()) {
|
||||
<div class="space-y-3 max-w-xl">
|
||||
@if (bannedUsers().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
|
||||
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ ban.displayName || 'Unknown User' }}
|
||||
</p>
|
||||
@if (ban.reason) {
|
||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||
}
|
||||
@if (ban.expiresAt) {
|
||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
</div>
|
||||
@if (isAdmin()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Actions, ofType } from '@ngrx/effects';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
|
||||
import { Room, BanEntry } from '../../../../shared-kernel';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bans-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './bans-settings.component.html'
|
||||
})
|
||||
export class BansSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private actions$ = inject(Actions);
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
bannedUsers = signal<BanEntry[]>([]);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const roomId = this.server()?.id;
|
||||
|
||||
if (!roomId) {
|
||||
this.bannedUsers.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadBansForServer(roomId);
|
||||
});
|
||||
|
||||
this.actions$
|
||||
.pipe(
|
||||
ofType(
|
||||
UsersActions.banUserSuccess,
|
||||
UsersActions.unbanUserSuccess,
|
||||
UsersActions.loadBansSuccess,
|
||||
RoomsActions.updateRoom
|
||||
),
|
||||
takeUntilDestroyed()
|
||||
)
|
||||
.subscribe(() => {
|
||||
const roomId = this.server()?.id;
|
||||
|
||||
if (roomId) {
|
||||
void this.loadBansForServer(roomId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unbanUser(ban: BanEntry): void {
|
||||
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
|
||||
oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
private async loadBansForServer(roomId: string): Promise<void> {
|
||||
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return (
|
||||
date.toLocaleDateString() +
|
||||
' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<div class="max-w-3xl space-y-6">
|
||||
<section class="rounded-xl border border-border bg-card/40 p-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-xl bg-primary/10 p-2 text-primary">
|
||||
<ng-icon
|
||||
name="lucideBug"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground">App-wide debugging</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
[checked]="enabled()"
|
||||
(change)="onEnabledChange($event)"
|
||||
/>
|
||||
<div
|
||||
class="h-5 w-10 rounded-full bg-secondary after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideClock3"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="text-xs font-medium uppercase tracking-wide">Captured events</span>
|
||||
</div>
|
||||
<p class="mt-3 text-2xl font-semibold text-foreground">{{ entryCount() }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Last update: {{ lastUpdatedLabel() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideCircleAlert"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="text-xs font-medium uppercase tracking-wide">Errors</span>
|
||||
</div>
|
||||
<p class="mt-3 text-2xl font-semibold text-destructive">{{ errorCount() }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Unhandled runtime failures and rejected promises.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideTriangleAlert"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="text-xs font-medium uppercase tracking-wide">Warnings</span>
|
||||
</div>
|
||||
<p class="mt-3 text-2xl font-semibold text-yellow-400">{{ warningCount() }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Navigation cancellations, offline events, and other warnings.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-border bg-card/40 p-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Floating debug console</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
When debugging is enabled, a bug icon appears in the app so you can open the docked console without blocking the rest of the UI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openConsole()"
|
||||
[disabled]="!enabled()"
|
||||
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{{ isConsoleOpen() ? 'Console open' : 'Open console' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="clearLogs()"
|
||||
[disabled]="entryCount() === 0"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Clear logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideBug,
|
||||
lucideCircleAlert,
|
||||
lucideClock3,
|
||||
lucideTrash2,
|
||||
lucideTriangleAlert
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-debugging-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideBug,
|
||||
lucideCircleAlert,
|
||||
lucideClock3,
|
||||
lucideTrash2,
|
||||
lucideTriangleAlert
|
||||
})
|
||||
],
|
||||
templateUrl: './debugging-settings.component.html'
|
||||
})
|
||||
export class DebuggingSettingsComponent {
|
||||
readonly debugging = inject(DebuggingService);
|
||||
|
||||
readonly enabled = this.debugging.enabled;
|
||||
readonly isConsoleOpen = this.debugging.isConsoleOpen;
|
||||
readonly entryCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0);
|
||||
});
|
||||
readonly errorCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => {
|
||||
return sum + (entry.level === 'error' ? entry.count : 0);
|
||||
}, 0);
|
||||
});
|
||||
readonly warningCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => {
|
||||
return sum + (entry.level === 'warn' ? entry.count : 0);
|
||||
}, 0);
|
||||
});
|
||||
readonly lastUpdatedLabel = computed(() => {
|
||||
const lastEntry = this.debugging.entries().at(-1);
|
||||
|
||||
return lastEntry ? lastEntry.timeLabel : 'No logs yet';
|
||||
});
|
||||
|
||||
onEnabledChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.debugging.setEnabled(input.checked);
|
||||
}
|
||||
|
||||
openConsole(): void {
|
||||
this.debugging.openConsole();
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.debugging.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="space-y-6 max-w-xl">
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon
|
||||
name="lucidePower"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Application</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
||||
[class.opacity-60]="!isElectron"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
|
||||
|
||||
@if (isElectron) {
|
||||
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
|
||||
} @else {
|
||||
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="relative inline-flex items-center"
|
||||
[class.cursor-pointer]="isElectron && !savingAutoStart()"
|
||||
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="autoStart()"
|
||||
[disabled]="!isElectron || savingAutoStart()"
|
||||
(change)="onAutoStartChange($event)"
|
||||
id="general-auto-start-toggle"
|
||||
aria-label="Toggle launch on startup"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePower } from '@ng-icons/lucide';
|
||||
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
|
||||
@Component({
|
||||
selector: 'app-general-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucidePower
|
||||
})
|
||||
],
|
||||
templateUrl: './general-settings.component.html'
|
||||
})
|
||||
export class GeneralSettingsComponent {
|
||||
private platform = inject(PlatformService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
autoStart = signal(false);
|
||||
savingAutoStart = signal(false);
|
||||
|
||||
constructor() {
|
||||
if (this.isElectron) {
|
||||
void this.loadDesktopSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async onAutoStartChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const enabled = !!input.checked;
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!this.isElectron || !api) {
|
||||
input.checked = this.autoStart();
|
||||
return;
|
||||
}
|
||||
|
||||
this.savingAutoStart.set(true);
|
||||
|
||||
try {
|
||||
const snapshot = await api.setDesktopSettings({ autoStart: enabled });
|
||||
|
||||
this.autoStart.set(snapshot.autoStart);
|
||||
} catch {
|
||||
input.checked = this.autoStart();
|
||||
} finally {
|
||||
this.savingAutoStart.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await api.getDesktopSettings();
|
||||
|
||||
this.autoStart.set(snapshot.autoStart);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@if (server()) {
|
||||
<div class="space-y-3 max-w-xl">
|
||||
@if (members().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
|
||||
} @else {
|
||||
@for (member of members(); track member.oderId || member.id) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<app-user-avatar
|
||||
[name]="member.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ member.displayName }}
|
||||
</p>
|
||||
@if (member.isOnline) {
|
||||
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span>
|
||||
}
|
||||
@if (member.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||
} @else if (member.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||
} @else if (member.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (member.role !== 'host' && isAdmin()) {
|
||||
<div class="flex items-center gap-1">
|
||||
@if (canChangeRoles()) {
|
||||
<select
|
||||
[ngModel]="member.role"
|
||||
(ngModelChange)="changeRole(member, $event)"
|
||||
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
}
|
||||
@if (canKickMembers()) {
|
||||
<button
|
||||
(click)="kickMember(member)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (canBanMembers()) {
|
||||
<button
|
||||
(click)="banMember(member)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { lucideUserX, lucideBan } from '@ng-icons/lucide';
|
||||
|
||||
import {
|
||||
Room,
|
||||
RoomMember,
|
||||
UserRole
|
||||
} from '../../../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
|
||||
interface ServerMemberView extends RoomMember {
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-members-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideUserX,
|
||||
lucideBan
|
||||
})
|
||||
],
|
||||
templateUrl: './members-settings.component.html'
|
||||
})
|
||||
export class MembersSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private webrtcService = inject(RealtimeSessionFacade);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
accessRole = input<UserRole | null>(null);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
usersEntities = this.store.selectSignal(selectUsersEntities);
|
||||
|
||||
members = computed<ServerMemberView[]>(() => {
|
||||
const room = this.server();
|
||||
const me = this.currentUser();
|
||||
const currentRoom = this.currentRoom();
|
||||
const usersEntities = this.usersEntities();
|
||||
|
||||
if (!room)
|
||||
return [];
|
||||
|
||||
return (room.members ?? [])
|
||||
.filter((member) => member.id !== me?.id && member.oderId !== me?.oderId)
|
||||
.map((member) => {
|
||||
const liveUser = currentRoom?.id === room.id
|
||||
? (usersEntities[member.id]
|
||||
|| Object.values(usersEntities).find((user) => !!user && user.oderId === member.oderId)
|
||||
|| null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...member,
|
||||
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
|
||||
displayName: liveUser?.displayName || member.displayName,
|
||||
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
canChangeRoles(): boolean {
|
||||
const role = this.accessRole();
|
||||
|
||||
return role === 'host' || role === 'admin';
|
||||
}
|
||||
|
||||
canKickMembers(): boolean {
|
||||
const role = this.accessRole();
|
||||
|
||||
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||
}
|
||||
|
||||
canBanMembers(): boolean {
|
||||
const role = this.accessRole();
|
||||
|
||||
return role === 'host' || role === 'admin';
|
||||
}
|
||||
|
||||
changeRole(member: ServerMemberView, role: 'admin' | 'moderator' | 'member'): void {
|
||||
const room = this.server();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
const members = (room.members ?? []).map((existingMember) =>
|
||||
existingMember.id === member.id || existingMember.oderId === member.oderId
|
||||
? { ...existingMember,
|
||||
role }
|
||||
: existingMember
|
||||
);
|
||||
|
||||
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
|
||||
changes: { members } }));
|
||||
|
||||
if (this.currentRoom()?.id === room.id) {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: member.id,
|
||||
role }));
|
||||
}
|
||||
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'role-change',
|
||||
roomId: room.id,
|
||||
targetUserId: member.id,
|
||||
role
|
||||
});
|
||||
}
|
||||
|
||||
kickMember(member: ServerMemberView): void {
|
||||
const room = this.server();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: member.id,
|
||||
roomId: room.id }));
|
||||
}
|
||||
|
||||
banMember(member: ServerMemberView): void {
|
||||
const room = this.server();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(UsersActions.banUser({ userId: member.id,
|
||||
roomId: room.id,
|
||||
displayName: member.displayName }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<div class="space-y-6 max-w-xl">
|
||||
<!-- Server Endpoints -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (hasMissingDefaultServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultServers()"
|
||||
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Restore Defaults
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="testAllServers()"
|
||||
[disabled]="isTesting()"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="w-3.5 h-3.5"
|
||||
[class.animate-spin]="isTesting()"
|
||||
/>
|
||||
Test All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
<div class="space-y-2 mb-3">
|
||||
@for (server of servers(); track server.id) {
|
||||
<div
|
||||
class="flex items-center gap-3 p-2.5 rounded-lg border transition-colors"
|
||||
[class.border-primary]="server.isActive"
|
||||
[class.bg-primary/5]="server.isActive"
|
||||
[class.border-border]="!server.isActive"
|
||||
[class.bg-secondary/30]="!server.isActive"
|
||||
>
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
[class.bg-green-500]="server.status === 'online'"
|
||||
[class.bg-red-500]="server.status === 'offline'"
|
||||
[class.bg-yellow-500]="server.status === 'checking'"
|
||||
[class.bg-muted]="server.status === 'unknown'"
|
||||
[class.bg-orange-500]="server.status === 'incompatible'"
|
||||
></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
|
||||
@if (server.isActive) {
|
||||
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
|
||||
}
|
||||
@if (server.status === 'incompatible') {
|
||||
<p class="text-[10px] text-destructive">Update the client in order to connect to other users</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
@if (!server.isActive && server.status !== 'incompatible') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="setActiveServer(server.id)"
|
||||
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Activate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-3.5 h-3.5 text-muted-foreground hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (server.isActive && hasMultipleActiveServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="deactivateServer(server.id)"
|
||||
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Deactivate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (hasMultipleServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeServer(server.id)"
|
||||
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-3.5 h-3.5 text-muted-foreground hover:text-destructive"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add New Server -->
|
||||
<div class="border-t border-border pt-3">
|
||||
<h4 class="text-xs font-medium text-foreground mb-2">Add New Server</h4>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="Server name"
|
||||
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="newServerUrl"
|
||||
placeholder="Server URL (e.g., http://localhost:3001)"
|
||||
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="addServer()"
|
||||
[disabled]="!newServerName || !newServerUrl"
|
||||
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
<p class="text-xs text-destructive mt-1.5">{{ addError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Connection Settings -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon
|
||||
name="lucideServer"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Auto-reconnect</p>
|
||||
<p class="text-xs text-muted-foreground">Reconnect when connection is lost</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="autoReconnect"
|
||||
(change)="saveConnectionSettings()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Search all servers</p>
|
||||
<p class="text-xs text-muted-foreground">Search across all server directories</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="searchAllServers"
|
||||
(change)="saveConnectionSettings()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,138 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideGlobe,
|
||||
lucideServer,
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-network-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideGlobe,
|
||||
lucideServer,
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './network-settings.component.html'
|
||||
})
|
||||
export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||
isTesting = signal(false);
|
||||
addError = signal<string | null>(null);
|
||||
newServerName = '';
|
||||
newServerUrl = '';
|
||||
autoReconnect = true;
|
||||
searchAllServers = true;
|
||||
|
||||
constructor() {
|
||||
this.loadConnectionSettings();
|
||||
}
|
||||
|
||||
addServer(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
try {
|
||||
new URL(this.newServerUrl);
|
||||
} catch {
|
||||
this.addError.set('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.servers().some((serverEntry) => serverEntry.url === this.newServerUrl)) {
|
||||
this.addError.set('This server URL already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverDirectory.addServer({
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, '')
|
||||
});
|
||||
|
||||
this.newServerName = '';
|
||||
this.newServerUrl = '';
|
||||
const servers = this.servers();
|
||||
const newServer = servers[servers.length - 1];
|
||||
|
||||
if (newServer)
|
||||
this.serverDirectory.testServer(newServer.id);
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
this.serverDirectory.removeServer(id);
|
||||
}
|
||||
|
||||
setActiveServer(id: string): void {
|
||||
this.serverDirectory.setActiveServer(id);
|
||||
}
|
||||
|
||||
deactivateServer(id: string): void {
|
||||
this.serverDirectory.deactivateServer(id);
|
||||
}
|
||||
|
||||
restoreDefaultServers(): void {
|
||||
this.serverDirectory.restoreDefaultServers();
|
||||
}
|
||||
|
||||
async testAllServers(): Promise<void> {
|
||||
this.isTesting.set(true);
|
||||
await this.serverDirectory.testAllServers();
|
||||
this.isTesting.set(false);
|
||||
}
|
||||
|
||||
loadConnectionSettings(): void {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
this.autoReconnect = parsed.autoReconnect ?? true;
|
||||
this.searchAllServers = parsed.searchAllServers ?? true;
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
}
|
||||
|
||||
saveConnectionSettings(): void {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
@if (server()) {
|
||||
<div class="space-y-4 max-w-xl">
|
||||
@if (!isAdmin()) {
|
||||
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
|
||||
}
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
|
||||
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowVoice"
|
||||
[disabled]="!isAdmin()"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
|
||||
<p class="text-xs text-muted-foreground">Users can share their screen</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowScreenShare"
|
||||
[disabled]="!isAdmin()"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
|
||||
<p class="text-xs text-muted-foreground">Users can upload files</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowFileUploads"
|
||||
[disabled]="!isAdmin()"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Slow Mode</p>
|
||||
<p class="text-xs text-muted-foreground">Limit message frequency</p>
|
||||
</div>
|
||||
<select
|
||||
[(ngModel)]="slowModeInterval"
|
||||
[disabled]="!isAdmin()"
|
||||
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="0">Off</option>
|
||||
<option value="5">5 seconds</option>
|
||||
<option value="10">10 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
<option value="60">1 minute</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Management permissions -->
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
|
||||
<p class="text-xs text-muted-foreground">Allow admins to create/modify rooms</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="adminsManageRooms"
|
||||
[disabled]="!isAdmin()"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
|
||||
<p class="text-xs text-muted-foreground">Allow moderators to create/modify rooms</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="moderatorsManageRooms"
|
||||
[disabled]="!isAdmin()"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
|
||||
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="adminsManageIcon"
|
||||
[disabled]="!isAdmin()"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
|
||||
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="moderatorsManageIcon"
|
||||
[disabled]="!isAdmin()"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isAdmin()) {
|
||||
<button
|
||||
(click)="savePermissions()"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
[class.bg-green-600]="saveSuccess() === 'permissions'"
|
||||
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { lucideCheck } from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../../../shared-kernel';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-permissions-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck
|
||||
})
|
||||
],
|
||||
templateUrl: './permissions-settings.component.html'
|
||||
})
|
||||
export class PermissionsSettingsComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
allowVoice = true;
|
||||
allowScreenShare = true;
|
||||
allowFileUploads = true;
|
||||
slowModeInterval = '0';
|
||||
adminsManageRooms = false;
|
||||
moderatorsManageRooms = false;
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
saveSuccess = signal<string | null>(null);
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Load permissions from the server input. Called by parent via effect or on init. */
|
||||
loadPermissions(room: Room): void {
|
||||
const perms = room.permissions || {};
|
||||
|
||||
this.allowVoice = perms.allowVoice !== false;
|
||||
this.allowScreenShare = perms.allowScreenShare !== false;
|
||||
this.allowFileUploads = perms.allowFileUploads !== false;
|
||||
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
|
||||
this.adminsManageRooms = !!perms.adminsManageRooms;
|
||||
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
|
||||
this.adminsManageIcon = !!perms.adminsManageIcon;
|
||||
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
|
||||
}
|
||||
|
||||
savePermissions(): void {
|
||||
const room = this.server();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
roomId: room.id,
|
||||
permissions: {
|
||||
allowVoice: this.allowVoice,
|
||||
allowScreenShare: this.allowScreenShare,
|
||||
allowFileUploads: this.allowFileUploads,
|
||||
slowModeInterval: parseInt(this.slowModeInterval, 10),
|
||||
adminsManageRooms: this.adminsManageRooms,
|
||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
||||
adminsManageIcon: this.adminsManageIcon,
|
||||
moderatorsManageIcon: this.moderatorsManageIcon
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.showSaveSuccess('permissions');
|
||||
}
|
||||
|
||||
private showSaveSuccess(key: string): void {
|
||||
this.saveSuccess.set(key);
|
||||
|
||||
if (this.saveTimeout)
|
||||
clearTimeout(this.saveTimeout);
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
@if (serverData()) {
|
||||
<div class="space-y-5 max-w-xl">
|
||||
<section>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
|
||||
@if (!isAdmin()) {
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
|
||||
</p>
|
||||
}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="room-name"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Room Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="roomName"
|
||||
[readOnly]="!isAdmin()"
|
||||
id="room-name"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="room-description"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
[(ngModel)]="roomDescription"
|
||||
[readOnly]="!isAdmin()"
|
||||
rows="3"
|
||||
id="room-description"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
[class.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (isAdmin()) {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Private Room</p>
|
||||
<p class="text-xs text-muted-foreground">Require approval to join</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="togglePrivate()"
|
||||
type="button"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
[class.bg-primary]="isPrivate()"
|
||||
[class.text-primary-foreground]="isPrivate()"
|
||||
[class.bg-secondary]="!isPrivate()"
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideUnlock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Private Room</p>
|
||||
<p class="text-xs text-muted-foreground">Require approval to join</p>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">{{ isPrivate() ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label
|
||||
for="room-max-users"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Max Users (0 = unlimited)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="maxUsers"
|
||||
[readOnly]="!isAdmin()"
|
||||
min="0"
|
||||
id="room-max-users"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (isAdmin()) {
|
||||
<div class="rounded-lg border border-border bg-secondary/40 p-4 space-y-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Server Password</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (hasPassword() && passwordAction() !== 'remove') {
|
||||
Joined members stay whitelisted until they are kicked or banned.
|
||||
} @else {
|
||||
Add an optional password so new members need it to join.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (hasPassword() && passwordAction() !== 'remove') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="markPasswordForRemoval()"
|
||||
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Remove Password
|
||||
</button>
|
||||
} @else if (hasPassword() && passwordAction() === 'remove') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="keepCurrentPassword()"
|
||||
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Keep Password
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
@if (hasPassword() && passwordAction() !== 'remove') {
|
||||
Password protection is currently enabled.
|
||||
} @else if (hasPassword() && passwordAction() === 'remove') {
|
||||
Password protection will be removed when you save.
|
||||
} @else {
|
||||
Password protection is currently disabled.
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="room-password"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
{{ hasPassword() ? 'Set New Password' : 'Set Password' }}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="room-password"
|
||||
[ngModel]="roomPassword"
|
||||
(ngModelChange)="onPasswordInput($event)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[placeholder]="hasPassword() ? 'Leave blank to keep the current password' : 'Optional password required for new joins'"
|
||||
/>
|
||||
|
||||
@if (passwordAction() === 'update') {
|
||||
<p class="mt-2 text-xs text-muted-foreground">The new password will replace the current one when you save.</p>
|
||||
}
|
||||
|
||||
@if (passwordError()) {
|
||||
<p class="mt-2 text-xs text-destructive">{{ passwordError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Server Password</p>
|
||||
<p class="text-xs text-muted-foreground">Invite links bypass the password, but bans still apply.</p>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">{{ hasPassword() ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (isAdmin()) {
|
||||
<button
|
||||
(click)="saveServerSettings()"
|
||||
type="button"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
[class.bg-green-600]="saveSuccess() === 'server'"
|
||||
[class.hover:bg-green-600]="saveSuccess() === 'server'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
|
||||
</button>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
|
||||
<button
|
||||
(click)="confirmDeleteRoom()"
|
||||
type="button"
|
||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation (sub-modal) -->
|
||||
@if (showDeleteConfirm()) {
|
||||
<app-confirm-dialog
|
||||
title="Delete Room"
|
||||
confirmLabel="Delete Room"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="deleteRoom()"
|
||||
(cancelled)="showDeleteConfirm.set(false)"
|
||||
>
|
||||
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
lucideCheck,
|
||||
lucideTrash2,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../../../shared-kernel';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
lucideTrash2,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
})
|
||||
],
|
||||
templateUrl: './server-settings.component.html'
|
||||
})
|
||||
export class ServerSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private modal = inject(SettingsModalService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
isPrivate = signal(false);
|
||||
hasPassword = signal(false);
|
||||
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
||||
passwordError = signal<string | null>(null);
|
||||
roomPassword = '';
|
||||
maxUsers = 0;
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
saveSuccess = signal<string | null>(null);
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Reload form fields whenever the server input changes. */
|
||||
readonly serverData = this.server;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const room = this.server();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.hasPassword.set(typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password);
|
||||
this.passwordAction.set('keep');
|
||||
this.passwordError.set(null);
|
||||
this.roomPassword = '';
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
});
|
||||
}
|
||||
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((currentValue) => !currentValue);
|
||||
}
|
||||
|
||||
saveServerSettings(): void {
|
||||
const room = this.server();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
const normalizedPassword = this.roomPassword.trim();
|
||||
const settings: {
|
||||
description: string;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
name: string;
|
||||
password?: string;
|
||||
} = {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers
|
||||
};
|
||||
|
||||
if (this.passwordAction() === 'remove') {
|
||||
settings.password = '';
|
||||
settings.hasPassword = false;
|
||||
} else if (normalizedPassword) {
|
||||
settings.password = normalizedPassword;
|
||||
settings.hasPassword = true;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomSettings({
|
||||
roomId: room.id,
|
||||
settings
|
||||
})
|
||||
);
|
||||
|
||||
this.hasPassword.set(settings.hasPassword ?? this.hasPassword());
|
||||
this.passwordAction.set('keep');
|
||||
this.passwordError.set(null);
|
||||
this.roomPassword = '';
|
||||
this.showSaveSuccess('server');
|
||||
}
|
||||
|
||||
markPasswordForRemoval(): void {
|
||||
this.passwordAction.set('remove');
|
||||
this.passwordError.set(null);
|
||||
this.roomPassword = '';
|
||||
}
|
||||
|
||||
keepCurrentPassword(): void {
|
||||
this.passwordAction.set('keep');
|
||||
this.passwordError.set(null);
|
||||
this.roomPassword = '';
|
||||
}
|
||||
|
||||
onPasswordInput(value: string): void {
|
||||
this.roomPassword = value;
|
||||
this.passwordError.set(null);
|
||||
|
||||
if (value.trim().length > 0) {
|
||||
this.passwordAction.set('update');
|
||||
return;
|
||||
}
|
||||
|
||||
this.passwordAction.set('keep');
|
||||
}
|
||||
|
||||
confirmDeleteRoom(): void {
|
||||
this.showDeleteConfirm.set(true);
|
||||
}
|
||||
|
||||
deleteRoom(): void {
|
||||
const room = this.server();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
this.modal.navigate('network');
|
||||
}
|
||||
|
||||
private showSaveSuccess(key: string): void {
|
||||
this.saveSuccess.set(key);
|
||||
|
||||
if (this.saveTimeout)
|
||||
clearTimeout(this.saveTimeout);
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
@if (isOpen()) {
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="onBackdropClick()"
|
||||
(keydown.enter)="onBackdropClick()"
|
||||
(keydown.space)="onBackdropClick()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close settings"
|
||||
></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
|
||||
[class.scale-100]="animating()"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.scale-95]="!animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
(keydown.space)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Side Navigation -->
|
||||
<nav class="w-52 flex-shrink-0 bg-secondary/40 border-r border-border flex flex-col">
|
||||
<div class="p-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<!-- Global section -->
|
||||
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">General</p>
|
||||
@for (page of globalPages; track page.id) {
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
|
||||
[class.bg-primary/10]="activePage() === page.id"
|
||||
[class.text-primary]="activePage() === page.id"
|
||||
[class.font-medium]="activePage() === page.id"
|
||||
[class.text-foreground]="activePage() !== page.id"
|
||||
[class.hover:bg-secondary]="activePage() !== page.id"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="page.icon"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ page.label }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Server section -->
|
||||
@if (manageableRooms().length > 0) {
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
|
||||
|
||||
<!-- Server selector -->
|
||||
<div class="px-3 pb-2">
|
||||
<select
|
||||
class="w-full px-2 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
[value]="selectedServerId() || ''"
|
||||
(change)="onServerSelect($event)"
|
||||
>
|
||||
<option value="">Select a server…</option>
|
||||
@for (room of manageableRooms(); track room.id) {
|
||||
<option [value]="room.id">{{ room.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (selectedServerId() && canAccessSelectedServer()) {
|
||||
@for (page of serverPages; track page.id) {
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
|
||||
[class.bg-primary/10]="activePage() === page.id"
|
||||
[class.text-primary]="activePage() === page.id"
|
||||
[class.font-medium]="activePage() === page.id"
|
||||
[class.text-foreground]="activePage() !== page.id"
|
||||
[class.hover:bg-secondary]="activePage() !== page.id"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="page.icon"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ page.label }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openThirdPartyLicenses()"
|
||||
class="text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline underline-offset-4"
|
||||
>
|
||||
Third-party licenses
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h3 class="text-lg font-semibold text-foreground">
|
||||
@switch (activePage()) {
|
||||
@case ('general') {
|
||||
General
|
||||
}
|
||||
@case ('network') {
|
||||
Network
|
||||
}
|
||||
@case ('voice') {
|
||||
Voice & Audio
|
||||
}
|
||||
@case ('updates') {
|
||||
Updates
|
||||
}
|
||||
@case ('debugging') {
|
||||
Debugging
|
||||
}
|
||||
@case ('server') {
|
||||
Server Settings
|
||||
}
|
||||
@case ('members') {
|
||||
Members
|
||||
}
|
||||
@case ('bans') {
|
||||
Bans
|
||||
}
|
||||
@case ('permissions') {
|
||||
Permissions
|
||||
}
|
||||
}
|
||||
</h3>
|
||||
<button
|
||||
(click)="close()"
|
||||
type="button"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
@switch (activePage()) {
|
||||
@case ('general') {
|
||||
<app-general-settings />
|
||||
}
|
||||
@case ('network') {
|
||||
<app-network-settings />
|
||||
}
|
||||
@case ('voice') {
|
||||
<app-voice-settings />
|
||||
}
|
||||
@case ('updates') {
|
||||
<app-updates-settings />
|
||||
}
|
||||
@case ('debugging') {
|
||||
<app-debugging-settings />
|
||||
}
|
||||
@case ('server') {
|
||||
<app-server-settings
|
||||
[server]="selectedServer()"
|
||||
[isAdmin]="isSelectedServerOwner()"
|
||||
/>
|
||||
}
|
||||
@case ('members') {
|
||||
<app-members-settings
|
||||
[server]="selectedServer()"
|
||||
[isAdmin]="canManageSelectedMembers()"
|
||||
[accessRole]="selectedServerRole()"
|
||||
/>
|
||||
}
|
||||
@case ('bans') {
|
||||
<app-bans-settings
|
||||
[server]="selectedServer()"
|
||||
[isAdmin]="canManageSelectedBans()"
|
||||
/>
|
||||
}
|
||||
@case ('permissions') {
|
||||
<app-permissions-settings
|
||||
#permissionsComp
|
||||
[server]="selectedServer()"
|
||||
[isAdmin]="isSelectedServerOwner()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showThirdPartyLicenses()) {
|
||||
<div
|
||||
class="absolute inset-0 z-10 bg-background/70 backdrop-blur-sm"
|
||||
(click)="closeThirdPartyLicenses()"
|
||||
(keydown.enter)="closeThirdPartyLicenses()"
|
||||
(keydown.space)="closeThirdPartyLicenses()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close third-party licenses"
|
||||
></div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-[11] flex items-center justify-center p-4 sm:p-6">
|
||||
<div class="pointer-events-auto w-full max-w-2xl max-h-full overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-foreground">Third-party licenses</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">License notices for bundled third-party libraries used by the app.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeThirdPartyLicenses()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close third-party licenses"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[min(70vh,42rem)] overflow-y-auto px-5 py-4 space-y-4">
|
||||
@for (license of thirdPartyLicenses; track license.id) {
|
||||
<section class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">{{ license.name }}</h5>
|
||||
<p class="text-xs text-muted-foreground">{{ license.licenseName }}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
[href]="license.sourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs font-medium text-primary hover:underline underline-offset-4"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
class="mt-4 whitespace-pre-wrap break-words rounded-md bg-background/80 px-3 py-2 text-[11px] leading-5 text-muted-foreground"
|
||||
>{{ license.text }}</pre
|
||||
>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
HostListener,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideX,
|
||||
lucideBug,
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
lucideShield
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { Room, UserRole } from '../../../shared-kernel';
|
||||
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
||||
|
||||
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
||||
import { ServerSettingsComponent } from './server-settings/server-settings.component';
|
||||
import { MembersSettingsComponent } from './members-settings/members-settings.component';
|
||||
import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
||||
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
GeneralSettingsComponent,
|
||||
NetworkSettingsComponent,
|
||||
VoiceSettingsComponent,
|
||||
UpdatesSettingsComponent,
|
||||
DebuggingSettingsComponent,
|
||||
ServerSettingsComponent,
|
||||
MembersSettingsComponent,
|
||||
BansSettingsComponent,
|
||||
PermissionsSettingsComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideX,
|
||||
lucideBug,
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
lucideShield
|
||||
})
|
||||
],
|
||||
templateUrl: './settings-modal.component.html'
|
||||
})
|
||||
export class SettingsModalComponent {
|
||||
readonly modal = inject(SettingsModalService);
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
isOpen = this.modal.isOpen;
|
||||
activePage = this.modal.activePage;
|
||||
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'general',
|
||||
label: 'General',
|
||||
icon: 'lucideSettings' },
|
||||
{ id: 'network',
|
||||
label: 'Network',
|
||||
icon: 'lucideGlobe' },
|
||||
{ id: 'voice',
|
||||
label: 'Voice & Audio',
|
||||
icon: 'lucideAudioLines' },
|
||||
{ id: 'updates',
|
||||
label: 'Updates',
|
||||
icon: 'lucideDownload' },
|
||||
{ id: 'debugging',
|
||||
label: 'Debugging',
|
||||
icon: 'lucideBug' }
|
||||
];
|
||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'server',
|
||||
label: 'Server',
|
||||
icon: 'lucideSettings' },
|
||||
{ id: 'members',
|
||||
label: 'Members',
|
||||
icon: 'lucideUsers' },
|
||||
{ id: 'bans',
|
||||
label: 'Bans',
|
||||
icon: 'lucideBan' },
|
||||
{ id: 'permissions',
|
||||
label: 'Permissions',
|
||||
icon: 'lucideShield' }
|
||||
];
|
||||
|
||||
manageableRooms = computed<Room[]>(() => {
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!user)
|
||||
return [];
|
||||
|
||||
return this.savedRooms().filter((room) => {
|
||||
const role = this.getUserRoleForRoom(room, user.id, user.oderId, this.currentRoom()?.id === room.id ? user.role : null);
|
||||
|
||||
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||
});
|
||||
});
|
||||
|
||||
selectedServerId = signal<string | null>(null);
|
||||
selectedServer = computed<Room | null>(() => {
|
||||
const id = this.selectedServerId();
|
||||
|
||||
if (!id)
|
||||
return null;
|
||||
|
||||
return this.manageableRooms().find((room) => room.id === id) ?? null;
|
||||
});
|
||||
|
||||
showServerTabs = computed(() => {
|
||||
return this.manageableRooms().length > 0 && !!this.selectedServerId();
|
||||
});
|
||||
|
||||
selectedServerRole = computed<UserRole | null>(() => {
|
||||
const server = this.selectedServer();
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!server || !user)
|
||||
return null;
|
||||
|
||||
return this.getUserRoleForRoom(
|
||||
server,
|
||||
user.id,
|
||||
user.oderId,
|
||||
this.currentRoom()?.id === server.id ? user.role : null
|
||||
);
|
||||
});
|
||||
|
||||
canAccessSelectedServer = computed(() => {
|
||||
const role = this.selectedServerRole();
|
||||
|
||||
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||
});
|
||||
|
||||
canManageSelectedMembers = computed(() => {
|
||||
const role = this.selectedServerRole();
|
||||
|
||||
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||
});
|
||||
|
||||
canManageSelectedBans = computed(() => {
|
||||
const role = this.selectedServerRole();
|
||||
|
||||
return role === 'host' || role === 'admin';
|
||||
});
|
||||
|
||||
isSelectedServerOwner = computed(() => {
|
||||
return this.selectedServerRole() === 'host';
|
||||
});
|
||||
|
||||
animating = signal(false);
|
||||
showThirdPartyLicenses = signal(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.isOpen()) {
|
||||
this.lastRequestedServerId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const rooms = this.manageableRooms();
|
||||
const targetId = this.modal.targetServerId();
|
||||
const currentRoomId = this.currentRoom()?.id ?? null;
|
||||
const selectedId = this.selectedServerId();
|
||||
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
|
||||
|
||||
if (!hasSelected) {
|
||||
const fallbackId = [targetId, currentRoomId].find((candidateId) =>
|
||||
!!candidateId && rooms.some((room) => room.id === candidateId)
|
||||
) ?? rooms[0]?.id ?? null;
|
||||
|
||||
this.selectedServerId.set(fallbackId);
|
||||
}
|
||||
|
||||
this.animating.set(true);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const server = this.selectedServer();
|
||||
|
||||
if (server) {
|
||||
const permsComp = this.permissionsComponent();
|
||||
|
||||
if (permsComp) {
|
||||
permsComp.loadPermissions(server);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.isOpen())
|
||||
return;
|
||||
|
||||
const serverId = this.selectedServerId();
|
||||
|
||||
if (!serverId || this.lastRequestedServerId === serverId)
|
||||
return;
|
||||
|
||||
this.lastRequestedServerId = serverId;
|
||||
|
||||
for (const peerId of this.webrtc.getConnectedPeers()) {
|
||||
try {
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
type: 'server-state-request',
|
||||
roomId: serverId
|
||||
});
|
||||
} catch {
|
||||
/* peer may have disconnected */
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getUserRoleForRoom(
|
||||
room: Room,
|
||||
userId: string,
|
||||
userOderId: string,
|
||||
currentRole: UserRole | null
|
||||
): UserRole | null {
|
||||
if (room.hostId === userId || room.hostId === userOderId)
|
||||
return 'host';
|
||||
|
||||
if (currentRole)
|
||||
return currentRole;
|
||||
|
||||
return findRoomMember(room.members ?? [], userId)?.role
|
||||
|| findRoomMember(room.members ?? [], userOderId)?.role
|
||||
|| null;
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.showThirdPartyLicenses()) {
|
||||
this.closeThirdPartyLicenses();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.showThirdPartyLicenses.set(false);
|
||||
this.animating.set(false);
|
||||
setTimeout(() => this.modal.close(), 200);
|
||||
}
|
||||
|
||||
openThirdPartyLicenses(): void {
|
||||
this.showThirdPartyLicenses.set(true);
|
||||
}
|
||||
|
||||
closeThirdPartyLicenses(): void {
|
||||
this.showThirdPartyLicenses.set(false);
|
||||
}
|
||||
|
||||
navigate(page: SettingsPage): void {
|
||||
this.modal.navigate(page);
|
||||
}
|
||||
|
||||
onBackdropClick(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
onServerSelect(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedServerId.set(select.value || null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface ThirdPartyLicense {
|
||||
id: string;
|
||||
name: string;
|
||||
licenseName: string;
|
||||
sourceUrl: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
|
||||
{
|
||||
id: 'wavesurfer-js',
|
||||
name: 'wavesurfer.js',
|
||||
licenseName: 'BSD 3-Clause License',
|
||||
sourceUrl: 'https://github.com/katspaugh/wavesurfer.js/blob/main/LICENSE',
|
||||
text: [
|
||||
'BSD 3-Clause License',
|
||||
'',
|
||||
'Copyright (c) 2012-2023, katspaugh and contributors',
|
||||
'All rights reserved.',
|
||||
'',
|
||||
'Redistribution and use in source and binary forms, with or without modification, are permitted provided',
|
||||
'that the following conditions are met:',
|
||||
'',
|
||||
'* Redistributions of source code must retain the above copyright notice, this list of conditions and',
|
||||
' the following disclaimer.',
|
||||
'',
|
||||
'* Redistributions in binary form must reproduce the above copyright notice, this list of conditions',
|
||||
' and the following disclaimer in the documentation and/or other materials provided with the',
|
||||
' distribution.',
|
||||
'',
|
||||
'* Neither the name of the copyright holder nor the names of its contributors may be used to endorse',
|
||||
' or promote products derived from this software without specific prior written permission.',
|
||||
'',
|
||||
'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR',
|
||||
'IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND',
|
||||
'FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR',
|
||||
'CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL',
|
||||
'DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,',
|
||||
'DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER',
|
||||
'IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT',
|
||||
'OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'
|
||||
].join('\n')
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,185 @@
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-xl border border-border bg-card/60 p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-foreground">Desktop app updates</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
{{ statusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!isElectron) {
|
||||
<section class="rounded-xl border border-border bg-secondary/30 p-5">
|
||||
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app.</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
|
||||
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().currentVersion }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Latest in manifest</p>
|
||||
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || 'Unknown' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Target version</p>
|
||||
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || 'Automatic' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
|
||||
<p class="mt-2 text-sm font-medium text-foreground">
|
||||
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Update policy</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Mode</span>
|
||||
<select
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
[value]="state().autoUpdateMode"
|
||||
(change)="onModeChange($event)"
|
||||
>
|
||||
<option value="auto">Newest release</option>
|
||||
<option value="version">Specific version</option>
|
||||
<option value="off">Turn off auto updates</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Pinned version</span>
|
||||
<select
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[disabled]="state().autoUpdateMode !== 'version' || state().availableVersions.length === 0"
|
||||
[value]="state().preferredVersion || ''"
|
||||
(change)="onVersionChange($event)"
|
||||
>
|
||||
<option value="">Choose a release…</option>
|
||||
@for (version of state().availableVersions; track version) {
|
||||
<option [value]="version">{{ version }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<p class="text-sm font-medium text-foreground">Status</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ state().statusMessage || 'Waiting for release information from the active server.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="refreshReleaseInfo()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Refresh release info
|
||||
</button>
|
||||
|
||||
@if (state().restartRequired) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartNow()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Restart to update
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Manifest URL priority</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Add one manifest URL per line. The app tries them from top to bottom and falls back to the next URL when a manifest cannot be loaded or is
|
||||
invalid.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">
|
||||
{{ isUsingConnectedServerDefaults() ? 'Using connected server defaults' : 'Using saved manifest URLs' }}
|
||||
</p>
|
||||
<p class="mt-1">When this list is empty, the app automatically uses manifest URLs reported by your configured servers.</p>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Manifest URLs</span>
|
||||
<textarea
|
||||
rows="6"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
[value]="manifestUrlsText()"
|
||||
(input)="onManifestUrlsInput($event)"
|
||||
placeholder="https://example.com/releases/latest/download/release-manifest.json"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (!state().defaultManifestUrls.length && isUsingConnectedServerDefaults()) {
|
||||
<p class="text-sm text-muted-foreground">None of your configured servers currently report a manifest URL.</p>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveManifestUrls()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Save manifest URLs
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="useConnectedServerDefaults()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Use connected server defaults
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (state().serverBlocked) {
|
||||
<section class="rounded-xl border border-red-500/30 bg-red-500/10 p-5">
|
||||
<h5 class="text-sm font-semibold text-foreground">Server update required</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ state().serverBlockMessage }}</p>
|
||||
<div class="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
|
||||
<p class="mt-1">{{ state().serverVersion || 'Not reported' }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
|
||||
<p class="mt-1">{{ state().minimumServerVersion || 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Resolved manifest URL</p>
|
||||
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || 'No working manifest URL has been resolved yet.' }}</p>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DesktopAppUpdateService } from '../../../../core/services/desktop-app-update.service';
|
||||
|
||||
type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
type DesktopUpdateStatus =
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'restart-required'
|
||||
| 'unsupported'
|
||||
| 'no-manifest'
|
||||
| 'target-unavailable'
|
||||
| 'target-older-than-installed'
|
||||
| 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-updates-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './updates-settings.component.html'
|
||||
})
|
||||
export class UpdatesSettingsComponent {
|
||||
readonly updates = inject(DesktopAppUpdateService);
|
||||
readonly isElectron = this.updates.isElectron;
|
||||
readonly state = this.updates.state;
|
||||
readonly hasPendingManifestUrlChanges = signal(false);
|
||||
readonly manifestUrlsText = signal('');
|
||||
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
|
||||
readonly isUsingConnectedServerDefaults = computed(() => {
|
||||
return this.state().configuredManifestUrls.length === 0;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.hasPendingManifestUrlChanges()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.manifestUrlsText.set(this.stringifyManifestUrls(
|
||||
this.isUsingConnectedServerDefaults()
|
||||
? this.state().defaultManifestUrls
|
||||
: this.state().configuredManifestUrls
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async onModeChange(event: Event): Promise<void> {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const mode = select.value as AutoUpdateMode;
|
||||
const preferredVersion = mode === 'version'
|
||||
? this.state().preferredVersion ?? this.state().availableVersions[0] ?? null
|
||||
: this.state().preferredVersion;
|
||||
|
||||
await this.updates.saveUpdatePreferences(mode, preferredVersion);
|
||||
}
|
||||
|
||||
async onVersionChange(event: Event): Promise<void> {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
await this.updates.saveUpdatePreferences('version', select.value || null);
|
||||
}
|
||||
|
||||
async refreshReleaseInfo(): Promise<void> {
|
||||
await this.updates.refreshServerContext();
|
||||
await this.updates.checkForUpdates();
|
||||
}
|
||||
|
||||
onManifestUrlsInput(event: Event): void {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
|
||||
this.hasPendingManifestUrlChanges.set(true);
|
||||
this.manifestUrlsText.set(textarea.value);
|
||||
}
|
||||
|
||||
async saveManifestUrls(): Promise<void> {
|
||||
await this.updates.saveManifestUrls(
|
||||
this.parseManifestUrls(this.manifestUrlsText())
|
||||
);
|
||||
|
||||
this.hasPendingManifestUrlChanges.set(false);
|
||||
}
|
||||
|
||||
async useConnectedServerDefaults(): Promise<void> {
|
||||
await this.updates.saveManifestUrls([]);
|
||||
this.hasPendingManifestUrlChanges.set(false);
|
||||
}
|
||||
|
||||
async restartNow(): Promise<void> {
|
||||
await this.updates.restartToApplyUpdate();
|
||||
}
|
||||
|
||||
private parseManifestUrls(rawValue: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
rawValue
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
private stringifyManifestUrls(manifestUrls: string[]): string {
|
||||
return manifestUrls.join('\n');
|
||||
}
|
||||
|
||||
private getStatusLabel(status: DesktopUpdateStatus): string {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return 'Checking';
|
||||
case 'downloading':
|
||||
return 'Downloading';
|
||||
case 'restart-required':
|
||||
return 'Restart required';
|
||||
case 'up-to-date':
|
||||
return 'Up to date';
|
||||
case 'disabled':
|
||||
return 'Disabled';
|
||||
case 'unsupported':
|
||||
return 'Unsupported';
|
||||
case 'no-manifest':
|
||||
return 'Manifest missing';
|
||||
case 'target-unavailable':
|
||||
return 'Version unavailable';
|
||||
case 'target-older-than-installed':
|
||||
return 'Pinned below current';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
default:
|
||||
return 'Idle';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<div class="space-y-6 max-w-xl">
|
||||
<!-- Devices -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
for="input-device-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Microphone</label
|
||||
>
|
||||
<select
|
||||
(change)="onInputDeviceChange($event)"
|
||||
id="input-device-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@for (device of inputDevices(); track device.deviceId) {
|
||||
<option
|
||||
[value]="device.deviceId"
|
||||
[selected]="device.deviceId === selectedInputDevice()"
|
||||
>
|
||||
{{ device.label || 'Microphone ' + $index }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="output-device-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Speaker</label
|
||||
>
|
||||
<select
|
||||
(change)="onOutputDeviceChange($event)"
|
||||
id="output-device-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@for (device of outputDevices(); track device.deviceId) {
|
||||
<option
|
||||
[value]="device.deviceId"
|
||||
[selected]="device.deviceId === selectedOutputDevice()"
|
||||
>
|
||||
{{ device.label || 'Speaker ' + $index }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Volume -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
for="input-volume-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Input Volume: {{ inputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[value]="inputVolume()"
|
||||
(input)="onInputVolumeChange($event)"
|
||||
min="0"
|
||||
max="100"
|
||||
id="input-volume-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="output-volume-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Output Volume: {{ outputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[value]="outputVolume()"
|
||||
(input)="onOutputVolumeChange($event)"
|
||||
min="0"
|
||||
max="200"
|
||||
id="output-volume-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="notification-volume-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
[value]="audioService.notificationVolume()"
|
||||
(input)="onNotificationVolumeChange($event)"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
id="notification-volume-slider"
|
||||
class="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<button
|
||||
(click)="previewNotificationSound()"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors flex-shrink-0"
|
||||
title="Preview notification sound"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave & notification sounds</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Quality & Processing -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon
|
||||
name="lucideAudioLines"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
for="latency-profile-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Latency Profile</label
|
||||
>
|
||||
<select
|
||||
(change)="onLatencyProfileChange($event)"
|
||||
id="latency-profile-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option
|
||||
value="low"
|
||||
[selected]="latencyProfile() === 'low'"
|
||||
>
|
||||
Low (fast)
|
||||
</option>
|
||||
<option
|
||||
value="balanced"
|
||||
[selected]="latencyProfile() === 'balanced'"
|
||||
>
|
||||
Balanced
|
||||
</option>
|
||||
<option
|
||||
value="high"
|
||||
[selected]="latencyProfile() === 'high'"
|
||||
>
|
||||
High (quality)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="audio-bitrate-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[value]="audioBitrate()"
|
||||
(input)="onAudioBitrateChange($event)"
|
||||
min="32"
|
||||
max="256"
|
||||
step="8"
|
||||
id="audio-bitrate-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="screen-share-quality-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Screen share quality</label
|
||||
>
|
||||
<select
|
||||
(change)="onScreenShareQualityChange($event)"
|
||||
id="screen-share-quality-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@for (option of screenShareQualityOptions; track option.id) {
|
||||
<option
|
||||
[value]="option.id"
|
||||
[selected]="screenShareQuality() === option.id"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<p class="text-[10px] text-muted-foreground/60 mt-1">
|
||||
{{ selectedScreenShareQualityDescription() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Ask before screen sharing</p>
|
||||
<p class="text-xs text-muted-foreground">Let the user confirm quality before each new screen share</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="askScreenShareQuality()"
|
||||
(change)="onAskScreenShareQualityChange($event)"
|
||||
id="ask-screen-share-quality-toggle"
|
||||
aria-label="Toggle screen share quality prompt"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Noise reduction</p>
|
||||
<p class="text-xs text-muted-foreground">Suppress background noise using RNNoise</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="noiseReduction()"
|
||||
(change)="onNoiseReductionChange()"
|
||||
id="noise-reduction-toggle"
|
||||
aria-label="Toggle noise reduction"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Screen share system audio</p>
|
||||
<p class="text-xs text-muted-foreground">Share other computer audio while filtering MeToYou audio when supported</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="includeSystemAudio()"
|
||||
(change)="onIncludeSystemAudioChange($event)"
|
||||
id="system-audio-toggle"
|
||||
aria-label="Toggle system audio in screen share"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted-foreground/60 -mt-1">
|
||||
Your microphone stays on the normal voice channel. The shared screen audio should only contain desktop sound.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (isElectron) {
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon
|
||||
name="lucideCpu"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Desktop Performance</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Hardware acceleration</p>
|
||||
<p class="text-xs text-muted-foreground">Use GPU acceleration for rendering and WebRTC when available</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="hardwareAcceleration()"
|
||||
(change)="onHardwareAccelerationChange($event)"
|
||||
id="hardware-acceleration-toggle"
|
||||
aria-label="Toggle hardware acceleration"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (hardwareAccelerationRestartRequired()) {
|
||||
<div class="rounded-lg border border-primary/30 bg-primary/10 p-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Restart required</p>
|
||||
<p class="text-xs text-muted-foreground">Restart MeToYou to apply the new hardware acceleration setting.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartDesktopApp()"
|
||||
class="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart app
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,281 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideHeadphones,
|
||||
lucideAudioLines,
|
||||
lucideMonitor,
|
||||
lucideCpu
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideHeadphones,
|
||||
lucideAudioLines,
|
||||
lucideMonitor,
|
||||
lucideCpu
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-settings.component.html'
|
||||
})
|
||||
export class VoiceSettingsComponent {
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private platform = inject(PlatformService);
|
||||
readonly audioService = inject(NotificationAudioService);
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
readonly screenShareQualityOptions = SCREEN_SHARE_QUALITY_OPTIONS;
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
selectedInputDevice = signal<string>('');
|
||||
selectedOutputDevice = signal<string>('');
|
||||
inputVolume = signal(100);
|
||||
outputVolume = signal(100);
|
||||
audioBitrate = signal(96);
|
||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||
includeSystemAudio = signal(false);
|
||||
noiseReduction = signal(true);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
hardwareAcceleration = signal(true);
|
||||
hardwareAccelerationRestartRequired = signal(false);
|
||||
readonly selectedScreenShareQualityDescription = computed(
|
||||
() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? ''
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.loadVoiceSettings();
|
||||
this.loadAudioDevices();
|
||||
|
||||
if (this.isElectron) {
|
||||
void this.loadDesktopSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices)
|
||||
return;
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
loadVoiceSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
this.selectedOutputDevice.set(settings.outputDevice);
|
||||
this.inputVolume.set(settings.inputVolume);
|
||||
this.outputVolume.set(settings.outputVolume);
|
||||
this.audioBitrate.set(settings.audioBitrate);
|
||||
this.latencyProfile.set(settings.latencyProfile);
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.noiseReduction.set(settings.noiseReduction);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
|
||||
if (this.noiseReduction() !== this.voiceConnection.isNoiseReductionEnabled()) {
|
||||
this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
|
||||
}
|
||||
|
||||
// Apply persisted volume levels to the live audio pipelines
|
||||
this.voiceConnection.setInputVolume(this.inputVolume() / 100);
|
||||
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
}
|
||||
|
||||
saveVoiceSettings(): void {
|
||||
saveVoiceSettingsToStorage({
|
||||
inputDevice: this.selectedInputDevice(),
|
||||
outputDevice: this.selectedOutputDevice(),
|
||||
inputVolume: this.inputVolume(),
|
||||
outputVolume: this.outputVolume(),
|
||||
audioBitrate: this.audioBitrate(),
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
noiseReduction: this.noiseReduction(),
|
||||
screenShareQuality: this.screenShareQuality(),
|
||||
askScreenShareQuality: this.askScreenShareQuality()
|
||||
});
|
||||
}
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.voiceConnection.setInputVolume(this.inputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onLatencyProfileChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const profile = select.value as 'low' | 'balanced' | 'high';
|
||||
|
||||
this.latencyProfile.set(profile);
|
||||
this.voiceConnection.setLatencyProfile(profile);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.audioBitrate.set(parseInt(input.value, 10));
|
||||
this.voiceConnection.setAudioBitrate(this.audioBitrate());
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onScreenShareQualityChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.screenShareQuality.set(select.value as ScreenShareQuality);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onAskScreenShareQualityChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.askScreenShareQuality.set(!!input.checked);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
async onNoiseReductionChange(): Promise<void> {
|
||||
this.noiseReduction.update((currentValue) => !currentValue);
|
||||
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onNotificationVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.audioService.setNotificationVolume(parseFloat(input.value));
|
||||
}
|
||||
|
||||
previewNotificationSound(): void {
|
||||
this.audioService.play(AppSound.Notification);
|
||||
}
|
||||
|
||||
async onHardwareAccelerationChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const enabled = !!input.checked;
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api) {
|
||||
this.hardwareAcceleration.set(enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await api.setDesktopSettings({ hardwareAcceleration: enabled });
|
||||
|
||||
this.applyDesktopSettings(snapshot);
|
||||
} catch {
|
||||
input.checked = this.hardwareAcceleration();
|
||||
}
|
||||
}
|
||||
|
||||
async restartDesktopApp(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (api) {
|
||||
await api.relaunchApp();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await api.getDesktopSettings();
|
||||
|
||||
this.applyDesktopSettings(snapshot);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
|
||||
this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
|
||||
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
|
||||
}
|
||||
}
|
||||
292
toju-app/src/app/features/settings/settings.component.html
Normal file
292
toju-app/src/app/features/settings/settings.component.html
Normal file
@@ -0,0 +1,292 @@
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
(click)="goBack()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Go back"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideArrowLeft"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-6 h-6 text-primary"
|
||||
/>
|
||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Server Endpoints Section -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (hasMissingDefaultServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultServers()"
|
||||
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Restore Defaults
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="testAllServers()"
|
||||
[disabled]="isTesting()"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="w-4 h-4"
|
||||
[class.animate-spin]="isTesting()"
|
||||
/>
|
||||
Test All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
<div class="space-y-3 mb-4">
|
||||
@for (server of servers(); track server.id) {
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
|
||||
[class.border-primary]="server.isActive"
|
||||
[class.bg-primary/5]="server.isActive"
|
||||
[class.border-border]="!server.isActive"
|
||||
[class.bg-secondary/30]="!server.isActive"
|
||||
>
|
||||
<!-- Status Indicator -->
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
[class.bg-green-500]="server.status === 'online'"
|
||||
[class.bg-red-500]="server.status === 'offline'"
|
||||
[class.bg-yellow-500]="server.status === 'checking'"
|
||||
[class.bg-muted]="server.status === 'unknown'"
|
||||
[class.bg-orange-500]="server.status === 'incompatible'"
|
||||
[title]="server.status"
|
||||
></div>
|
||||
|
||||
<!-- Server Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
|
||||
@if (server.isActive) {
|
||||
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
||||
}
|
||||
@if (server.status === 'incompatible') {
|
||||
<p class="text-xs text-destructive">Update the client in order to connect to other users</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if (!server.isActive && server.status !== 'incompatible') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="setActiveServer(server.id)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Activate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4 text-muted-foreground hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (server.isActive && hasMultipleActiveServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="deactivateServer(server.id)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Deactivate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (hasMultipleServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeServer(server.id)"
|
||||
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Remove server"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4 text-muted-foreground hover:text-destructive"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add New Server -->
|
||||
<div class="border-t border-border pt-4">
|
||||
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="Server name (e.g., My Server)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="newServerUrl"
|
||||
placeholder="Server URL (e.g., http://localhost:3001)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="addServer()"
|
||||
[disabled]="!newServerName || !newServerUrl"
|
||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
<p class="text-sm text-destructive mt-2">{{ addError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon
|
||||
name="lucideServer"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Auto-reconnect</p>
|
||||
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="autoReconnect"
|
||||
(change)="saveConnectionSettings()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Search all servers</p>
|
||||
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="searchAllServers"
|
||||
(change)="saveConnectionSettings()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon
|
||||
name="lucideAudioLines"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Notification Volume -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Notification volume</p>
|
||||
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
|
||||
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
[ngModel]="audioService.notificationVolume()"
|
||||
(ngModelChange)="onNotificationVolumeChange($event)"
|
||||
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="previewNotificationSound()"
|
||||
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
title="Preview sound"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Noise reduction</p>
|
||||
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="noiseReduction"
|
||||
(change)="saveVoiceSettings()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
222
toju-app/src/app/features/settings/settings.component.ts
Normal file
222
toju-app/src/app/features/settings/settings.component.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideServer,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideSettings,
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { VoiceConnectionFacade } from '../../domains/voice-connection';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideServer,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideSettings,
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines
|
||||
})
|
||||
],
|
||||
templateUrl: './settings.component.html'
|
||||
})
|
||||
/**
|
||||
* Settings page for managing signaling servers and connection preferences.
|
||||
*/
|
||||
export class SettingsComponent implements OnInit {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private router = inject(Router);
|
||||
audioService = inject(NotificationAudioService);
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||
isTesting = signal(false);
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newServerName = '';
|
||||
newServerUrl = '';
|
||||
autoReconnect = true;
|
||||
searchAllServers = true;
|
||||
noiseReduction = true;
|
||||
|
||||
/** Load persisted connection settings on component init. */
|
||||
ngOnInit(): void {
|
||||
this.loadConnectionSettings();
|
||||
this.loadVoiceSettings();
|
||||
}
|
||||
|
||||
/** Add a new signaling server after URL validation and duplicate checking. */
|
||||
addServer(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(this.newServerUrl);
|
||||
} catch {
|
||||
this.addError.set('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (this.servers().some((server) => server.url === this.newServerUrl)) {
|
||||
this.addError.set('This server URL already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverDirectory.addServer({
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, '') // Remove trailing slash
|
||||
});
|
||||
|
||||
// Clear form
|
||||
this.newServerName = '';
|
||||
this.newServerUrl = '';
|
||||
|
||||
// Test the new server
|
||||
const servers = this.servers();
|
||||
const newServer = servers[servers.length - 1];
|
||||
|
||||
if (newServer) {
|
||||
this.serverDirectory.testServer(newServer.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a signaling server by its ID. */
|
||||
removeServer(id: string): void {
|
||||
this.serverDirectory.removeServer(id);
|
||||
}
|
||||
|
||||
/** Set the active signaling server used for connections. */
|
||||
setActiveServer(id: string): void {
|
||||
this.serverDirectory.setActiveServer(id);
|
||||
}
|
||||
|
||||
deactivateServer(id: string): void {
|
||||
this.serverDirectory.deactivateServer(id);
|
||||
}
|
||||
|
||||
restoreDefaultServers(): void {
|
||||
this.serverDirectory.restoreDefaultServers();
|
||||
}
|
||||
|
||||
/** Test connectivity to all configured servers. */
|
||||
async testAllServers(): Promise<void> {
|
||||
this.isTesting.set(true);
|
||||
await this.serverDirectory.testAllServers();
|
||||
this.isTesting.set(false);
|
||||
}
|
||||
|
||||
/** Load connection settings (auto-reconnect, search scope) from localStorage. */
|
||||
loadConnectionSettings(): void {
|
||||
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings);
|
||||
|
||||
this.autoReconnect = parsed.autoReconnect ?? true;
|
||||
this.searchAllServers = parsed.searchAllServers ?? true;
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist current connection settings to localStorage. */
|
||||
saveConnectionSettings(): void {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
/** Navigate back to the main page. */
|
||||
goBack(): void {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
/** Load voice settings (noise reduction) from localStorage. */
|
||||
loadVoiceSettings(): void {
|
||||
const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings);
|
||||
|
||||
this.noiseReduction = parsed.noiseReduction ?? false;
|
||||
}
|
||||
|
||||
// Sync the live WebRTC state with the persisted preference
|
||||
if (this.noiseReduction !== this.voiceConnection.isNoiseReductionEnabled()) {
|
||||
this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when the notification volume slider changes. */
|
||||
onNotificationVolumeChange(value: number): void {
|
||||
this.audioService.setNotificationVolume(value);
|
||||
}
|
||||
|
||||
/** Play a preview of the notification sound at the current volume. */
|
||||
previewNotificationSound(): void {
|
||||
this.audioService.play(AppSound.Notification);
|
||||
}
|
||||
|
||||
/** Persist noise reduction preference (merged into existing voice settings) and apply immediately. */
|
||||
async saveVoiceSettings(): Promise<void> {
|
||||
// Merge into existing voice settings so we don't overwrite device/volume prefs
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
|
||||
if (raw)
|
||||
existing = JSON.parse(raw);
|
||||
} catch {}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_VOICE_SETTINGS,
|
||||
JSON.stringify({ ...existing,
|
||||
noiseReduction: this.noiseReduction })
|
||||
);
|
||||
|
||||
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
}
|
||||
171
toju-app/src/app/features/shell/title-bar.component.html
Normal file
171
toju-app/src/app/features/shell/title-bar.component.html
Normal file
@@ -0,0 +1,171 @@
|
||||
<div
|
||||
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
|
||||
style="-webkit-app-region: drag"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 relative"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
@if (inRoom()) {
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||
|
||||
@if (showRoomCompatibilityNotice()) {
|
||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||
{{ signalServerCompatibilityError() }}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (showRoomReconnectNotice()) {
|
||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
Reconnecting to signal server…
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (roomDescription()) {
|
||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||
{{ roomDescription() }}
|
||||
</span>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||
[class.hidden]="!isReconnecting()"
|
||||
>Reconnecting…</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||
[class.hidden]="isAuthed()"
|
||||
(click)="goLogin()"
|
||||
title="Login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMenu()"
|
||||
class="ml-2 p-2 hover:bg-secondary rounded"
|
||||
title="Menu"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMenu"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
@if (showMenu()) {
|
||||
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card shadow-lg">
|
||||
@if (inRoom()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="createInviteLink()"
|
||||
[disabled]="creatingInvite()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@if (creatingInvite()) {
|
||||
Creating Invite Link…
|
||||
} @else {
|
||||
Create Invite Link
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
[class.hidden]="!inviteStatus()"
|
||||
>
|
||||
{{ inviteStatus() }}
|
||||
</div>
|
||||
<div class="border-t border-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="logout()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (isElectron()) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||
title="Minimize"
|
||||
(click)="minimize()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMinus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||
title="Maximize"
|
||||
(click)="maximize()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSquare"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
|
||||
title="Close"
|
||||
(click)="close()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-destructive"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
(click)="closeMenu()"
|
||||
(keydown.enter)="closeMenu()"
|
||||
(keydown.space)="closeMenu()"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close menu overlay"
|
||||
style="-webkit-app-region: no-drag"
|
||||
></div>
|
||||
}
|
||||
|
||||
@if (showLeaveConfirm() && currentRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="currentRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeave($event)"
|
||||
(cancelled)="cancelLeave()"
|
||||
/>
|
||||
}
|
||||
264
toju-app/src/app/features/shell/title-bar.component.ts
Normal file
264
toju-app/src/app/features/shell/title-bar.component.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMinus,
|
||||
lucideSquare,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucideRefreshCw
|
||||
} from '@ng-icons/lucide';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectIsSignalServerReconnecting,
|
||||
selectSignalServerCompatibilityError
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../shared';
|
||||
import { Room } from '../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
LeaveServerDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMinus,
|
||||
lucideSquare,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucideRefreshCw })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
/**
|
||||
* Electron-style title bar with window controls, navigation, and server menu.
|
||||
*/
|
||||
export class TitleBarComponent {
|
||||
private store = inject(Store);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private router = inject(Router);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
|
||||
isElectron = computed(() => this.platform.isElectron);
|
||||
showMenuState = computed(() => false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
username = computed(() => this.currentUser()?.displayName || 'Guest');
|
||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
|
||||
isConnected = computed(() => this.webrtc.isConnected());
|
||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||
isAuthed = computed(() => !!this.currentUser());
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||
inRoom = computed(() => !!this.currentRoom());
|
||||
roomName = computed(() => this.currentRoom()?.name || '');
|
||||
roomDescription = computed(() => this.currentRoom()?.description || '');
|
||||
showRoomCompatibilityNotice = computed(() =>
|
||||
this.inRoom() && !!this.signalServerCompatibilityError()
|
||||
);
|
||||
showRoomReconnectNotice = computed(() =>
|
||||
this.inRoom()
|
||||
&& !this.signalServerCompatibilityError()
|
||||
&& (
|
||||
this.isSignalServerReconnecting()
|
||||
|| this.webrtc.shouldShowConnectionError()
|
||||
|| this.isReconnecting()
|
||||
)
|
||||
);
|
||||
private _showMenu = signal(false);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
showLeaveConfirm = signal(false);
|
||||
inviteStatus = signal<string | null>(null);
|
||||
creatingInvite = signal(false);
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
const api = this.getWindowControlsApi();
|
||||
|
||||
api?.minimizeWindow?.();
|
||||
}
|
||||
|
||||
/** Maximize or restore the Electron window. */
|
||||
maximize() {
|
||||
const api = this.getWindowControlsApi();
|
||||
|
||||
api?.maximizeWindow?.();
|
||||
}
|
||||
|
||||
/** Close the Electron window. */
|
||||
close() {
|
||||
const api = this.getWindowControlsApi();
|
||||
|
||||
api?.closeWindow?.();
|
||||
}
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
if (this.currentRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the server dropdown menu. */
|
||||
toggleMenu() {
|
||||
this.inviteStatus.set(null);
|
||||
this._showMenu.set(!this._showMenu());
|
||||
}
|
||||
|
||||
/** Create a new invite link for the active room and copy it to the clipboard. */
|
||||
async createInviteLink(): Promise<void> {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!room || !user || this.creatingInvite()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingInvite.set(true);
|
||||
this.inviteStatus.set('Creating invite link…');
|
||||
|
||||
try {
|
||||
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
||||
room.id,
|
||||
{
|
||||
requesterUserId: user.id,
|
||||
requesterDisplayName: user.displayName,
|
||||
requesterRole: user.role
|
||||
},
|
||||
this.toSourceSelector(room)
|
||||
));
|
||||
|
||||
await this.copyInviteLink(invite.inviteUrl);
|
||||
this.inviteStatus.set('Invite link copied to clipboard.');
|
||||
} catch (error: unknown) {
|
||||
const inviteError = error as { error?: { error?: string } };
|
||||
|
||||
this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.');
|
||||
} finally {
|
||||
this.creatingInvite.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave the current server and navigate to the servers list. */
|
||||
leaveServer() {
|
||||
this.openLeaveConfirm();
|
||||
}
|
||||
|
||||
/** Confirm the unified leave action and remove the server locally. */
|
||||
confirmLeave(result: { nextOwnerKey?: string }) {
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
this.showLeaveConfirm.set(false);
|
||||
|
||||
if (!roomId)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({
|
||||
roomId,
|
||||
nextOwnerKey: result.nextOwnerKey
|
||||
}));
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
/** Cancel the leave-server confirmation dialog. */
|
||||
cancelLeave() {
|
||||
this.showLeaveConfirm.set(false);
|
||||
}
|
||||
|
||||
/** Close the server dropdown menu. */
|
||||
closeMenu() {
|
||||
this._showMenu.set(false);
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
} catch {}
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
textarea.value = inviteUrl;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy');
|
||||
|
||||
if (copied) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to prompt fallback */
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
window.prompt('Copy this invite link', inviteUrl);
|
||||
}
|
||||
|
||||
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
|
||||
return {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user