Add eslint
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
<!-- 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'"
|
||||
@@ -20,6 +21,7 @@
|
||||
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'"
|
||||
@@ -31,6 +33,7 @@
|
||||
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'"
|
||||
@@ -42,6 +45,7 @@
|
||||
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'"
|
||||
@@ -63,9 +67,10 @@
|
||||
|
||||
<!-- Room Name -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Room Name</label>
|
||||
<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"
|
||||
/>
|
||||
@@ -73,8 +78,9 @@
|
||||
|
||||
<!-- Room Description -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Description</label>
|
||||
<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"
|
||||
@@ -88,6 +94,7 @@
|
||||
<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()"
|
||||
@@ -105,9 +112,10 @@
|
||||
|
||||
<!-- Max Users -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
|
||||
<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"
|
||||
@@ -116,6 +124,7 @@
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
@@ -127,6 +136,7 @@
|
||||
<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"
|
||||
>
|
||||
@@ -173,6 +183,7 @@
|
||||
<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"
|
||||
@@ -180,6 +191,7 @@
|
||||
<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"
|
||||
@@ -225,6 +237,7 @@
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -346,6 +359,7 @@
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
lucideUnlock
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
@@ -23,9 +24,9 @@ import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
selectOnlineUsers,
|
||||
selectOnlineUsers
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, Room, User } from '../../../core/models';
|
||||
import { BanEntry, User } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
@@ -46,10 +47,10 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
}),
|
||||
lucideUnlock
|
||||
})
|
||||
],
|
||||
templateUrl: './admin-panel.component.html',
|
||||
templateUrl: './admin-panel.component.html'
|
||||
})
|
||||
/**
|
||||
* Admin panel for managing room settings, members, bans, and permissions.
|
||||
@@ -87,12 +88,14 @@ export class AdminPanelComponent {
|
||||
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;
|
||||
@@ -112,7 +115,9 @@ export class AdminPanelComponent {
|
||||
/** Save the current room name, description, privacy, and max-user settings. */
|
||||
saveSettings(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoom({
|
||||
@@ -121,8 +126,8 @@ export class AdminPanelComponent {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers,
|
||||
},
|
||||
maxUsers: this.maxUsers
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -130,7 +135,9 @@ export class AdminPanelComponent {
|
||||
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
@@ -143,8 +150,8 @@ export class AdminPanelComponent {
|
||||
adminsManageRooms: this.adminsManageRooms,
|
||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
||||
adminsManageIcon: this.adminsManageIcon,
|
||||
moderatorsManageIcon: this.moderatorsManageIcon,
|
||||
},
|
||||
moderatorsManageIcon: this.moderatorsManageIcon
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -162,7 +169,9 @@ export class AdminPanelComponent {
|
||||
/** Delete the current room after confirmation. */
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
@@ -171,6 +180,7 @@ export class AdminPanelComponent {
|
||||
/** 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' });
|
||||
}
|
||||
|
||||
@@ -178,6 +188,7 @@ export class AdminPanelComponent {
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -187,7 +198,7 @@ export class AdminPanelComponent {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
role
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,7 +208,7 @@ export class AdminPanelComponent {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
kickedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,7 +218,7 @@ export class AdminPanelComponent {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'ban',
|
||||
targetUserId: user.id,
|
||||
bannedBy: this.currentUser()?.id,
|
||||
bannedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,28 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<label for="login-username" class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="login-username"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<label for="login-password" class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="login-password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<label for="login-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="login-server"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -38,12 +41,20 @@
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
|
||||
No account?
|
||||
<button
|
||||
type="button"
|
||||
(click)="goRegister()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html',
|
||||
templateUrl: './login.component.html'
|
||||
})
|
||||
/**
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
@@ -41,9 +42,12 @@ export class LoginComponent {
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
|
||||
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
@@ -51,15 +55,17 @@ export class LoginComponent {
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,33 +7,37 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<label for="register-username" class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="register-username"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
||||
<label for="register-display-name" class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
||||
<input
|
||||
[(ngModel)]="displayName"
|
||||
type="text"
|
||||
id="register-display-name"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<label for="register-password" class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="register-password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<label for="register-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="register-server"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -46,12 +50,20 @@
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
|
||||
Have an account?
|
||||
<button
|
||||
type="button"
|
||||
(click)="goLogin()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html',
|
||||
templateUrl: './register.component.html'
|
||||
})
|
||||
/**
|
||||
* Registration form allowing new users to create an account on a selected server.
|
||||
@@ -42,9 +43,12 @@ export class RegisterComponent {
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
|
||||
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
@@ -52,15 +56,17 @@ export class RegisterComponent {
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<button (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1">
|
||||
<button type="button" (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1">
|
||||
<ng-icon name="lucideLogIn" class="w-4 h-4" />
|
||||
Login
|
||||
</button>
|
||||
<button (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1">
|
||||
<button type="button" (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1">
|
||||
<ng-icon name="lucideUserPlus" class="w-4 h-4" />
|
||||
Register
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -11,7 +12,7 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||
templateUrl: './user-bar.component.html',
|
||||
templateUrl: './user-bar.component.html'
|
||||
})
|
||||
/**
|
||||
* Compact user status bar showing the current user with login/register navigation links.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
||||
<div class="chat-layout relative h-full">
|
||||
<!-- Messages List -->
|
||||
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-len, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
lucideCopy
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
@@ -25,7 +26,6 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/user
|
||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
|
||||
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
|
||||
@@ -55,16 +55,15 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
}),
|
||||
lucideCopy
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-messages.component.html',
|
||||
styleUrls: ['./chat-messages.component.scss'],
|
||||
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
|
||||
host: {
|
||||
'(document:keydown)': 'onDocKeydown($event)',
|
||||
'(document:keyup)': 'onDocKeyup($event)',
|
||||
},
|
||||
'(document:keyup)': 'onDocKeyup($event)'
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Real-time chat messages view with infinite scroll, markdown rendering,
|
||||
@@ -100,6 +99,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private allChannelMessages = computed(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
return this.allMessages().filter(message =>
|
||||
message.roomId === roomId && (message.channelId || 'general') === channelId
|
||||
);
|
||||
@@ -109,7 +109,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
if (all.length <= limit) return all;
|
||||
|
||||
if (all.length <= limit)
|
||||
return all;
|
||||
|
||||
return all.slice(all.length - limit);
|
||||
});
|
||||
|
||||
@@ -191,6 +194,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private onMessagesChanged = effect(() => {
|
||||
const currentCount = this.totalChannelMessagesLength();
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!el) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
@@ -204,6 +208,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
|
||||
if (newMessages) {
|
||||
if (distanceFromBottom <= 300) {
|
||||
// Smooth auto-scroll only when near bottom; schedule after render
|
||||
@@ -214,12 +219,15 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
this.lastMessageCount = currentCount;
|
||||
});
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
// First render after connect: scroll to bottom instantly (no animation)
|
||||
// Only proceed once messages are actually rendered in the DOM
|
||||
@@ -238,8 +246,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.initialScrollPending = false;
|
||||
this.lastMessageCount = 0;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateScrollPadding();
|
||||
}
|
||||
|
||||
@@ -257,18 +267,22 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.boundOnKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
|
||||
|
||||
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
|
||||
if (this.nowTimer) {
|
||||
clearInterval(this.nowTimer);
|
||||
this.nowTimer = null;
|
||||
}
|
||||
|
||||
if (this.boundOnKeydown) {
|
||||
document.removeEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
@@ -277,7 +291,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Send the current message content (with optional attachments) and reset the input. */
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
if (!raw && this.pendingFiles.length === 0) return;
|
||||
|
||||
if (!raw && this.pendingFiles.length === 0)
|
||||
return;
|
||||
|
||||
const content = this.markdown.appendImageMarkdown(raw);
|
||||
|
||||
@@ -285,7 +301,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
MessagesActions.sendMessage({
|
||||
content,
|
||||
replyToId: this.replyTo()?.id,
|
||||
channelId: this.activeChannelId(),
|
||||
channelId: this.activeChannelId()
|
||||
})
|
||||
);
|
||||
|
||||
@@ -305,6 +321,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Throttle and broadcast a typing indicator when the user types. */
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
@@ -321,12 +338,13 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Save the edited message content and exit edit mode. */
|
||||
saveEdit(messageId: string): void {
|
||||
if (!this.editContent.trim()) return;
|
||||
if (!this.editContent.trim())
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.editMessage({
|
||||
messageId,
|
||||
content: this.editContent.trim(),
|
||||
content: this.editContent.trim()
|
||||
})
|
||||
);
|
||||
|
||||
@@ -366,8 +384,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Smooth-scroll to a specific message element and briefly highlight it. */
|
||||
scrollToMessage(messageId: string): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-primary/10');
|
||||
@@ -393,7 +415,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const message = this.messages().find((msg) => msg.id === messageId);
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!message || !currentUserId) return;
|
||||
if (!message || !currentUserId)
|
||||
return;
|
||||
|
||||
const hasReacted = message.reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
|
||||
@@ -418,15 +441,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
message.reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||
|
||||
groups.set(reaction.emoji, {
|
||||
count: existing.count + 1,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([emoji, data]) => ({
|
||||
emoji,
|
||||
...data,
|
||||
...data
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -435,7 +459,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef);
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||
@@ -454,6 +477,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private scrollToBottom(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
@@ -470,11 +494,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.stopInitialScrollWatch(); // clean up any prior watcher
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
const snap = () => {
|
||||
if (this.messagesContainer) {
|
||||
const e = this.messagesContainer.nativeElement;
|
||||
|
||||
this.isAutoScrolling = true;
|
||||
e.scrollTop = e.scrollHeight;
|
||||
// Clear flag after browser fires the synchronous scroll event
|
||||
@@ -490,7 +517,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src'], // img src swaps
|
||||
attributeFilter: ['src'] // img src swaps
|
||||
});
|
||||
|
||||
// 2. Capture-phase 'load' listener catches images finishing load
|
||||
@@ -506,10 +533,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.initialScrollObserver.disconnect();
|
||||
this.initialScrollObserver = null;
|
||||
}
|
||||
|
||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||
this.boundOnImageLoad = null;
|
||||
}
|
||||
|
||||
if (this.initialScrollTimer) {
|
||||
clearTimeout(this.initialScrollTimer);
|
||||
this.initialScrollTimer = null;
|
||||
@@ -519,12 +548,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private scrollToBottomSmooth(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
} catch {
|
||||
// Fallback if smooth not supported
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
}
|
||||
@@ -538,21 +569,28 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */
|
||||
onScroll(): void {
|
||||
if (!this.messagesContainer) return;
|
||||
if (!this.messagesContainer)
|
||||
return;
|
||||
|
||||
// Ignore scroll events caused by programmatic snap-to-bottom
|
||||
if (this.isAutoScrolling) return;
|
||||
if (this.isAutoScrolling)
|
||||
return;
|
||||
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
|
||||
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
||||
|
||||
if (this.shouldScrollToBottom) {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
// Any user-initiated scroll during the initial load period
|
||||
// immediately hands control back to the user
|
||||
if (this.initialScrollObserver) {
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
|
||||
// Infinite scroll upwards — load older messages when near the top
|
||||
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
@@ -561,7 +599,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Load older messages by expanding the display window, preserving scroll position */
|
||||
loadMore(): void {
|
||||
if (this.loadingMore() || !this.hasMoreMessages()) return;
|
||||
if (this.loadingMore() || !this.hasMoreMessages())
|
||||
return;
|
||||
|
||||
this.loadingMore.set(true);
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
@@ -574,8 +614,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const newScrollHeight = el.scrollHeight;
|
||||
|
||||
el.scrollTop += newScrollHeight - prevScrollHeight;
|
||||
}
|
||||
|
||||
this.loadingMore.set(false);
|
||||
});
|
||||
});
|
||||
@@ -585,21 +627,25 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */
|
||||
onEnter(evt: Event): void {
|
||||
const keyEvent = evt as KeyboardEvent;
|
||||
|
||||
if (keyEvent.shiftKey) {
|
||||
// allow newline
|
||||
return;
|
||||
}
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (el) {
|
||||
el.selectionStart = start;
|
||||
el.selectionEnd = end;
|
||||
@@ -610,6 +656,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */
|
||||
applyInline(token: string): void {
|
||||
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -617,6 +664,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
|
||||
applyPrefix(prefix: string): void {
|
||||
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -624,6 +672,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a markdown heading at the given level around the current selection. */
|
||||
applyHeading(level: number): void {
|
||||
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -631,6 +680,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Convert selected lines into a numbered markdown list. */
|
||||
applyOrderedList(): void {
|
||||
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -638,6 +688,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Wrap the selection in a fenced markdown code block. */
|
||||
applyCodeBlock(): void {
|
||||
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -645,6 +696,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a markdown link around the current selection. */
|
||||
applyLink(): void {
|
||||
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -652,6 +704,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a markdown image embed around the current selection. */
|
||||
applyImage(): void {
|
||||
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -659,6 +712,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a horizontal rule at the cursor position. */
|
||||
applyHorizontalRule(): void {
|
||||
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -687,12 +741,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
evt.preventDefault();
|
||||
const files: File[] = [];
|
||||
const items = evt.dataTransfer?.items;
|
||||
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
|
||||
if (file)
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
} else if (evt.dataTransfer?.files?.length) {
|
||||
@@ -700,6 +758,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
files.push(evt.dataTransfer.files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file) => this.pendingFiles.push(file));
|
||||
// Keep toolbar visible so user sees options
|
||||
this.toolbarVisible.set(true);
|
||||
@@ -714,25 +773,34 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||
formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
let size = bytes;
|
||||
let i = 0;
|
||||
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
|
||||
return `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Format a transfer speed in bytes/second to a human-readable string. */
|
||||
formatSpeed(bps?: number): string {
|
||||
if (!bps || bps <= 0) return '0 B/s';
|
||||
if (!bps || bps <= 0)
|
||||
return '0 B/s';
|
||||
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
|
||||
let speed = bps;
|
||||
let i = 0;
|
||||
|
||||
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
|
||||
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Remove a pending file from the upload queue. */
|
||||
removePendingFile(file: File): void {
|
||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||
|
||||
if (idx >= 0) {
|
||||
this.pendingFiles.splice(idx, 1);
|
||||
}
|
||||
@@ -740,8 +808,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Download a completed attachment to the user's device. */
|
||||
downloadAttachment(att: Attachment): void {
|
||||
if (!att.available || !att.objectUrl) return;
|
||||
if (!att.available || !att.objectUrl)
|
||||
return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = att.objectUrl;
|
||||
a.download = att.filename;
|
||||
document.body.appendChild(a);
|
||||
@@ -762,6 +833,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Check whether the current user is the original uploader of an attachment. */
|
||||
isUploader(att: Attachment): boolean {
|
||||
const myUserId = this.currentUser()?.id;
|
||||
|
||||
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
||||
}
|
||||
|
||||
@@ -794,14 +866,18 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Copy an image attachment to the system clipboard as PNG. */
|
||||
async copyImageToClipboard(att: Attachment): Promise<void> {
|
||||
this.closeImageContextMenu();
|
||||
if (!att.objectUrl) return;
|
||||
|
||||
if (!att.objectUrl)
|
||||
return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(att.objectUrl);
|
||||
const blob = await resp.blob();
|
||||
// Convert to PNG for clipboard compatibility
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob }),
|
||||
new ClipboardItem({ 'image/png': pngBlob })
|
||||
]);
|
||||
} catch (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
@@ -814,22 +890,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) { reject(new Error('Canvas not supported')); return; }
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob((pngBlob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (pngBlob) resolve(pngBlob);
|
||||
else reject(new Error('PNG conversion failed'));
|
||||
|
||||
if (pngBlob)
|
||||
resolve(pngBlob);
|
||||
else
|
||||
reject(new Error('PNG conversion failed'));
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@@ -841,14 +927,20 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
private attachFilesToLastOwnMessage(content: string): void {
|
||||
const me = this.currentUser()?.id;
|
||||
if (!me) return;
|
||||
|
||||
if (!me)
|
||||
return;
|
||||
|
||||
const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted);
|
||||
|
||||
if (!msg) {
|
||||
// Retry shortly until message appears
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaderPeerId = this.currentUser()?.id || undefined;
|
||||
|
||||
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
|
||||
this.pendingFiles = [];
|
||||
}
|
||||
@@ -856,7 +948,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 520) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
@@ -868,7 +963,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
requestAnimationFrame(() => {
|
||||
const bar = this.bottomBar?.nativeElement;
|
||||
const scroll = this.messagesContainer?.nativeElement;
|
||||
if (!bar || !scroll) return;
|
||||
|
||||
if (!bar || !scroll)
|
||||
return;
|
||||
|
||||
scroll.style.paddingBottom = bar.offsetHeight + 20 + 'px';
|
||||
});
|
||||
}
|
||||
@@ -895,6 +993,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Track mouse leave on the toolbar; hide if input is not focused. */
|
||||
onToolbarMouseLeave(): void {
|
||||
this.toolbarHovering = false;
|
||||
|
||||
if (document.activeElement !== this.messageInputRef?.nativeElement) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
@@ -902,12 +1001,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Handle Ctrl key down for enabling manual resize. */
|
||||
onDocKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') this.ctrlHeld.set(true);
|
||||
if (event.key === 'Control')
|
||||
this.ctrlHeld.set(true);
|
||||
}
|
||||
|
||||
/** Handle Ctrl key up for disabling manual resize. */
|
||||
onDocKeyup(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') this.ctrlHeld.set(false);
|
||||
if (event.key === 'Control')
|
||||
this.ctrlHeld.set(false);
|
||||
}
|
||||
|
||||
/** Scroll to the newest message and dismiss the new-messages snackbar. */
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ChatMarkdownService {
|
||||
const after = content.slice(end);
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ export class ChatMarkdownService {
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ export class ChatMarkdownService {
|
||||
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -58,6 +61,7 @@ export class ChatMarkdownService {
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ export class ChatMarkdownService {
|
||||
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -80,6 +85,7 @@ export class ChatMarkdownService {
|
||||
const link = `[${selected}](https://)`;
|
||||
const text = `${before}${link}${after}`;
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
@@ -92,6 +98,7 @@ export class ChatMarkdownService {
|
||||
const img = ``;
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
@@ -99,26 +106,33 @@ export class ChatMarkdownService {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const after = content.slice(end);
|
||||
const hr = `\n\n---\n\n`;
|
||||
const hr = '\n\n---\n\n';
|
||||
const text = `${before}${hr}${after}`;
|
||||
const cursor = before.length + hr.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
appendImageMarkdown(content: string): string {
|
||||
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
|
||||
const urls = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const text = content;
|
||||
|
||||
while ((match = imageUrlRegex.exec(text)) !== null) {
|
||||
urls.add(match[1]);
|
||||
}
|
||||
|
||||
if (urls.size === 0) return content;
|
||||
if (urls.size === 0)
|
||||
return content;
|
||||
|
||||
let append = '';
|
||||
|
||||
for (const url of urls) {
|
||||
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
|
||||
|
||||
if (!alreadyEmbedded) {
|
||||
append += `\n`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
|
||||
import { Component, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -13,8 +14,8 @@ const MAX_SHOWN = 4;
|
||||
templateUrl: './typing-indicator.component.html',
|
||||
host: {
|
||||
'class': 'block',
|
||||
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));',
|
||||
},
|
||||
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
|
||||
}
|
||||
})
|
||||
export class TypingIndicatorComponent {
|
||||
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||
@@ -25,30 +26,31 @@ export class TypingIndicatorComponent {
|
||||
constructor() {
|
||||
const webrtc = inject(WebRTCService);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
|
||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
|
||||
tap((msg: any) => {
|
||||
const now = Date.now();
|
||||
|
||||
this.typingMap.set(String(msg.oderId), {
|
||||
name: String(msg.displayName),
|
||||
expiresAt: now + TYPING_TTL,
|
||||
expiresAt: now + TYPING_TTL
|
||||
});
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const purge$ = interval(PURGE_INTERVAL).pipe(
|
||||
map(() => Date.now()),
|
||||
filter((now) => {
|
||||
let changed = false;
|
||||
|
||||
for (const [key, entry] of this.typingMap) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.typingMap.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
merge(typing$, purge$)
|
||||
@@ -61,6 +63,7 @@ export class TypingIndicatorComponent {
|
||||
const names = Array.from(this.typingMap.values())
|
||||
.filter((e) => e.expiresAt > now)
|
||||
.map((e) => e.name);
|
||||
|
||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(click)="toggleUserMenu(user.id)"
|
||||
(keydown.enter)="toggleUserMenu(user.id)"
|
||||
(keydown.space)="toggleUserMenu(user.id)"
|
||||
(keyup.enter)="toggleUserMenu(user.id)"
|
||||
(keyup.space)="toggleUserMenu(user.id)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
@@ -65,9 +71,13 @@
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="muteUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
||||
>
|
||||
@@ -81,6 +91,7 @@
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
||||
>
|
||||
@@ -88,6 +99,7 @@
|
||||
<span>Kick</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="banUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
||||
>
|
||||
@@ -121,19 +133,21 @@
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<label for="ban-reason-input" class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
id="ban-reason-input"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<label for="ban-duration-select" class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
id="ban-duration-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="3600000">1 hour</option>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,14 +14,14 @@ import {
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
@@ -40,10 +41,10 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
}),
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './user-list.component.html',
|
||||
templateUrl: './user-list.component.html'
|
||||
})
|
||||
/**
|
||||
* Displays the list of online users with voice state indicators and admin actions.
|
||||
@@ -79,6 +80,7 @@ export class UserListComponent {
|
||||
} else {
|
||||
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
||||
}
|
||||
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
@@ -106,7 +108,9 @@ export class UserListComponent {
|
||||
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
|
||||
confirmBan(): void {
|
||||
const user = this.userToBan();
|
||||
if (!user) return;
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
const duration = parseInt(this.banDuration, 10);
|
||||
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
||||
@@ -115,7 +119,7 @@ export class UserListComponent {
|
||||
UsersActions.banUser({
|
||||
userId: user.id,
|
||||
reason: this.banReason || undefined,
|
||||
expiresAt,
|
||||
expiresAt
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideChevronLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
|
||||
@@ -19,13 +20,11 @@ import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.co
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
selectTextChannels,
|
||||
selectTextChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
|
||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
standalone: true,
|
||||
@@ -34,7 +33,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
ScreenShareViewerComponent,
|
||||
RoomsSidePanelComponent,
|
||||
RoomsSidePanelComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -43,10 +42,10 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
}),
|
||||
lucideChevronLeft
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-room.component.html',
|
||||
templateUrl: './chat-room.component.html'
|
||||
})
|
||||
/**
|
||||
* Main chat room view combining the messages panel, side panels, and admin controls.
|
||||
@@ -67,12 +66,14 @@ export class ChatRoomComponent {
|
||||
get activeChannelName(): string {
|
||||
const id = this.activeChannelId();
|
||||
const activeChannel = this.textChannels().find((channel) => channel.id === id);
|
||||
|
||||
return activeChannel ? activeChannel.name : id;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 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">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -11,18 +12,18 @@ import {
|
||||
lucideMonitor,
|
||||
lucideHash,
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
lucidePlus
|
||||
} from '@ng-icons/lucide';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
selectTextChannels,
|
||||
selectVoiceChannels,
|
||||
selectVoiceChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
@@ -47,7 +48,7 @@ type TabView = 'channels' | 'users';
|
||||
VoiceControlsComponent,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -58,10 +59,10 @@ type TabView = 'channels' | 'users';
|
||||
lucideMonitor,
|
||||
lucideHash,
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
}),
|
||||
lucidePlus
|
||||
})
|
||||
],
|
||||
templateUrl: './rooms-side-panel.component.html',
|
||||
templateUrl: './rooms-side-panel.component.html'
|
||||
})
|
||||
/**
|
||||
* Side panel listing text and voice channels, online users, and channel management actions.
|
||||
@@ -108,8 +109,9 @@ export class RoomsSidePanelComponent {
|
||||
const current = this.currentUser();
|
||||
const currentId = current?.id;
|
||||
const currentOderId = current?.oderId;
|
||||
|
||||
return this.onlineUsers().filter(
|
||||
(user) => user.id !== currentId && user.oderId !== currentOderId,
|
||||
(user) => user.id !== currentId && user.oderId !== currentOderId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,19 +119,31 @@ export class RoomsSidePanelComponent {
|
||||
canManageChannels(): boolean {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
if (!room || !user) return false;
|
||||
|
||||
if (!room || !user)
|
||||
return false;
|
||||
|
||||
// Owner always can
|
||||
if (room.hostId === user.id) return true;
|
||||
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;
|
||||
|
||||
if (user.role === 'admin' && perms.adminsManageRooms)
|
||||
return true;
|
||||
|
||||
if (user.role === 'moderator' && perms.moderatorsManageRooms)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Select a text channel (no-op if currently renaming). */
|
||||
// ---- Text channel selection ----
|
||||
selectTextChannel(channelId: string) {
|
||||
if (this.renamingChannelId()) return; // don't switch while renaming
|
||||
if (this.renamingChannelId())
|
||||
return; // don't switch while renaming
|
||||
|
||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||
}
|
||||
|
||||
@@ -151,7 +165,9 @@ export class RoomsSidePanelComponent {
|
||||
/** Begin inline renaming of the context-menu channel. */
|
||||
startRename() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
this.closeChannelMenu();
|
||||
|
||||
if (ch) {
|
||||
this.renamingChannelId.set(ch.id);
|
||||
}
|
||||
@@ -162,9 +178,11 @@ export class RoomsSidePanelComponent {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -176,7 +194,9 @@ export class RoomsSidePanelComponent {
|
||||
/** Delete the context-menu channel. */
|
||||
deleteChannel() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
this.closeChannelMenu();
|
||||
|
||||
if (ch) {
|
||||
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
|
||||
}
|
||||
@@ -186,6 +206,7 @@ export class RoomsSidePanelComponent {
|
||||
resyncMessages() {
|
||||
this.closeChannelMenu();
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
@@ -195,9 +216,11 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
// Request inventory from all connected peers
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (peers.length === 0) {
|
||||
// No connected peers — sync will time out
|
||||
}
|
||||
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
||||
@@ -218,15 +241,19 @@ export class RoomsSidePanelComponent {
|
||||
/** Confirm channel creation and dispatch the add-channel action. */
|
||||
confirmCreateChannel() {
|
||||
const name = this.newChannelName.trim();
|
||||
if (!name) return;
|
||||
|
||||
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,
|
||||
position: existing.length
|
||||
};
|
||||
|
||||
this.store.dispatch(RoomsActions.addChannel({ channel }));
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
@@ -240,7 +267,10 @@ export class RoomsSidePanelComponent {
|
||||
// ---- User context menu (kick/role) ----
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
if (!this.isAdmin()) return;
|
||||
|
||||
if (!this.isAdmin())
|
||||
return;
|
||||
|
||||
this.contextMenuUser.set(user);
|
||||
this.userMenuX.set(evt.clientX);
|
||||
this.userMenuY.set(evt.clientY);
|
||||
@@ -255,14 +285,16 @@ export class RoomsSidePanelComponent {
|
||||
/** Change a user's role and broadcast the update to connected peers. */
|
||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||
const user = this.contextMenuUser();
|
||||
|
||||
this.closeUserMenu();
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
// Broadcast role change to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
role
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -270,14 +302,16 @@ export class RoomsSidePanelComponent {
|
||||
/** Kick a user and broadcast the action to peers. */
|
||||
kickUserAction() {
|
||||
const user = this.contextMenuUser();
|
||||
|
||||
this.closeUserMenu();
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
// Broadcast kick to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
kickedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -287,6 +321,7 @@ export class RoomsSidePanelComponent {
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
// Voice is disabled by room permissions
|
||||
return;
|
||||
@@ -309,9 +344,9 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
}),
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -325,7 +360,6 @@ export class RoomsSidePanelComponent {
|
||||
current?.voiceState?.isConnected &&
|
||||
current.voiceState.serverId === room?.id &&
|
||||
current.voiceState.roomId !== roomId;
|
||||
|
||||
// Enable microphone and broadcast voice-state
|
||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||
|
||||
@@ -340,11 +374,12 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room.id,
|
||||
},
|
||||
}),
|
||||
serverId: room.id
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
|
||||
this.webrtc.broadcastMessage({
|
||||
@@ -356,8 +391,8 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room?.id,
|
||||
},
|
||||
serverId: room?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Update voice session for floating controls
|
||||
@@ -365,6 +400,7 @@ export class RoomsSidePanelComponent {
|
||||
// Find label from channel list
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
@@ -372,7 +408,7 @@ export class RoomsSidePanelComponent {
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -384,8 +420,10 @@ export class RoomsSidePanelComponent {
|
||||
/** Leave a voice channel and broadcast the disconnect state. */
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
|
||||
// Only leave if currently in this room
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
||||
return;
|
||||
|
||||
// Stop voice heartbeat
|
||||
this.webrtc.stopVoiceHeartbeat();
|
||||
@@ -403,9 +441,9 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
}),
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -419,8 +457,8 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
serverId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
// End voice session
|
||||
@@ -431,50 +469,59 @@ export class RoomsSidePanelComponent {
|
||||
voiceOccupancy(roomId: string): number {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return users.filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id,
|
||||
user.voiceState?.serverId === room?.id
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||
viewShare(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's stream. */
|
||||
viewStream(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Check whether a user is currently sharing their screen. */
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
|
||||
if (me?.id === userId) {
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
|
||||
const user = this.onlineUsers().find(
|
||||
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId,
|
||||
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
|
||||
);
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream = this.webrtc.getRemoteStream(userId);
|
||||
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
/** Return all users currently connected to a specific voice channel. */
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return this.onlineUsers().filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id,
|
||||
user.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,6 +529,7 @@ export class RoomsSidePanelComponent {
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!(
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
@@ -492,6 +540,7 @@ export class RoomsSidePanelComponent {
|
||||
/** Check whether voice is enabled by the current room's permissions. */
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
}
|
||||
|
||||
@@ -501,6 +550,7 @@ export class RoomsSidePanelComponent {
|
||||
*/
|
||||
getPeerLatency(user: User): number | null {
|
||||
const latencies = this.webrtc.peerLatencies();
|
||||
|
||||
// Try oderId first (primary peer key), then fall back to user id
|
||||
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
|
||||
}
|
||||
@@ -515,10 +565,19 @@ export class RoomsSidePanelComponent {
|
||||
*/
|
||||
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';
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
(click)="joinSavedRoom(room)"
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
|
||||
>
|
||||
{{ room.name }}
|
||||
@@ -35,6 +36,7 @@
|
||||
</div>
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
type="button"
|
||||
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
@@ -47,6 +49,7 @@
|
||||
<div class="p-4 border-b border-border">
|
||||
<button
|
||||
(click)="openCreateDialog()"
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
||||
@@ -71,6 +74,7 @@
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<button
|
||||
(click)="joinServer(server)"
|
||||
type="button"
|
||||
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
@@ -119,37 +123,56 @@
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeCreateDialog()">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()">
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
(click)="closeCreateDialog()"
|
||||
(keydown.enter)="closeCreateDialog()"
|
||||
(keydown.space)="closeCreateDialog()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close create server dialog"
|
||||
>
|
||||
<div
|
||||
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
(keydown.space)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Server Name</label>
|
||||
<label for="create-server-name" class="block text-sm font-medium text-foreground mb-1">Server Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="My Awesome Server"
|
||||
id="create-server-name"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
|
||||
<label for="create-server-description" class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
rows="3"
|
||||
id="create-server-description"
|
||||
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 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
|
||||
<label for="create-server-topic" class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
placeholder="gaming, music, coding..."
|
||||
id="create-server-topic"
|
||||
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>
|
||||
@@ -166,11 +189,12 @@
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<label for="create-server-password" class="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Enter password"
|
||||
id="create-server-password"
|
||||
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>
|
||||
@@ -180,6 +204,7 @@
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeCreateDialog()"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
@@ -187,6 +212,7 @@
|
||||
<button
|
||||
(click)="createServer()"
|
||||
[disabled]="!newServerName()"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
lucideSettings
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms,
|
||||
selectSavedRooms
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import { Room } from '../../core/models';
|
||||
import { ServerInfo } from '../../core/models';
|
||||
@@ -36,10 +37,10 @@ import { SettingsModalService } from '../../core/services/settings-modal.service
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
}),
|
||||
lucideSettings
|
||||
})
|
||||
],
|
||||
templateUrl: './server-search.component.html',
|
||||
templateUrl: './server-search.component.html'
|
||||
})
|
||||
/**
|
||||
* Server search and discovery view with server creation dialog.
|
||||
@@ -85,19 +86,21 @@ export class ServerSearchComponent implements OnInit {
|
||||
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
||||
joinServer(server: ServerInfo): void {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: server.id,
|
||||
serverInfo: {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
hostName: server.hostName,
|
||||
},
|
||||
}),
|
||||
hostName: server.hostName
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,8 +117,11 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
/** Submit the new server creation form and dispatch the create action. */
|
||||
createServer(): void {
|
||||
if (!this.newServerName()) return;
|
||||
if (!this.newServerName())
|
||||
return;
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
@@ -127,8 +133,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
description: this.newServerDescription() || undefined,
|
||||
topic: this.newServerTopic() || undefined,
|
||||
isPrivate: this.newServerPrivate(),
|
||||
password: this.newServerPrivate() ? this.newServerPassword() : undefined,
|
||||
}),
|
||||
password: this.newServerPrivate() ? this.newServerPassword() : undefined
|
||||
})
|
||||
);
|
||||
|
||||
this.closeCreateDialog();
|
||||
@@ -149,7 +155,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
isPrivate: !!room.password,
|
||||
createdAt: room.createdAt,
|
||||
createdAt: room.createdAt
|
||||
} as any);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -17,7 +18,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html',
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
/**
|
||||
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
|
||||
@@ -40,8 +41,11 @@ export class ServersRailComponent {
|
||||
|
||||
/** Return the first character of a server name as its icon initial. */
|
||||
initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
if (!name)
|
||||
return '?';
|
||||
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
|
||||
return ch || '?';
|
||||
}
|
||||
|
||||
@@ -52,9 +56,11 @@ export class ServersRailComponent {
|
||||
// Navigate to server list (has create button)
|
||||
// Update voice session state if connected to voice
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
}
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
@@ -62,6 +68,7 @@ export class ServersRailComponent {
|
||||
joinSavedRoom(room: Room): void {
|
||||
// Require auth: if no current user, go to login
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
@@ -69,6 +76,7 @@ export class ServersRailComponent {
|
||||
|
||||
// Check if we're navigating to a different server while in voice
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId && voiceServerId !== room.id) {
|
||||
// User is switching to a different server while connected to voice
|
||||
// Update voice session to show floating controls (voice stays connected)
|
||||
@@ -89,8 +97,8 @@ export class ServersRailComponent {
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
},
|
||||
hostName: room.hostId || 'Unknown'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +123,7 @@ export class ServersRailComponent {
|
||||
isCurrentContextRoom(): boolean {
|
||||
const ctx = this.contextRoom();
|
||||
const cur = this.currentRoom();
|
||||
|
||||
return !!ctx && !!cur && ctx.id === cur.id;
|
||||
}
|
||||
|
||||
@@ -134,11 +143,15 @@ export class ServersRailComponent {
|
||||
/** Forget (remove) a server from the saved list, leaving if it is the current room. */
|
||||
confirmForget(): void {
|
||||
const ctx = this.contextRoom();
|
||||
if (!ctx) return;
|
||||
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
if (this.currentRoom()?.id === ctx.id) {
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id }));
|
||||
this.showConfirm.set(false);
|
||||
this.contextRoom.set(null);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -14,10 +15,10 @@ import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideX,
|
||||
}),
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './bans-settings.component.html',
|
||||
templateUrl: './bans-settings.component.html'
|
||||
})
|
||||
export class BansSettingsComponent {
|
||||
private store = inject(Store);
|
||||
@@ -35,6 +36,7 @@ export class BansSettingsComponent {
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return (
|
||||
date.toLocaleDateString() +
|
||||
' ' +
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -18,10 +19,10 @@ import { UserAvatarComponent } from '../../../../shared';
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideUserX,
|
||||
lucideBan,
|
||||
}),
|
||||
lucideBan
|
||||
})
|
||||
],
|
||||
templateUrl: './members-settings.component.html',
|
||||
templateUrl: './members-settings.component.html'
|
||||
})
|
||||
export class MembersSettingsComponent {
|
||||
private store = inject(Store);
|
||||
@@ -37,6 +38,7 @@ export class MembersSettingsComponent {
|
||||
|
||||
membersFiltered(): User[] {
|
||||
const me = this.currentUser();
|
||||
|
||||
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||
}
|
||||
|
||||
@@ -45,7 +47,7 @@ export class MembersSettingsComponent {
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
role
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ export class MembersSettingsComponent {
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
kickedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ export class MembersSettingsComponent {
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'ban',
|
||||
targetUserId: user.id,
|
||||
bannedBy: this.currentUser()?.id,
|
||||
bannedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideCheck
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
||||
@@ -25,10 +26,10 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
}),
|
||||
lucideCheck
|
||||
})
|
||||
],
|
||||
templateUrl: './network-settings.component.html',
|
||||
templateUrl: './network-settings.component.html'
|
||||
})
|
||||
export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
@@ -47,25 +48,30 @@ export class NetworkSettingsComponent {
|
||||
|
||||
addServer(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
try {
|
||||
new URL(this.newServerUrl);
|
||||
} catch {
|
||||
this.addError.set('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
if (this.servers().some((s) => s.url === this.newServerUrl)) {
|
||||
|
||||
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(/\/$/, ''),
|
||||
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);
|
||||
|
||||
if (newServer)
|
||||
this.serverDirectory.testServer(newServer.id);
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
@@ -84,8 +90,10 @@ export class NetworkSettingsComponent {
|
||||
|
||||
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);
|
||||
@@ -97,8 +105,8 @@ export class NetworkSettingsComponent {
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers,
|
||||
}),
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -14,10 +15,10 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
}),
|
||||
lucideCheck
|
||||
})
|
||||
],
|
||||
templateUrl: './permissions-settings.component.html',
|
||||
templateUrl: './permissions-settings.component.html'
|
||||
})
|
||||
export class PermissionsSettingsComponent {
|
||||
private store = inject(Store);
|
||||
@@ -42,6 +43,7 @@ export class PermissionsSettingsComponent {
|
||||
/** 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;
|
||||
@@ -54,7 +56,10 @@ export class PermissionsSettingsComponent {
|
||||
|
||||
savePermissions(): void {
|
||||
const room = this.server();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
roomId: room.id,
|
||||
@@ -66,16 +71,19 @@ export class PermissionsSettingsComponent {
|
||||
adminsManageRooms: this.adminsManageRooms,
|
||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
||||
adminsManageIcon: this.adminsManageIcon,
|
||||
moderatorsManageIcon: this.moderatorsManageIcon,
|
||||
},
|
||||
}),
|
||||
moderatorsManageIcon: this.moderatorsManageIcon
|
||||
}
|
||||
})
|
||||
);
|
||||
this.showSaveSuccess('permissions');
|
||||
}
|
||||
|
||||
private showSaveSuccess(key: string): void {
|
||||
this.saveSuccess.set(key);
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||
|
||||
if (this.saveTimeout)
|
||||
clearTimeout(this.saveTimeout);
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,24 @@
|
||||
}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label>
|
||||
<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 class="block text-xs font-medium text-muted-foreground mb-1">Description</label>
|
||||
<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()"
|
||||
@@ -39,6 +41,7 @@
|
||||
</div>
|
||||
<button
|
||||
(click)="togglePrivate()"
|
||||
type="button"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
[class.bg-primary]="isPrivate()"
|
||||
[class.text-primary-foreground]="isPrivate()"
|
||||
@@ -62,14 +65,15 @@
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Max Users (0 = unlimited)</label
|
||||
>
|
||||
<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()"
|
||||
@@ -81,6 +85,7 @@
|
||||
@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'"
|
||||
@@ -94,6 +99,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -19,10 +20,10 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
lucideCheck,
|
||||
lucideTrash2,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
}),
|
||||
lucideUnlock
|
||||
})
|
||||
],
|
||||
templateUrl: './server-settings.component.html',
|
||||
templateUrl: './server-settings.component.html'
|
||||
})
|
||||
export class ServerSettingsComponent {
|
||||
private store = inject(Store);
|
||||
@@ -45,22 +46,27 @@ export class ServerSettingsComponent {
|
||||
/** Reload form fields whenever the server input changes. */
|
||||
serverData = computed(() => {
|
||||
const room = this.server();
|
||||
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
}
|
||||
|
||||
return room;
|
||||
});
|
||||
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((v) => !v);
|
||||
this.isPrivate.update((currentValue) => !currentValue);
|
||||
}
|
||||
|
||||
saveServerSettings(): void {
|
||||
const room = this.server();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
@@ -68,9 +74,9 @@ export class ServerSettingsComponent {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers,
|
||||
},
|
||||
}),
|
||||
maxUsers: this.maxUsers
|
||||
}
|
||||
})
|
||||
);
|
||||
this.showSaveSuccess('server');
|
||||
}
|
||||
@@ -81,7 +87,10 @@ export class ServerSettingsComponent {
|
||||
|
||||
deleteRoom(): void {
|
||||
const room = this.server();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
this.modal.navigate('network');
|
||||
@@ -89,7 +98,10 @@ export class ServerSettingsComponent {
|
||||
|
||||
private showSaveSuccess(key: string): void {
|
||||
this.saveSuccess.set(key);
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||
|
||||
if (this.saveTimeout)
|
||||
clearTimeout(this.saveTimeout);
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
[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 -->
|
||||
@@ -16,6 +21,11 @@
|
||||
[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">
|
||||
@@ -33,6 +43,7 @@
|
||||
@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"
|
||||
@@ -72,6 +83,7 @@
|
||||
@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"
|
||||
@@ -119,6 +131,7 @@
|
||||
</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" />
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
effect,
|
||||
HostListener,
|
||||
viewChild,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -20,13 +19,13 @@ import {
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
lucideShield,
|
||||
lucideShield
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectCurrentUser,
|
||||
selectCurrentUser
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { Room } from '../../../core/models';
|
||||
|
||||
@@ -49,7 +48,7 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
||||
ServerSettingsComponent,
|
||||
MembersSettingsComponent,
|
||||
BansSettingsComponent,
|
||||
PermissionsSettingsComponent,
|
||||
PermissionsSettingsComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -59,12 +58,12 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
lucideShield,
|
||||
}),
|
||||
lucideShield
|
||||
})
|
||||
],
|
||||
templateUrl: './settings-modal.component.html',
|
||||
templateUrl: './settings-modal.component.html'
|
||||
})
|
||||
export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
export class SettingsModalComponent {
|
||||
readonly modal = inject(SettingsModalService);
|
||||
private store = inject(Store);
|
||||
|
||||
@@ -82,21 +81,24 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
// --- Side-nav items ---
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }
|
||||
];
|
||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
||||
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
||||
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
||||
];
|
||||
|
||||
// ===== SERVER SELECTOR =====
|
||||
selectedServerId = signal<string | null>(null);
|
||||
selectedServer = computed<Room | null>(() => {
|
||||
const id = this.selectedServerId();
|
||||
if (!id) return null;
|
||||
return this.savedRooms().find((r) => r.id === id) ?? null;
|
||||
|
||||
if (!id)
|
||||
return null;
|
||||
|
||||
return this.savedRooms().find((room) => room.id === id) ?? null;
|
||||
});
|
||||
|
||||
/** Whether the user can see server-admin tabs. */
|
||||
@@ -108,7 +110,10 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
isSelectedServerAdmin = computed(() => {
|
||||
const server = this.selectedServer();
|
||||
const user = this.currentUser();
|
||||
if (!server || !user) return false;
|
||||
|
||||
if (!server || !user)
|
||||
return false;
|
||||
|
||||
return server.hostId === user.id || server.hostId === user.oderId;
|
||||
});
|
||||
|
||||
@@ -120,11 +125,17 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
effect(() => {
|
||||
if (this.isOpen()) {
|
||||
const targetId = this.modal.targetServerId();
|
||||
|
||||
if (targetId) {
|
||||
this.selectedServerId.set(targetId);
|
||||
} else if (this.currentRoom()) {
|
||||
this.selectedServerId.set(this.currentRoom()!.id);
|
||||
} else {
|
||||
const currentRoom = this.currentRoom();
|
||||
|
||||
if (currentRoom) {
|
||||
this.selectedServerId.set(currentRoom.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.animating.set(true);
|
||||
}
|
||||
});
|
||||
@@ -132,19 +143,16 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
// When selected server changes, reload permissions data
|
||||
effect(() => {
|
||||
const server = this.selectedServer();
|
||||
|
||||
if (server) {
|
||||
const permsComp = this.permissionsComponent();
|
||||
|
||||
if (permsComp) {
|
||||
permsComp.loadPermissions(server);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.isOpen()) {
|
||||
@@ -168,6 +176,7 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
|
||||
onServerSelect(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedServerId.set(select.value || null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label>
|
||||
<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) {
|
||||
@@ -23,9 +24,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label>
|
||||
<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) {
|
||||
@@ -49,7 +51,7 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label for="input-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Input Volume: {{ inputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -58,11 +60,12 @@
|
||||
(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 class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label for="output-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Output Volume: {{ outputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -71,11 +74,12 @@
|
||||
(input)="onOutputVolumeChange($event)"
|
||||
min="0"
|
||||
max="100"
|
||||
id="output-volume-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<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">
|
||||
@@ -86,10 +90,12 @@
|
||||
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"
|
||||
>
|
||||
@@ -111,9 +117,10 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label>
|
||||
<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>
|
||||
@@ -122,7 +129,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label for="audio-bitrate-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||
</label>
|
||||
<input
|
||||
@@ -132,6 +139,7 @@
|
||||
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>
|
||||
@@ -145,6 +153,8 @@
|
||||
type="checkbox"
|
||||
[checked]="noiseReduction()"
|
||||
(change)="onNoiseReductionChange()"
|
||||
id="noise-reduction-toggle"
|
||||
aria-label="Toggle noise reduction"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
@@ -162,6 +172,8 @@
|
||||
type="checkbox"
|
||||
[checked]="includeSystemAudio()"
|
||||
(change)="onIncludeSystemAudioChange($event)"
|
||||
id="system-audio-toggle"
|
||||
aria-label="Toggle system audio in screen share"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
@@ -190,6 +202,8 @@
|
||||
type="checkbox"
|
||||
[checked]="voiceLeveling.enabled()"
|
||||
(change)="onVoiceLevelingToggle()"
|
||||
id="voice-leveling-toggle"
|
||||
aria-label="Toggle voice leveling"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
@@ -203,7 +217,7 @@
|
||||
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
|
||||
<!-- Target Loudness -->
|
||||
<div class="pl-3">
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label for="target-loudness-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS
|
||||
</label>
|
||||
<input
|
||||
@@ -213,6 +227,7 @@
|
||||
min="-30"
|
||||
max="-12"
|
||||
step="1"
|
||||
id="target-loudness-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5">
|
||||
@@ -223,9 +238,10 @@
|
||||
|
||||
<!-- AGC Strength -->
|
||||
<div class="pl-3">
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label>
|
||||
<label for="agc-strength-select" class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label>
|
||||
<select
|
||||
(change)="onStrengthChange($event)"
|
||||
id="agc-strength-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]="voiceLeveling.strength() === 'low'">
|
||||
@@ -242,7 +258,7 @@
|
||||
|
||||
<!-- Max Gain Boost -->
|
||||
<div class="pl-3">
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label for="max-gain-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB
|
||||
</label>
|
||||
<input
|
||||
@@ -252,6 +268,7 @@
|
||||
min="3"
|
||||
max="20"
|
||||
step="1"
|
||||
id="max-gain-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5">
|
||||
@@ -262,11 +279,12 @@
|
||||
|
||||
<!-- Response Speed -->
|
||||
<div class="pl-3">
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label for="response-speed-select" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Response Speed
|
||||
</label>
|
||||
<select
|
||||
(change)="onSpeedChange($event)"
|
||||
id="response-speed-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="slow" [selected]="voiceLeveling.speed() === 'slow'">
|
||||
@@ -290,6 +308,8 @@
|
||||
type="checkbox"
|
||||
[checked]="voiceLeveling.noiseGate()"
|
||||
(change)="onNoiseGateToggle()"
|
||||
id="noise-gate-toggle"
|
||||
aria-label="Toggle noise floor gate"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -8,7 +9,7 @@ import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
||||
import {
|
||||
NotificationAudioService,
|
||||
AppSound,
|
||||
AppSound
|
||||
} from '../../../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
||||
|
||||
@@ -26,10 +27,10 @@ interface AudioDevice {
|
||||
lucideMic,
|
||||
lucideHeadphones,
|
||||
lucideAudioLines,
|
||||
lucideActivity,
|
||||
}),
|
||||
lucideActivity
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-settings.component.html',
|
||||
templateUrl: './voice-settings.component.html'
|
||||
})
|
||||
export class VoiceSettingsComponent {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
@@ -54,17 +55,20 @@ export class VoiceSettingsComponent {
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return;
|
||||
if (!navigator.mediaDevices?.enumerateDevices)
|
||||
return;
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audioinput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audiooutput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
@@ -72,18 +76,37 @@ export class VoiceSettingsComponent {
|
||||
loadVoiceSettings(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
if (!raw) return;
|
||||
const s = JSON.parse(raw);
|
||||
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
|
||||
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
|
||||
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
|
||||
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
|
||||
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
|
||||
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
|
||||
if (typeof s.includeSystemAudio === 'boolean')
|
||||
this.includeSystemAudio.set(s.includeSystemAudio);
|
||||
if (typeof s.noiseReduction === 'boolean') this.noiseReduction.set(s.noiseReduction);
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const settings = JSON.parse(raw);
|
||||
|
||||
if (settings.inputDevice)
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
|
||||
if (settings.outputDevice)
|
||||
this.selectedOutputDevice.set(settings.outputDevice);
|
||||
|
||||
if (typeof settings.inputVolume === 'number')
|
||||
this.inputVolume.set(settings.inputVolume);
|
||||
|
||||
if (typeof settings.outputVolume === 'number')
|
||||
this.outputVolume.set(settings.outputVolume);
|
||||
|
||||
if (typeof settings.audioBitrate === 'number')
|
||||
this.audioBitrate.set(settings.audioBitrate);
|
||||
|
||||
if (settings.latencyProfile)
|
||||
this.latencyProfile.set(settings.latencyProfile);
|
||||
|
||||
if (typeof settings.includeSystemAudio === 'boolean')
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
|
||||
if (typeof settings.noiseReduction === 'boolean')
|
||||
this.noiseReduction.set(settings.noiseReduction);
|
||||
} catch {}
|
||||
|
||||
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) {
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
}
|
||||
@@ -101,20 +124,22 @@ export class VoiceSettingsComponent {
|
||||
audioBitrate: this.audioBitrate(),
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
noiseReduction: this.noiseReduction(),
|
||||
}),
|
||||
noiseReduction: this.noiseReduction()
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
@@ -122,12 +147,14 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
@@ -136,6 +163,7 @@ export class VoiceSettingsComponent {
|
||||
onLatencyProfileChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const profile = select.value as 'low' | 'balanced' | 'high';
|
||||
|
||||
this.latencyProfile.set(profile);
|
||||
this.webrtcService.setLatencyProfile(profile);
|
||||
this.saveVoiceSettings();
|
||||
@@ -143,6 +171,7 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.audioBitrate.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setAudioBitrate(this.audioBitrate());
|
||||
this.saveVoiceSettings();
|
||||
@@ -150,12 +179,13 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
async onNoiseReductionChange(): Promise<void> {
|
||||
this.noiseReduction.update((v) => !v);
|
||||
this.noiseReduction.update((currentValue) => !currentValue);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
@@ -168,21 +198,25 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onTargetDbfsChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.voiceLeveling.setTargetDbfs(parseInt(input.value, 10));
|
||||
}
|
||||
|
||||
onStrengthChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.voiceLeveling.setStrength(select.value as 'low' | 'medium' | 'high');
|
||||
}
|
||||
|
||||
onMaxGainDbChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.voiceLeveling.setMaxGainDb(parseInt(input.value, 10));
|
||||
}
|
||||
|
||||
onSpeedChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.voiceLeveling.setSpeed(select.value as 'slow' | 'medium' | 'fast');
|
||||
}
|
||||
|
||||
@@ -192,6 +226,7 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onNotificationVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.audioService.setNotificationVolume(parseFloat(input.value));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines,
|
||||
lucideAudioLines
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
@@ -36,10 +37,10 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines,
|
||||
}),
|
||||
lucideAudioLines
|
||||
})
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
templateUrl: './settings.component.html'
|
||||
})
|
||||
/**
|
||||
* Settings page for managing signaling servers and connection preferences.
|
||||
@@ -86,7 +87,7 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
this.serverDirectory.addServer({
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, ''), // Remove trailing slash
|
||||
url: this.newServerUrl.trim().replace(/\/$/, '') // Remove trailing slash
|
||||
});
|
||||
|
||||
// Clear form
|
||||
@@ -96,6 +97,7 @@ export class SettingsComponent implements OnInit {
|
||||
// Test the new server
|
||||
const servers = this.servers();
|
||||
const newServer = servers[servers.length - 1];
|
||||
|
||||
if (newServer) {
|
||||
this.serverDirectory.testServer(newServer.id);
|
||||
}
|
||||
@@ -121,8 +123,10 @@ export class SettingsComponent implements OnInit {
|
||||
/** 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);
|
||||
@@ -135,8 +139,8 @@ export class SettingsComponent implements OnInit {
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers,
|
||||
}),
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
@@ -149,10 +153,13 @@ export class SettingsComponent implements OnInit {
|
||||
/** 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.webrtcService.isNoiseReductionEnabled()) {
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||
@@ -173,13 +180,17 @@ export class SettingsComponent implements OnInit {
|
||||
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);
|
||||
|
||||
if (raw)
|
||||
existing = JSON.parse(raw);
|
||||
} catch {}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_VOICE_SETTINGS,
|
||||
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction }),
|
||||
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction })
|
||||
);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||
@if (inRoom()) {
|
||||
<button (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<button type="button" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
@@ -16,13 +16,14 @@
|
||||
{{ roomDescription() }}
|
||||
</span>
|
||||
}
|
||||
<button (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
|
||||
<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 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||
>
|
||||
@@ -30,6 +31,7 @@
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
@@ -49,6 +51,7 @@
|
||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
||||
@if (!isAuthed()) {
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||
(click)="goLogin()"
|
||||
title="Login"
|
||||
@@ -57,13 +60,13 @@
|
||||
</button>
|
||||
}
|
||||
@if (isElectron()) {
|
||||
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
|
||||
<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 class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
|
||||
<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 class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
|
||||
<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>
|
||||
}
|
||||
@@ -71,5 +74,14 @@
|
||||
</div>
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
@if (showMenu()) {
|
||||
<div class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
||||
templateUrl: './title-bar.component.html',
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
/**
|
||||
* Electron-style title bar with window controls, navigation, and server menu.
|
||||
@@ -48,19 +49,25 @@ export class TitleBarComponent {
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.minimizeWindow) api.minimizeWindow();
|
||||
|
||||
if (api?.minimizeWindow)
|
||||
api.minimizeWindow();
|
||||
}
|
||||
|
||||
/** Maximize or restore the Electron window. */
|
||||
maximize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.maximizeWindow) api.maximizeWindow();
|
||||
|
||||
if (api?.maximizeWindow)
|
||||
api.maximizeWindow();
|
||||
}
|
||||
|
||||
/** Close the Electron window. */
|
||||
close() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.closeWindow) api.closeWindow();
|
||||
|
||||
if (api?.closeWindow)
|
||||
api.closeWindow();
|
||||
}
|
||||
|
||||
/** Navigate to the login page. */
|
||||
@@ -98,9 +105,11 @@ export class TitleBarComponent {
|
||||
// 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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
(click)="navigateToServer()"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
|
||||
title="Back to {{ voiceSession()?.serverName }}"
|
||||
>
|
||||
@@ -35,6 +36,7 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
@@ -43,6 +45,7 @@
|
||||
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
@@ -51,6 +54,7 @@
|
||||
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
type="button"
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
@@ -59,6 +63,7 @@
|
||||
|
||||
<button
|
||||
(click)="disconnect()"
|
||||
type="button"
|
||||
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
title="Disconnect"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft,
|
||||
lucideArrowLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -25,12 +26,13 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft,
|
||||
}),
|
||||
lucideArrowLeft
|
||||
})
|
||||
],
|
||||
templateUrl: './floating-voice-controls.component.html'
|
||||
})
|
||||
@@ -86,8 +88,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,8 +112,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,8 +145,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
},
|
||||
isDeafened: false
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
@@ -157,6 +159,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Update user voice state in store
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
@@ -176,45 +179,55 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
/** Return the CSS classes for the compact control button based on active state. */
|
||||
getCompactButtonClass(isActive: boolean): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (isActive) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact screen-share button. */
|
||||
getCompactScreenShareClass(): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the mute toggle button. */
|
||||
getMuteButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the deafen toggle button. */
|
||||
getDeafenButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the screen-share toggle button. */
|
||||
getScreenShareButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleFullscreen()"
|
||||
type="button"
|
||||
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
@if (isFullscreen()) {
|
||||
@@ -53,6 +54,7 @@
|
||||
@if (isLocalShare()) {
|
||||
<button
|
||||
(click)="stopSharing()"
|
||||
type="button"
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop sharing"
|
||||
>
|
||||
@@ -61,6 +63,7 @@
|
||||
} @else {
|
||||
<button
|
||||
(click)="stopWatching()"
|
||||
type="button"
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop watching"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideX,
|
||||
lucideMonitor,
|
||||
lucideMonitor
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -24,10 +25,10 @@ import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideX,
|
||||
lucideMonitor,
|
||||
}),
|
||||
lucideMonitor
|
||||
})
|
||||
],
|
||||
templateUrl: './screen-share-viewer.component.html',
|
||||
templateUrl: './screen-share-viewer.component.html'
|
||||
})
|
||||
/**
|
||||
* Displays a local or remote screen-share stream in a video player.
|
||||
@@ -54,9 +55,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||
try {
|
||||
const userId = evt.detail?.userId;
|
||||
if (!userId) return;
|
||||
|
||||
if (!userId)
|
||||
return;
|
||||
|
||||
const stream = this.webrtcService.getRemoteStream(userId);
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
|
||||
|
||||
if (stream && stream.getVideoTracks().length > 0) {
|
||||
if (user) {
|
||||
this.setRemoteStream(stream, user);
|
||||
@@ -77,6 +82,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
// React to screen share stream changes
|
||||
effect(() => {
|
||||
const screenStream = this.webrtcService.screenStream();
|
||||
|
||||
if (screenStream && this.videoRef) {
|
||||
// Local share: always mute to avoid audio feedback
|
||||
this.videoRef.nativeElement.srcObject = screenStream;
|
||||
@@ -97,7 +103,8 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
|
||||
|
||||
// Only check if we're actually watching a remote stream
|
||||
if (!watchingId || !isWatchingRemote) return;
|
||||
if (!watchingId || !isWatchingRemote)
|
||||
return;
|
||||
|
||||
const users = this.onlineUsers();
|
||||
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
|
||||
@@ -111,6 +118,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
// Also check if the stream's video tracks are still available
|
||||
const stream = this.webrtcService.getRemoteStream(watchingId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
// Stream or video tracks are gone - stop watching
|
||||
this.stopWatching();
|
||||
@@ -153,6 +161,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
/** Enter fullscreen mode, requesting browser fullscreen if available. */
|
||||
enterFullscreen(): void {
|
||||
this.isFullscreen.set(true);
|
||||
|
||||
// Request browser fullscreen if available
|
||||
if (this.videoRef?.nativeElement.requestFullscreen) {
|
||||
this.videoRef.nativeElement.requestFullscreen().catch(() => {
|
||||
@@ -164,6 +173,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
/** Exit fullscreen mode. */
|
||||
exitFullscreen(): void {
|
||||
this.isFullscreen.set(false);
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
@@ -183,10 +193,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = null;
|
||||
}
|
||||
|
||||
this.activeScreenSharer.set(null);
|
||||
this.watchingUserId.set(null);
|
||||
this.hasStream.set(false);
|
||||
this.isLocalShare.set(false);
|
||||
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
}
|
||||
@@ -198,8 +210,10 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
this.activeScreenSharer.set(user);
|
||||
this.watchingUserId.set(user.id || user.oderId || null);
|
||||
this.isLocalShare.set(false);
|
||||
|
||||
if (this.videoRef) {
|
||||
const el = this.videoRef.nativeElement;
|
||||
|
||||
el.srcObject = stream;
|
||||
// For autoplay policies, try muted first, then unmute per volume setting
|
||||
el.muted = true;
|
||||
@@ -208,17 +222,19 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
// After playback starts, apply viewer volume settings
|
||||
el.volume = this.screenVolume() / 100;
|
||||
el.muted = this.screenVolume() === 0;
|
||||
}).catch(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
// If autoplay fails, keep muted to allow play, then apply volume
|
||||
try {
|
||||
el.muted = true;
|
||||
el.volume = 0;
|
||||
el.play().then(() => {
|
||||
el.volume = this.screenVolume() / 100;
|
||||
el.muted = this.screenVolume() === 0;
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
});
|
||||
try {
|
||||
el.muted = true;
|
||||
el.volume = 0;
|
||||
el.play().then(() => {
|
||||
el.volume = this.screenVolume() / 100;
|
||||
el.muted = this.screenVolume() === 0;
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {}
|
||||
});
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
@@ -228,6 +244,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
setLocalStream(stream: MediaStream, user: User): void {
|
||||
this.activeScreenSharer.set(user);
|
||||
this.isLocalShare.set(true);
|
||||
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = stream;
|
||||
// Always mute local share playback
|
||||
@@ -241,10 +258,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
onScreenVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));
|
||||
|
||||
this.screenVolume.set(val);
|
||||
|
||||
if (this.videoRef?.nativeElement) {
|
||||
// Volume applies only to remote streams; keep local share muted
|
||||
const isLocal = this.isLocalShare();
|
||||
|
||||
this.videoRef.nativeElement.volume = isLocal ? 0 : val / 100;
|
||||
this.videoRef.nativeElement.muted = isLocal ? true : val === 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
||||
|
||||
@@ -10,15 +10,12 @@ export interface PlaybackOptions {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoicePlaybackService {
|
||||
private voiceLeveling = inject(VoiceLevelingService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||
private rawRemoteStreams = new Map<string, MediaStream>();
|
||||
|
||||
constructor(
|
||||
private voiceLeveling: VoiceLevelingService,
|
||||
private webrtc: WebRTCService,
|
||||
) {}
|
||||
|
||||
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
||||
if (!options.isConnected) {
|
||||
this.pendingRemoteStreams.set(peerId, stream);
|
||||
@@ -36,6 +33,7 @@ export class VoicePlaybackService {
|
||||
|
||||
// Start playback immediately with the raw stream
|
||||
const audio = new Audio();
|
||||
|
||||
audio.srcObject = stream;
|
||||
audio.autoplay = true;
|
||||
audio.volume = options.outputVolume;
|
||||
@@ -47,10 +45,12 @@ export class VoicePlaybackService {
|
||||
if (this.voiceLeveling.enabled()) {
|
||||
this.voiceLeveling.enable(peerId, stream).then((leveledStream) => {
|
||||
const currentAudio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (currentAudio && leveledStream !== stream) {
|
||||
currentAudio.srcObject = leveledStream;
|
||||
}
|
||||
}).catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,18 +62,25 @@ export class VoicePlaybackService {
|
||||
}
|
||||
|
||||
playPendingStreams(options: PlaybackOptions): void {
|
||||
if (!options.isConnected) return;
|
||||
if (!options.isConnected)
|
||||
return;
|
||||
|
||||
this.pendingRemoteStreams.forEach((stream, peerId) => this.handleRemoteStream(peerId, stream, options));
|
||||
this.pendingRemoteStreams.clear();
|
||||
}
|
||||
|
||||
ensureAllRemoteStreamsPlaying(options: PlaybackOptions): void {
|
||||
if (!options.isConnected) return;
|
||||
if (!options.isConnected)
|
||||
return;
|
||||
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
for (const peerId of peers) {
|
||||
const stream = this.webrtc.getRemoteStream(peerId);
|
||||
|
||||
if (stream && this.hasAudio(stream)) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
if (!trackedRaw || trackedRaw !== stream) {
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
@@ -87,6 +94,7 @@ export class VoicePlaybackService {
|
||||
try {
|
||||
const leveledStream = await this.voiceLeveling.enable(peerId, rawStream);
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (audio && leveledStream !== rawStream) {
|
||||
audio.srcObject = leveledStream;
|
||||
}
|
||||
@@ -94,13 +102,16 @@ export class VoicePlaybackService {
|
||||
}
|
||||
} else {
|
||||
this.voiceLeveling.disableAll();
|
||||
|
||||
for (const [peerId, rawStream] of this.rawRemoteStreams) {
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (audio) {
|
||||
audio.srcObject = rawStream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateOutputVolume(options.outputVolume);
|
||||
this.updateDeafened(options.isDeafened);
|
||||
}
|
||||
@@ -118,9 +129,12 @@ export class VoicePlaybackService {
|
||||
}
|
||||
|
||||
applyOutputDevice(deviceId: string): void {
|
||||
if (!deviceId) return;
|
||||
if (!deviceId)
|
||||
return;
|
||||
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
const anyAudio = audio as any;
|
||||
|
||||
if (typeof anyAudio.setSinkId === 'function') {
|
||||
anyAudio.setSinkId(deviceId).catch(() => {});
|
||||
}
|
||||
@@ -143,6 +157,7 @@ export class VoicePlaybackService {
|
||||
|
||||
private removeAudioElement(peerId: string): void {
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (audio) {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="text-xs text-destructive">{{
|
||||
connectionErrorMessage() || 'Connection error'
|
||||
}}</span>
|
||||
<button (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
|
||||
<button type="button" (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors">
|
||||
<button type="button" (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors">
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button (click)="toggleMute()" [class]="getMuteButtonClass()">
|
||||
<button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()">
|
||||
@if (isMuted()) {
|
||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
@@ -49,12 +49,12 @@
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
|
||||
<button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
|
||||
<button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
@@ -64,6 +64,7 @@
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="disconnect()"
|
||||
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
computed,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones,
|
||||
lucideHeadphones
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -55,10 +54,10 @@ interface AudioDevice {
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones,
|
||||
}),
|
||||
lucideHeadphones
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-controls.component.html',
|
||||
templateUrl: './voice-controls.component.html'
|
||||
})
|
||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
@@ -98,7 +97,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
isConnected: this.isConnected(),
|
||||
outputVolume: this.outputVolume() / 100,
|
||||
isDeafened: this.isDeafened(),
|
||||
isDeafened: this.isDeafened()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,18 +114,19 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
||||
({ peerId, stream }) => {
|
||||
this.voicePlayback.handleRemoteStream(peerId, stream, this.playbackOptions());
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Listen for live voice-leveling toggle changes so we can
|
||||
// rebuild all remote Audio elements immediately (no reconnect).
|
||||
this.voiceLevelingUnsubscribe = this.voiceLeveling.onEnabledChange(
|
||||
(enabled) => this.voicePlayback.rebuildAllRemoteAudio(enabled, this.playbackOptions()),
|
||||
(enabled) => this.voicePlayback.rebuildAllRemoteAudio(enabled, this.playbackOptions())
|
||||
);
|
||||
|
||||
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
|
||||
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
||||
const options = this.playbackOptions();
|
||||
|
||||
this.voicePlayback.playPendingStreams(options);
|
||||
// Also ensure all remote streams from connected peers are playing
|
||||
// This handles the case where streams were received while voice was "connected"
|
||||
@@ -158,24 +158,27 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
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 })),
|
||||
.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 })),
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
} catch (error) {}
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// Require signaling connectivity first
|
||||
const ok = await this.webrtcService.ensureSignalingConnected();
|
||||
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@@ -188,14 +191,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
noiseSuppression: true
|
||||
}
|
||||
});
|
||||
|
||||
await this.webrtcService.setLocalStream(stream);
|
||||
|
||||
// Track local mic for voice-activity visualisation
|
||||
const userId = this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.trackLocalMic(userId, stream);
|
||||
}
|
||||
@@ -204,6 +208,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
const room = this.currentRoom();
|
||||
const roomId = this.currentUser()?.voiceState?.roomId || room?.id;
|
||||
const serverId = room?.id;
|
||||
|
||||
this.webrtcService.startVoiceHeartbeat(roomId, serverId);
|
||||
|
||||
// Broadcast voice state to other users
|
||||
@@ -216,8 +221,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
roomId,
|
||||
serverId,
|
||||
},
|
||||
serverId
|
||||
}
|
||||
});
|
||||
|
||||
// Play any pending remote streams now that we're connected
|
||||
@@ -225,7 +230,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Persist settings after successful connection
|
||||
this.saveSettings();
|
||||
} catch (error) {}
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
// Retry connection when there's a connection error
|
||||
@@ -248,8 +253,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
serverId: this.currentRoom()?.id,
|
||||
},
|
||||
serverId: this.currentRoom()?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
@@ -259,6 +264,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Untrack local mic from voice-activity visualisation
|
||||
const userId = this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
@@ -271,6 +277,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.voicePlayback.teardownAll();
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
@@ -280,9 +287,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
}),
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,8 +313,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -331,8 +338,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -344,7 +351,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
try {
|
||||
await this.webrtcService.startScreenShare(this.includeSystemAudio());
|
||||
this.isScreenSharing.set(true);
|
||||
} catch (error) {}
|
||||
} catch (_error) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,17 +365,21 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
|
||||
// Reconnect with new device if connected
|
||||
if (this.isConnected()) {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.applyOutputDevice();
|
||||
this.saveSettings();
|
||||
@@ -376,12 +387,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
@@ -391,6 +404,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
onLatencyProfileChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const profile = select.value as 'low' | 'balanced' | 'high';
|
||||
|
||||
this.latencyProfile.set(profile);
|
||||
this.webrtcService.setLatencyProfile(profile);
|
||||
this.saveSettings();
|
||||
@@ -399,6 +413,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const kbps = parseInt(input.value, 10);
|
||||
|
||||
this.audioBitrate.set(kbps);
|
||||
this.webrtcService.setAudioBitrate(kbps);
|
||||
this.saveSettings();
|
||||
@@ -406,12 +421,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
async onNoiseReductionChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.noiseReduction.set(!!input.checked);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveSettings();
|
||||
@@ -420,7 +437,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private loadSettings(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
if (!raw) return;
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const settings = JSON.parse(raw) as {
|
||||
inputDevice?: string;
|
||||
outputDevice?: string;
|
||||
@@ -431,14 +451,28 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
includeSystemAudio?: boolean;
|
||||
noiseReduction?: boolean;
|
||||
};
|
||||
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
|
||||
if (settings.outputDevice) this.selectedOutputDevice.set(settings.outputDevice);
|
||||
if (typeof settings.inputVolume === 'number') this.inputVolume.set(settings.inputVolume);
|
||||
if (typeof settings.outputVolume === 'number') this.outputVolume.set(settings.outputVolume);
|
||||
if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate);
|
||||
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile);
|
||||
|
||||
if (settings.inputDevice)
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
|
||||
if (settings.outputDevice)
|
||||
this.selectedOutputDevice.set(settings.outputDevice);
|
||||
|
||||
if (typeof settings.inputVolume === 'number')
|
||||
this.inputVolume.set(settings.inputVolume);
|
||||
|
||||
if (typeof settings.outputVolume === 'number')
|
||||
this.outputVolume.set(settings.outputVolume);
|
||||
|
||||
if (typeof settings.audioBitrate === 'number')
|
||||
this.audioBitrate.set(settings.audioBitrate);
|
||||
|
||||
if (settings.latencyProfile)
|
||||
this.latencyProfile.set(settings.latencyProfile);
|
||||
|
||||
if (typeof settings.includeSystemAudio === 'boolean')
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
|
||||
if (typeof settings.noiseReduction === 'boolean')
|
||||
this.noiseReduction.set(settings.noiseReduction);
|
||||
} catch {}
|
||||
@@ -454,8 +488,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
audioBitrate: this.audioBitrate(),
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
noiseReduction: this.noiseReduction(),
|
||||
noiseReduction: this.noiseReduction()
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
|
||||
} catch {}
|
||||
}
|
||||
@@ -474,34 +509,43 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async applyOutputDevice(): Promise<void> {
|
||||
const deviceId = this.selectedOutputDevice();
|
||||
if (!deviceId) return;
|
||||
|
||||
if (!deviceId)
|
||||
return;
|
||||
|
||||
this.voicePlayback.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
getMuteButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
|
||||
getDeafenButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
|
||||
getScreenShareButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user