Now formatted correctly with eslint

This commit is contained in:
2026-03-04 00:41:02 +01:00
parent ad0e28bf84
commit 4e95ae77c5
99 changed files with 3231 additions and 1464 deletions

View File

@@ -2,7 +2,10 @@
<div class="h-full flex flex-col bg-card">
<!-- Header -->
<div class="p-4 border-b border-border flex items-center gap-2">
<ng-icon name="lucideShield" class="w-5 h-5 text-primary" />
<ng-icon
name="lucideShield"
class="w-5 h-5 text-primary"
/>
<h2 class="font-semibold text-foreground">Admin Panel</h2>
</div>
@@ -17,7 +20,10 @@
[class.border-primary]="activeTab() === 'settings'"
[class.text-muted-foreground]="activeTab() !== 'settings'"
>
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
<ng-icon
name="lucideSettings"
class="w-4 h-4 inline mr-1"
/>
Settings
</button>
<button
@@ -29,7 +35,10 @@
[class.border-primary]="activeTab() === 'members'"
[class.text-muted-foreground]="activeTab() !== 'members'"
>
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
<ng-icon
name="lucideUsers"
class="w-4 h-4 inline mr-1"
/>
Members
</button>
<button
@@ -41,7 +50,10 @@
[class.border-primary]="activeTab() === 'bans'"
[class.text-muted-foreground]="activeTab() !== 'bans'"
>
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" />
<ng-icon
name="lucideBan"
class="w-4 h-4 inline mr-1"
/>
Bans
</button>
<button
@@ -53,7 +65,10 @@
[class.border-primary]="activeTab() === 'permissions'"
[class.text-muted-foreground]="activeTab() !== 'permissions'"
>
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" />
<ng-icon
name="lucideShield"
class="w-4 h-4 inline mr-1"
/>
Perms
</button>
</div>
@@ -67,7 +82,11 @@
<!-- Room Name -->
<div>
<label for="room-name-input" 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"
@@ -78,7 +97,11 @@
<!-- Room Description -->
<div>
<label for="room-description-input" 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"
@@ -103,16 +126,26 @@
[class.text-muted-foreground]="!isPrivate()"
>
@if (isPrivate()) {
<ng-icon name="lucideLock" class="w-4 h-4" />
<ng-icon
name="lucideLock"
class="w-4 h-4"
/>
} @else {
<ng-icon name="lucideUnlock" class="w-4 h-4" />
<ng-icon
name="lucideUnlock"
class="w-4 h-4"
/>
}
</button>
</div>
<!-- Max Users -->
<div>
<label for="max-users-input" class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
<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"
@@ -128,7 +161,10 @@
(click)="saveSettings()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Settings
</button>
@@ -140,7 +176,10 @@
(click)="confirmDeleteRoom()"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
>
<ng-icon name="lucideTrash2" class="w-4 h-4" />
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
@@ -151,13 +190,14 @@
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
@if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">
No other members online
</p>
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
} @else {
@for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
<app-user-avatar
[name]="user.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
@@ -188,7 +228,10 @@
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
<button
type="button"
@@ -196,7 +239,10 @@
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
</div>
}
@@ -210,9 +256,7 @@
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
@if (bannedUsers().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">
No banned users
</p>
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
} @else {
@for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
@@ -224,14 +268,10 @@
{{ ban.displayName || 'Unknown User' }}
</p>
@if (ban.reason) {
<p class="text-xs text-muted-foreground truncate">
Reason: {{ ban.reason }}
</p>
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
}
@if (ban.expiresAt) {
<p class="text-xs text-muted-foreground">
Expires: {{ formatExpiry(ban.expiresAt) }}
</p>
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
} @else {
<p class="text-xs text-destructive">Permanent</p>
}
@@ -241,7 +281,10 @@
(click)="unbanUser(ban)"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
>
<ng-icon name="lucideX" class="w-4 h-4" />
<ng-icon
name="lucideX"
class="w-4 h-4"
/>
</button>
</div>
}
@@ -363,7 +406,10 @@
(click)="savePermissions()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Permissions
</button>
</div>

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core';
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
@@ -35,7 +39,13 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
@Component({
selector: 'app-admin-panel',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideShield,
@@ -181,7 +191,8 @@ export class AdminPanelComponent {
formatExpiry(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' });
}
// Members tab: get all users except self
@@ -194,7 +205,9 @@ export class AdminPanelComponent {
/** Change a member's role and broadcast the update to all peers. */
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtc.broadcastMessage({
type: 'role-change',
targetUserId: user.id,

View File

@@ -1,13 +1,20 @@
<div class="h-full grid place-items-center bg-background">
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideLogIn" class="w-5 h-5 text-primary" />
<ng-icon
name="lucideLogIn"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Login</h1>
</div>
<div class="space-y-3">
<div>
<label for="login-username" 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"
@@ -16,7 +23,11 @@
/>
</div>
<div>
<label for="login-password" 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"
@@ -25,7 +36,11 @@
/>
</div>
<div>
<label for="login-server" 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"

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
import { Component, inject, signal } from '@angular/core';
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [provideIcons({ lucideLogIn })],
templateUrl: './login.component.html'
})
@@ -43,7 +51,9 @@ export class LoginComponent {
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({
this.auth.login({ username: this.username.trim(),
password: this.password,
serverId: sid }).subscribe({
next: (resp) => {
if (sid)
this.serversSvc.setActiveServer(sid);

View File

@@ -1,13 +1,20 @@
<div class="h-full grid place-items-center bg-background">
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideUserPlus" class="w-5 h-5 text-primary" />
<ng-icon
name="lucideUserPlus"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Register</h1>
</div>
<div class="space-y-3">
<div>
<label for="register-username" 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"
@@ -16,7 +23,11 @@
/>
</div>
<div>
<label for="register-display-name" 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"
@@ -25,7 +36,11 @@
/>
</div>
<div>
<label for="register-password" 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"
@@ -34,7 +49,11 @@
/>
</div>
<div>
<label for="register-server" 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"

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
import { Component, inject, signal } from '@angular/core';
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({
selector: 'app-register',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [provideIcons({ lucideUserPlus })],
templateUrl: './register.component.html'
})
@@ -44,7 +52,10 @@ export class RegisterComponent {
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({
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);

View File

@@ -2,16 +2,33 @@
<div class="flex-1"></div>
@if (user()) {
<div class="flex items-center gap-2 text-sm">
<ng-icon name="lucideUser" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideUser"
class="w-4 h-4 text-muted-foreground"
/>
<span class="text-foreground">{{ user()?.displayName }}</span>
</div>
} @else {
<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" />
<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 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" />
<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>
}

View File

@@ -4,14 +4,22 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
import {
lucideUser,
lucideLogIn,
lucideUserPlus
} from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../store/users/users.selectors';
@Component({
selector: 'app-user-bar',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
viewProviders: [
provideIcons({ lucideUser,
lucideLogIn,
lucideUserPlus })
],
templateUrl: './user-bar.component.html'
})
/**

View File

@@ -1,5 +1,17 @@
/* 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';
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, 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';
import { Store } from '@ngrx/store';
@@ -21,7 +33,11 @@ import {
} from '@ng-icons/lucide';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors';
import {
selectAllMessages,
selectMessagesLoading,
selectMessagesSyncing
} from '../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models';
@@ -36,12 +52,30 @@ import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { ChatMarkdownService } from './services/chat-markdown.service';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
const COMMON_EMOJIS = [
'👍',
'❤️',
'😂',
'😮',
'😢',
'🎉',
'🔥',
'👀'
];
@Component({
selector: 'app-chat-messages',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
ContextMenuComponent,
UserAvatarComponent,
TypingIndicatorComponent,
RemarkModule,
MermaidComponent
],
viewProviders: [
provideIcons({
lucideSend,
@@ -105,7 +139,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
);
});
/** Paginated view only the most recent `displayLimit` messages */
/** Paginated view - only the most recent `displayLimit` messages */
messages = computed(() => {
const all = this.allChannelMessages();
const limit = this.displayLimit();
@@ -391,7 +425,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.scrollIntoView({ behavior: 'smooth',
block: 'center' });
el.classList.add('bg-primary/10');
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
}
@@ -406,7 +442,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Add a reaction emoji to a message. */
addReaction(messageId: string, emoji: string): void {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
this.store.dispatch(MessagesActions.addReaction({ messageId,
emoji }));
this.showEmojiPicker.set(null);
}
@@ -423,9 +461,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
);
if (hasReacted) {
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
this.store.dispatch(MessagesActions.removeReaction({ messageId,
emoji }));
} else {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
this.store.dispatch(MessagesActions.addReaction({ messageId,
emoji }));
}
}
@@ -440,7 +480,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const currentUserId = this.currentUser()?.id;
message.reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
const existing = groups.get(reaction.emoji) || { count: 0,
hasCurrentUser: false };
groups.set(reaction.emoji, {
count: existing.count + 1,
@@ -458,7 +499,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date(this.nowRef);
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
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));
@@ -470,7 +512,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} else if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
return date.toLocaleDateString([], { month: 'short',
day: 'numeric' }) + ' ' + time;
}
}
@@ -513,6 +556,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snap);
});
this.initialScrollObserver.observe(el, {
childList: true,
subtree: true,
@@ -550,7 +594,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const el = this.messagesContainer.nativeElement;
try {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
el.scrollTo({ top: el.scrollHeight,
behavior: 'smooth' });
} catch {
// Fallback if smooth not supported
el.scrollTop = el.scrollHeight;
@@ -591,7 +636,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.stopInitialScrollWatch();
}
// Infinite scroll upwards load older messages when near the top
// Infinite scroll upwards - load older messages when near the top
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
@@ -640,7 +685,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private getSelection(): { start: number; end: number } {
const el = this.messageInputRef?.nativeElement;
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
return { start: el?.selectionStart ?? this.messageContent.length,
end: el?.selectionEnd ?? this.messageContent.length };
}
private setSelection(start: number, end: number): void {
@@ -772,7 +818,12 @@ 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'];
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let i = 0;
@@ -787,7 +838,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
if (!bps || bps <= 0)
return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
const units = [
'B/s',
'KB/s',
'MB/s',
'GB/s'
];
let speed = bps;
let i = 0;
@@ -855,7 +911,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
openImageContextMenu(event: MouseEvent, att: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
this.imageContextMenu.set({ x: event.clientX,
y: event.clientY,
attachment: att });
}
/** Close the image context menu. */
@@ -876,9 +934,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
// Convert to PNG for clipboard compatibility
const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob })
]);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
} catch (_error) {
// Failed to copy image to clipboard
}

View File

@@ -21,7 +21,9 @@ export class ChatMarkdownService {
const newText = `${before}${token}${selected}${token}${after}`;
const cursor = before.length + token.length + selected.length + token.length;
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
return { text: newText,
selectionStart: cursor,
selectionEnd: cursor };
}
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
@@ -34,7 +36,9 @@ export class ChatMarkdownService {
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
@@ -49,7 +53,9 @@ export class ChatMarkdownService {
const text = `${before}${block}${after}`;
const cursor = before.length + block.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
@@ -62,7 +68,9 @@ export class ChatMarkdownService {
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
@@ -74,7 +82,9 @@ export class ChatMarkdownService {
const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyLink(content: string, selection: SelectionRange): ComposeResult {
@@ -87,7 +97,9 @@ export class ChatMarkdownService {
const cursorStart = before.length + link.length - 1;
// Position inside the URL placeholder
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
}
applyImage(content: string, selection: SelectionRange): ComposeResult {
@@ -99,7 +111,9 @@ export class ChatMarkdownService {
const text = `${before}${img}${after}`;
const cursorStart = before.length + img.length - 1;
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
}
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
@@ -110,7 +124,9 @@ export class ChatMarkdownService {
const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
appendImageMarkdown(content: string): string {

View File

@@ -1,8 +1,19 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
import { Component, inject, signal, DestroyRef } from '@angular/core';
import {
Component,
inject,
signal,
DestroyRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { merge, interval, filter, map, tap } from 'rxjs';
import {
merge,
interval,
filter,
map,
tap
} from 'rxjs';
const TYPING_TTL = 3_000;
const PURGE_INTERVAL = 1_000;

View File

@@ -1,161 +1,202 @@
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }}
</span>
}
</div>
}
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }}
</span>
}
</div>
}
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<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">
<app-user-avatar
[name]="user.displayName"
size="sm"
/>
<span
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<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">
<app-user-avatar [name]="user.displayName" size="sm" />
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
}
@if (user.isRoomOwner) {
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
}
</div>
</div>
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
} @else if (user.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
} @else if (user.voiceState?.isConnected) {
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
}
@if (user.screenShareState?.isSharing) {
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<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"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon name="lucideVolume2" class="w-4 h-4" />
<span>Unmute</span>
} @else {
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
<span>Mute</span>
}
</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"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
<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"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
<span>Ban</span>
</button>
</div>
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">
No users online
</div>
}
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon
name="lucideShield"
class="w-3 h-3 text-primary"
/>
}
@if (user.isRoomOwner) {
<ng-icon
name="lucideCrown"
class="w-3 h-3 text-yellow-500"
/>
}
</div>
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
</p>
<div class="mb-4">
<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"
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon
name="lucideMic"
class="w-4 h-4 text-green-500 animate-pulse"
/>
</div>
} @else if (user.voiceState?.isMuted) {
<ng-icon
name="lucideMicOff"
class="w-4 h-4 text-muted-foreground"
/>
} @else if (user.voiceState?.isConnected) {
<ng-icon
name="lucideMic"
class="w-4 h-4 text-muted-foreground"
/>
}
<div>
<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"
@if (user.screenShareState?.isSharing) {
<ng-icon
name="lucideMonitor"
class="w-4 h-4 text-primary"
/>
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<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"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon
name="lucideVolume2"
class="w-4 h-4"
/>
<span>Unmute</span>
} @else {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4"
/>
<span>Mute</span>
}
</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"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
<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"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
<span>Ban</span>
</button>
</div>
</app-confirm-dialog>
}
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
}
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
>?
</p>
<div class="mb-4">
<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
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>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
</div>
</app-confirm-dialog>
}

View File

@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal, computed } from '@angular/core';
import {
Component,
inject,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
@@ -29,7 +34,13 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideMic,

View File

@@ -29,7 +29,10 @@
<!-- No Room Selected -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-muted-foreground">
<ng-icon name="lucideHash" class="w-16 h-16 mx-auto mb-4 opacity-30" />
<ng-icon
name="lucideHash"
class="w-16 h-16 mx-auto mb-4 opacity-30"
/>
<h2 class="text-xl font-medium mb-2">No room selected</h2>
<p class="text-sm">Select or create a room to start chatting</p>
</div>

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core';
import {
Component,
inject,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';

View File

@@ -13,7 +13,10 @@
[class.text-muted-foreground]="activeTab() !== 'channels'"
[class.hover:text-foreground]="activeTab() !== 'channels'"
>
<ng-icon name="lucideHash" class="w-4 h-4" />
<ng-icon
name="lucideHash"
class="w-4 h-4"
/>
<span>Channels</span>
</button>
<button
@@ -25,11 +28,12 @@
[class.text-muted-foreground]="activeTab() !== 'users'"
[class.hover:text-foreground]="activeTab() !== 'users'"
>
<ng-icon name="lucideUsers" class="w-4 h-4" />
<ng-icon
name="lucideUsers"
class="w-4 h-4"
/>
<span>Users</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{
onlineUsers().length
}}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
</button>
</div>
</div>
@@ -40,16 +44,17 @@
<!-- Text Channels -->
<div class="p-3">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
Text Channels
</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('text')"
class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Text Channel"
>
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
<ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
</button>
}
</div>
@@ -89,16 +94,17 @@
<!-- Voice Channels -->
<div class="p-3 pt-0">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
Voice Channels
</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('voice')"
class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Voice Channel"
>
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
<ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
</button>
}
</div>
@@ -116,7 +122,10 @@
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideMic"
class="w-4 h-4 text-muted-foreground"
/>
@if (renamingChannelId() === ch.id) {
<input
#renameInput
@@ -155,17 +164,13 @@
: 'ring-2 ring-green-500/40'
"
/>
<span class="text-sm text-foreground/80 truncate flex-1">{{
u.displayName
}}</span>
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
<!-- Ping latency indicator -->
@if (u.id !== currentUser()?.id) {
<span
class="w-2 h-2 rounded-full shrink-0"
[class]="getPingColorClass(u)"
[title]="
getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'
"
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
></span>
}
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
@@ -177,7 +182,10 @@
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideMicOff"
class="w-4 h-4 text-muted-foreground"
/>
}
</div>
}
@@ -196,9 +204,7 @@
<!-- Current User (You) -->
@if (currentUser()) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
You
</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
<div class="relative">
<app-user-avatar
@@ -206,27 +212,26 @@
[avatarUrl]="currentUser()?.avatarUrl"
size="sm"
/>
<span
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
></span>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
<div class="flex items-center gap-2">
@if (currentUser()?.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
<ng-icon
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice
</p>
}
@if (
currentUser()?.screenShareState?.isSharing ||
(currentUser()?.id && isUserSharing(currentUser()!.id))
) {
<span
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse"
>
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
<span class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse">
<ng-icon
name="lucideMonitor"
class="w-2.5 h-2.5"
/>
LIVE
</span>
}
@@ -239,9 +244,7 @@
<!-- Other Online Users -->
@if (onlineUsersFiltered().length > 0) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
Online — {{ onlineUsersFiltered().length }}
</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineUsersFiltered().length }}</h4>
<div class="space-y-1">
@for (user of onlineUsersFiltered(); track user.id) {
<div
@@ -254,34 +257,26 @@
[avatarUrl]="user.avatarUrl"
size="sm"
/>
<span
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
></span>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span
class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium"
>Owner</span
>
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
} @else if (user.role === 'admin') {
<span
class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium"
>Admin</span
>
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
} @else if (user.role === 'moderator') {
<span
class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium"
>Mod</span
>
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
<div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
<ng-icon
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice
</p>
}
@@ -290,7 +285,10 @@
(click)="viewStream(user.id); $event.stopPropagation()"
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
>
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
<ng-icon
name="lucideMonitor"
class="w-2.5 h-2.5"
/>
LIVE
</button>
}
@@ -327,42 +325,81 @@
(closed)="closeChannelMenu()"
[width]="'w-44'"
>
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
<button
(click)="resyncMessages()"
class="context-menu-item"
>
Resync Messages
</button>
@if (canManageChannels()) {
<div class="context-menu-divider"></div>
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
<button
(click)="startRename()"
class="context-menu-item"
>
Rename Channel
</button>
<button
(click)="deleteChannel()"
class="context-menu-item-danger"
>
Delete Channel
</button>
}
</app-context-menu>
}
<!-- User context menu (kick / role management) -->
@if (showUserMenu()) {
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
<app-context-menu
[x]="userMenuX()"
[y]="userMenuY()"
(closed)="closeUserMenu()"
>
@if (isAdmin()) {
@if (contextMenuUser()?.role === 'member') {
<button (click)="changeUserRole('moderator')" class="context-menu-item">
<button
(click)="changeUserRole('moderator')"
class="context-menu-item"
>
Promote to Moderator
</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">
<button
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin
</button>
}
@if (contextMenuUser()?.role === 'moderator') {
<button (click)="changeUserRole('admin')" class="context-menu-item">
<button
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin
</button>
<button (click)="changeUserRole('member')" class="context-menu-item">
<button
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member
</button>
}
@if (contextMenuUser()?.role === 'admin') {
<button (click)="changeUserRole('member')" class="context-menu-item">
<button
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member
</button>
}
<div class="context-menu-divider"></div>
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>
<button
(click)="kickUserAction()"
class="context-menu-item-danger"
>
Kick User
</button>
} @else {
<div class="context-menu-empty">No actions available</div>
}

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal } from '@angular/core';
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
@@ -32,8 +36,17 @@ import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
import { Channel, User } from '../../../core/models';
import {
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
} from '../../../shared';
import {
Channel,
ChatEvent,
Room,
User
} from '../../../core/models';
import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users';
@@ -41,15 +54,7 @@ type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
VoiceControlsComponent,
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
],
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
viewProviders: [
provideIcons({
lucideMessageSquare,
@@ -110,9 +115,7 @@ export class RoomsSidePanelComponent {
const currentId = current?.id;
const currentOderId = current?.oderId;
return this.onlineUsers().filter(
(user) => user.id !== currentId && user.oderId !== currentOderId
);
return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
}
/** Check whether the current user has permission to manage channels. */
@@ -218,12 +221,14 @@ export class RoomsSidePanelComponent {
const peers = this.webrtc.getConnectedPeers();
if (peers.length === 0) {
// No connected peers sync will time out
// No connected peers - sync will time out
}
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
peers.forEach((pid) => {
try {
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
this.webrtc.sendToPeer(pid, inventoryRequest);
} catch (_error) {
// Failed to send inventory request to this peer
}
@@ -327,6 +332,9 @@ export class RoomsSidePanelComponent {
return;
}
if (!room)
return;
const current = this.currentUser();
// Check if already connected to voice in a DIFFERENT server - must disconnect first
@@ -334,7 +342,7 @@ export class RoomsSidePanelComponent {
// clear it so the user can join.
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.webrtc.isVoiceConnected()) {
// Stale state clear it so the user can proceed
// Stale state - clear it so the user can proceed
if (current.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
@@ -356,67 +364,76 @@ export class RoomsSidePanelComponent {
}
// If switching channels within the same server, just update the room
const isSwitchingChannels =
current?.voiceState?.isConnected &&
current.voiceState.serverId === room?.id &&
current.voiceState.roomId !== roomId;
const isSwitchingChannels = 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();
enableVoicePromise
.then(() => {
if (current?.id && room) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: true,
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId: roomId,
serverId: room.id
}
})
);
}
// Start voice heartbeat to broadcast presence every 5 seconds
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId: roomId,
serverId: room?.id
}
});
// Update voice session for floating controls
if (room) {
// 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,
roomId: roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
}
})
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch((_error) => {
// Failed to join voice room
});
}
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
this.updateVoiceStateStore(roomId, room, current);
this.startVoiceHeartbeat(roomId, room);
this.broadcastVoiceConnected(roomId, room, current);
this.startVoiceSession(roomId, room);
}
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
if (!current?.id)
return;
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: true,
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
}
})
);
}
private startVoiceHeartbeat(roomId: string, room: Room): void {
this.webrtc.startVoiceHeartbeat(roomId, room.id);
}
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
}
});
}
private startVoiceSession(roomId: string, room: Room): void {
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
}
/** Leave a voice channel and broadcast the disconnect state. */
leaveVoice(roomId: string) {
const current = this.currentUser();
@@ -470,12 +487,8 @@ export class RoomsSidePanelComponent {
const users = this.onlineUsers();
const room = this.currentRoom();
return users.filter(
(user) =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
).length;
return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id)
.length;
}
/** Dispatch a viewer:focus event to display a remote user's screen share. */
@@ -500,9 +513,7 @@ export class RoomsSidePanelComponent {
return this.webrtc.isScreenSharing();
}
const user = this.onlineUsers().find(
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
);
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
if (user?.screenShareState?.isSharing === false) {
return false;
@@ -518,10 +529,7 @@ export class RoomsSidePanelComponent {
const room = this.currentRoom();
return this.onlineUsers().filter(
(user) =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
);
}
@@ -530,11 +538,7 @@ export class RoomsSidePanelComponent {
const me = this.currentUser();
const room = this.currentRoom();
return !!(
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id
);
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
}
/** Check whether voice is enabled by the current room's permissions. */
@@ -558,8 +562,8 @@ export class RoomsSidePanelComponent {
/**
* Return a Tailwind `bg-*` class representing the latency quality.
* - green : < 100 ms
* - yellow : 100199 ms
* - orange : 200349 ms
* - yellow : 100-199 ms
* - orange : 200-349 ms
* - red : >= 350 ms
* - gray : no data yet
*/

View File

@@ -40,7 +40,10 @@
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
title="Settings"
>
<ng-icon name="lucideSettings" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideSettings"
class="w-5 h-5 text-muted-foreground"
/>
</button>
</div>
</div>
@@ -52,7 +55,10 @@
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" />
<ng-icon
name="lucidePlus"
class="w-4 h-4"
/>
Create New Server
</button>
</div>
@@ -65,7 +71,10 @@
</div>
} @else if (searchResults().length === 0) {
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ng-icon name="lucideSearch" class="w-12 h-12 mb-4 opacity-50" />
<ng-icon
name="lucideSearch"
class="w-12 h-12 mb-4 opacity-50"
/>
<p class="text-lg">No servers found</p>
<p class="text-sm">Try a different search or create your own</p>
</div>
@@ -84,9 +93,15 @@
{{ server.name }}
</h3>
@if (server.isPrivate) {
<ng-icon name="lucideLock" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
} @else {
<ng-icon name="lucideGlobe" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideGlobe"
class="w-4 h-4 text-muted-foreground"
/>
}
</div>
@if (server.description) {
@@ -101,13 +116,14 @@
}
</div>
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
<ng-icon name="lucideUsers" class="w-4 h-4" />
<ng-icon
name="lucideUsers"
class="w-4 h-4"
/>
<span>{{ server.userCount }}/{{ server.maxUsers }}</span>
</div>
</div>
<div class="mt-2 text-xs text-muted-foreground">
Hosted by {{ server.hostName }}
</div>
<div class="mt-2 text-xs text-muted-foreground">Hosted by {{ server.hostName }}</div>
</button>
}
</div>
@@ -145,7 +161,11 @@
<div class="space-y-4">
<div>
<label for="create-server-name" 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"
@@ -156,7 +176,11 @@
</div>
<div>
<label for="create-server-description" 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?"
@@ -167,7 +191,11 @@
</div>
<div>
<label for="create-server-topic" 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"
@@ -184,12 +212,20 @@
id="private"
class="w-4 h-4 rounded border-border bg-secondary"
/>
<label for="private" class="text-sm text-foreground">Private server</label>
<label
for="private"
class="text-sm text-foreground"
>Private server</label
>
</div>
@if (newServerPrivate()) {
<div>
<label for="create-server-password" 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"

View File

@@ -1,10 +1,19 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, OnInit, signal } from '@angular/core';
import {
Component,
inject,
OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSearch,
@@ -147,16 +156,20 @@ export class ServerSearchComponent implements OnInit {
/** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void {
this.joinServer({
this.joinServer(this.toServerInfo(room));
}
private toServerInfo(room: Room): ServerInfo {
return {
id: room.id,
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
userCount: room.userCount,
maxUsers: room.maxUsers || 50,
maxUsers: room.maxUsers ?? 50,
isPrivate: !!room.password,
createdAt: room.createdAt
} as any);
};
}
private resetCreateForm(): void {

View File

@@ -1,24 +1,33 @@
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
<!-- Create button -->
<button
type="button"
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
title="Create Server"
(click)="createServer()"
>
<ng-icon name="lucidePlus" class="w-5 h-5" />
<ng-icon
name="lucidePlus"
class="w-5 h-5"
/>
</button>
<!-- Saved servers icons -->
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
@for (room of savedRooms(); track room.id) {
<button
type="button"
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
[title]="room.name"
(click)="joinSavedRoom(room)"
(contextmenu)="openContextMenu($event, room)"
>
@if (room.icon) {
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
<img
[ngSrc]="room.icon"
[alt]="room.name"
class="w-full h-full object-cover"
/>
} @else {
<div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
@@ -31,11 +40,28 @@
<!-- Context menu -->
@if (showMenu()) {
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'">
<app-context-menu
[x]="menuX()"
[y]="menuY()"
(closed)="closeMenu()"
[width]="'w-44'"
>
@if (isCurrentContextRoom()) {
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button>
<button
type="button"
(click)="leaveServer()"
class="context-menu-item"
>
Leave Server
</button>
}
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button>
<button
type="button"
(click)="openForgetConfirm()"
class="context-menu-item"
>
Forget Server
</button>
</app-context-menu>
}
@@ -48,6 +74,8 @@
(cancelled)="cancelForget()"
[widthClass]="'w-[280px]'"
>
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p>
<p>
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
</p>
</app-confirm-dialog>
}

View File

@@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -16,7 +20,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
@Component({
selector: 'app-servers-rail',
standalone: true,
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent, NgOptimizedImage],
viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html'
})
@@ -92,14 +96,16 @@ export class ServersRailComponent {
this.store.dispatch(RoomsActions.viewServer({ room }));
} else {
// First time joining this server
this.store.dispatch(RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown'
}
}));
this.store.dispatch(
RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown'
}
})
);
}
}
@@ -108,7 +114,7 @@ export class ServersRailComponent {
evt.preventDefault();
this.contextRoom.set(room);
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
this.menuX.set(Math.max((evt.clientX + 8), 72));
this.menuX.set(Math.max(evt.clientX + 8, 72));
this.menuY.set(evt.clientY);
this.showMenu.set(true);
}
@@ -161,5 +167,4 @@ export class ServersRailComponent {
cancelForget(): void {
this.showConfirm.set(false);
}
}

View File

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

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input } from '@angular/core';
import {
Component,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
@@ -40,7 +44,8 @@ export class BansSettingsComponent {
return (
date.toLocaleDateString() +
' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' })
);
}
}

View File

@@ -5,24 +5,21 @@
} @else {
@for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
<app-user-avatar
[name]="user.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">
{{ user.displayName }}
</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded"
>Owner</span
>
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded"
>Admin</span
>
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded"
>Mod</span
>
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
@@ -42,14 +39,20 @@
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
<button
(click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
</div>
}
@@ -58,7 +61,5 @@
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
Select a server from the sidebar to manage
</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
}

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input } from '@angular/core';
import {
Component,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -15,7 +19,12 @@ import { UserAvatarComponent } from '../../../../shared';
@Component({
selector: 'app-members-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideUserX,
@@ -43,7 +52,9 @@ export class MembersSettingsComponent {
}
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtcService.broadcastMessage({
type: 'role-change',
targetUserId: user.id,

View File

@@ -3,7 +3,10 @@
<section>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
</div>
<button
@@ -11,14 +14,16 @@
[disabled]="isTesting()"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon name="lucideRefreshCw" class="w-3.5 h-3.5" [class.animate-spin]="isTesting()" />
<ng-icon
name="lucideRefreshCw"
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
</div>
<p class="text-xs text-muted-foreground mb-3">
Server directories to search for rooms. The active server is used for creating new rooms.
</p>
<p class="text-xs text-muted-foreground mb-3">Server directories to search for rooms. The active server is used for creating new rooms.</p>
<!-- Server List -->
<div class="space-y-2 mb-3">
@@ -41,10 +46,7 @@
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span
class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full"
>Active</span
>
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
}
</div>
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
@@ -105,7 +107,10 @@
[disabled]="!newServerName || !newServerUrl"
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
>
<ng-icon name="lucidePlus" class="w-4 h-4" />
<ng-icon
name="lucidePlus"
class="w-4 h-4"
/>
</button>
</div>
@if (addError()) {
@@ -117,7 +122,10 @@
<!-- Connection Settings -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
</div>
<div class="space-y-3">

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core';
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -18,7 +22,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
@Component({
selector: 'app-network-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideGlobe,
@@ -65,6 +73,7 @@ export class NetworkSettingsComponent {
name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, '')
});
this.newServerName = '';
this.newServerUrl = '';
const servers = this.servers();
@@ -108,6 +117,7 @@ export class NetworkSettingsComponent {
searchAllServers: this.searchAllServers
})
);
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
}

View File

@@ -1,9 +1,7 @@
@if (server()) {
<div class="space-y-4 max-w-xl">
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-1">
You are viewing this server's permissions. Only the server owner can make changes.
</p>
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
}
<div class="space-y-2.5">
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
@@ -118,13 +116,14 @@
[class.bg-green-600]="saveSuccess() === 'permissions'"
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
</button>
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
Select a server from the sidebar to manage
</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
}

View File

@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input, signal } from '@angular/core';
import {
Component,
inject,
input,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -12,7 +17,11 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@Component({
selector: 'app-permissions-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideCheck
@@ -75,6 +84,7 @@ export class PermissionsSettingsComponent {
}
})
);
this.showSaveSuccess('permissions');
}

View File

@@ -4,13 +4,16 @@
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3">
You are viewing this server's settings as a non-admin. Only the server owner can make
changes.
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
</p>
}
<div class="space-y-4">
<div>
<label for="room-name" class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label>
<label
for="room-name"
class="block text-xs font-medium text-muted-foreground mb-1"
>Room Name</label
>
<input
type="text"
[(ngModel)]="roomName"
@@ -22,7 +25,11 @@
/>
</div>
<div>
<label for="room-description" 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()"
@@ -49,9 +56,15 @@
[class.text-muted-foreground]="!isPrivate()"
>
@if (isPrivate()) {
<ng-icon name="lucideLock" class="w-4 h-4" />
<ng-icon
name="lucideLock"
class="w-4 h-4"
/>
} @else {
<ng-icon name="lucideUnlock" class="w-4 h-4" />
<ng-icon
name="lucideUnlock"
class="w-4 h-4"
/>
}
</button>
</div>
@@ -65,7 +78,10 @@
</div>
}
<div>
<label for="room-max-users" class="block text-xs font-medium text-muted-foreground mb-1">
<label
for="room-max-users"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Max Users (0 = unlimited)
</label>
<input
@@ -90,7 +106,10 @@
[class.bg-green-600]="saveSuccess() === 'server'"
[class.hover:bg-green-600]="saveSuccess() === 'server'"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button>
@@ -102,7 +121,10 @@
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" />
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
@@ -123,7 +145,5 @@
</app-confirm-dialog>
}
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
Select a server from the sidebar to manage
</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
}

View File

@@ -1,10 +1,21 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input, signal, computed } from '@angular/core';
import {
Component,
inject,
input,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideCheck, lucideTrash2, lucideLock, lucideUnlock } from '@ng-icons/lucide';
import {
lucideCheck,
lucideTrash2,
lucideLock,
lucideUnlock
} from '@ng-icons/lucide';
import { Room } from '../../../../core/models';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@@ -14,7 +25,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
@Component({
selector: 'app-server-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideCheck,
@@ -78,6 +94,7 @@ export class ServerSettingsComponent {
}
})
);
this.showSaveSuccess('server');
}

View File

@@ -35,11 +35,7 @@
<div class="flex-1 overflow-y-auto py-2">
<!-- Global section -->
<p
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
>
General
</p>
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">General</p>
@for (page of globalPages; track page.id) {
<button
(click)="navigate(page.id)"
@@ -51,7 +47,10 @@
[class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id"
>
<ng-icon [name]="page.icon" class="w-4 h-4" />
<ng-icon
[name]="page.icon"
class="w-4 h-4"
/>
{{ page.label }}
</button>
}
@@ -59,11 +58,7 @@
<!-- Server section -->
@if (savedRooms().length > 0) {
<div class="mt-3 pt-3 border-t border-border">
<p
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
>
Server
</p>
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
<!-- Server selector -->
<div class="px-3 pb-2">
@@ -91,7 +86,10 @@
[class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id"
>
<ng-icon [name]="page.icon" class="w-4 h-4" />
<ng-icon
[name]="page.icon"
class="w-4 h-4"
/>
{{ page.label }}
</button>
}
@@ -104,9 +102,7 @@
<!-- Content -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"
>
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h3 class="text-lg font-semibold text-foreground">
@switch (activePage()) {
@case ('network') {
@@ -134,7 +130,10 @@
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" />
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
</div>

View File

@@ -24,9 +24,7 @@ import {
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import {
selectCurrentUser
} from '../../../store/users/users.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room } from '../../../core/models';
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
@@ -80,14 +78,25 @@ export class SettingsModalComponent {
// --- 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: 'network',
label: 'Network',
icon: 'lucideGlobe' }, { id: 'voice',
label: 'Voice & Audio',
icon: 'lucideAudioLines' }
];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
{ id: 'server',
label: 'Server',
icon: 'lucideSettings' },
{ id: 'members',
label: 'Members',
icon: 'lucideUsers' },
{ id: 'bans',
label: 'Bans',
icon: 'lucideBan' },
{ id: 'permissions',
label: 'Permissions',
icon: 'lucideShield' }
];
// ===== SERVER SELECTOR =====

View File

@@ -2,12 +2,19 @@
<!-- Devices -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideMic" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideMic"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
</div>
<div class="space-y-3">
<div>
<label for="input-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label>
<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"
@@ -24,7 +31,11 @@
</select>
</div>
<div>
<label for="output-device-select" 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"
@@ -46,12 +57,18 @@
<!-- Volume -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideHeadphones" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideHeadphones"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
</div>
<div class="space-y-3">
<div>
<label for="input-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
<label
for="input-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Input Volume: {{ inputVolume() }}%
</label>
<input
@@ -65,7 +82,10 @@
/>
</div>
<div>
<label for="output-volume-slider" 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
@@ -79,7 +99,10 @@
/>
</div>
<div>
<label for="notification-volume-slider" 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">
@@ -102,9 +125,7 @@
Test
</button>
</div>
<p class="text-[10px] text-muted-foreground/60 mt-1">
Controls join, leave &amp; notification sounds
</p>
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave &amp; notification sounds</p>
</div>
</div>
</section>
@@ -112,24 +133,49 @@
<!-- Quality & Processing -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
</div>
<div class="space-y-3">
<div>
<label for="latency-profile-select" class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label>
<label
for="latency-profile-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Latency Profile</label
>
<select
(change)="onLatencyProfileChange($event)"
id="latency-profile-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option>
<option value="balanced" [selected]="latencyProfile() === 'balanced'">Balanced</option>
<option value="high" [selected]="latencyProfile() === 'high'">High (quality)</option>
<option
value="low"
[selected]="latencyProfile() === 'low'"
>
Low (fast)
</option>
<option
value="balanced"
[selected]="latencyProfile() === 'balanced'"
>
Balanced
</option>
<option
value="high"
[selected]="latencyProfile() === 'high'"
>
High (quality)
</option>
</select>
</div>
<div>
<label for="audio-bitrate-slider" class="block text-xs font-medium text-muted-foreground mb-1">
<label
for="audio-bitrate-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Audio Bitrate: {{ audioBitrate() }} kbps
</label>
<input
@@ -187,7 +233,10 @@
<!-- Voice Leveling -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideActivity" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideActivity"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Voice Leveling</h4>
</div>
<div class="space-y-3">
@@ -212,12 +261,15 @@
</label>
</div>
<!-- Advanced controls visible only when enabled -->
<!-- Advanced controls - visible only when enabled -->
@if (voiceLeveling.enabled()) {
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
<!-- Target Loudness -->
<div class="pl-3">
<label for="target-loudness-slider" 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
@@ -238,19 +290,32 @@
<!-- AGC Strength -->
<div class="pl-3">
<label for="agc-strength-select" 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'">
<option
value="low"
[selected]="voiceLeveling.strength() === 'low'"
>
Low (gentle)
</option>
<option value="medium" [selected]="voiceLeveling.strength() === 'medium'">
<option
value="medium"
[selected]="voiceLeveling.strength() === 'medium'"
>
Medium
</option>
<option value="high" [selected]="voiceLeveling.strength() === 'high'">
<option
value="high"
[selected]="voiceLeveling.strength() === 'high'"
>
High (aggressive)
</option>
</select>
@@ -258,7 +323,10 @@
<!-- Max Gain Boost -->
<div class="pl-3">
<label for="max-gain-slider" 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
@@ -279,7 +347,10 @@
<!-- Response Speed -->
<div class="pl-3">
<label for="response-speed-select" 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
@@ -287,11 +358,22 @@
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'">
<option
value="slow"
[selected]="voiceLeveling.speed() === 'slow'"
>
Slow (natural)
</option>
<option value="medium" [selected]="voiceLeveling.speed() === 'medium'">Medium</option>
<option value="fast" [selected]="voiceLeveling.speed() === 'fast'">
<option
value="medium"
[selected]="voiceLeveling.speed() === 'medium'"
>
Medium
</option>
<option
value="fast"
[selected]="voiceLeveling.speed() === 'fast'"
>
Fast (aggressive)
</option>
</select>

View File

@@ -1,16 +1,22 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core';
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMic, lucideHeadphones, lucideAudioLines, lucideActivity } from '@ng-icons/lucide';
import {
lucideMic,
lucideHeadphones,
lucideAudioLines,
lucideActivity
} from '@ng-icons/lucide';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
import {
NotificationAudioService,
AppSound
} from '../../../../core/services/notification-audio.service';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
interface AudioDevice {
@@ -21,7 +27,11 @@ interface AudioDevice {
@Component({
selector: 'app-voice-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideMic,
@@ -63,12 +73,15 @@ export class VoiceSettingsComponent {
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 {}
}

View File

@@ -5,9 +5,15 @@
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Go back"
>
<ng-icon name="lucideArrowLeft" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideArrowLeft"
class="w-5 h-5 text-muted-foreground"
/>
</button>
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" />
<ng-icon
name="lucideSettings"
class="w-6 h-6 text-primary"
/>
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
</div>
@@ -15,7 +21,10 @@
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
</div>
<button
@@ -23,14 +32,18 @@
[disabled]="isTesting()"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon name="lucideRefreshCw" class="w-4 h-4" [class.animate-spin]="isTesting()" />
<ng-icon
name="lucideRefreshCw"
class="w-4 h-4"
[class.animate-spin]="isTesting()"
/>
Test All
</button>
</div>
<p class="text-sm text-muted-foreground mb-4">
Add multiple server directories to search for rooms across different networks. The active
server will be used for creating and registering new rooms.
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
rooms.
</p>
<!-- Server List -->
@@ -58,9 +71,7 @@
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full"
>Active</span
>
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
}
</div>
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
@@ -123,7 +134,10 @@
[disabled]="!newServerName || !newServerUrl"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
>
<ng-icon name="lucidePlus" class="w-5 h-5" />
<ng-icon
name="lucidePlus"
class="w-5 h-5"
/>
</button>
</div>
@if (addError()) {
@@ -135,7 +149,10 @@
<!-- Connection Settings -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
</div>
@@ -143,9 +160,7 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Auto-reconnect</p>
<p class="text-sm text-muted-foreground">
Automatically reconnect when connection is lost
</p>
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -163,9 +178,7 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Search all servers</p>
<p class="text-sm text-muted-foreground">
Search across all configured server directories
</p>
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -185,7 +198,10 @@
<!-- Voice Settings -->
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
</div>
@@ -195,9 +211,7 @@
<div class="flex items-center justify-between mb-2">
<div>
<p class="font-medium text-foreground">Notification volume</p>
<p class="text-sm text-muted-foreground">
Volume for join, leave, and notification sounds
</p>
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
</div>
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
@@ -226,9 +240,7 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Noise reduction</p>
<p class="text-sm text-muted-foreground">
Use RNNoise to suppress background noise from your microphone
</p>
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input

View File

@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal, OnInit } from '@angular/core';
import {
Component,
inject,
signal,
OnInit
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
@@ -25,7 +30,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideServer,
@@ -142,6 +151,7 @@ export class SettingsComponent implements OnInit {
searchAllServers: this.searchAllServers
})
);
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
@@ -190,8 +200,10 @@ export class SettingsComponent implements OnInit {
localStorage.setItem(
STORAGE_KEY_VOICE_SETTINGS,
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction })
JSON.stringify({ ...existing,
noiseReduction: this.noiseReduction })
);
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
}
}

View File

@@ -1,23 +1,45 @@
<div
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
style="-webkit-app-region: drag;"
style="-webkit-app-region: drag"
>
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
<div
class="flex items-center gap-2 min-w-0 relative"
style="-webkit-app-region: no-drag"
>
@if (inRoom()) {
<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
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>
}
@if (inRoom()) {
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideHash"
class="w-5 h-5 text-muted-foreground"
/>
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
@if (roomDescription()) {
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
{{ roomDescription() }}
</span>
}
<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
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()) {
@@ -48,7 +70,10 @@
</div>
}
</div>
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
<div
class="flex items-center gap-2"
style="-webkit-app-region: no-drag"
>
@if (!isAuthed()) {
<button
type="button"
@@ -60,14 +85,38 @@
</button>
}
@if (isElectron()) {
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
<ng-icon name="lucideMinus" class="w-4 h-4" />
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
title="Minimize"
(click)="minimize()"
>
<ng-icon
name="lucideMinus"
class="w-4 h-4"
/>
</button>
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
<ng-icon name="lucideSquare" class="w-4 h-4" />
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
title="Maximize"
(click)="maximize()"
>
<ng-icon
name="lucideSquare"
class="w-4 h-4"
/>
</button>
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
title="Close"
(click)="close()"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-destructive"
/>
</button>
}
</div>
@@ -82,6 +131,6 @@
tabindex="0"
role="button"
aria-label="Close menu overlay"
style="-webkit-app-region: no-drag;"
style="-webkit-app-region: no-drag"
></div>
}

View File

@@ -1,9 +1,21 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, computed, signal } from '@angular/core';
import {
Component,
inject,
computed,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide';
import {
lucideMinus,
lucideSquare,
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu
} from '@ng-icons/lucide';
import { Router } from '@angular/router';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
@@ -17,7 +29,14 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
selector: 'app-title-bar',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
viewProviders: [
provideIcons({ lucideMinus,
lucideSquare,
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu })
],
templateUrl: './title-bar.component.html'
})
/**
@@ -102,7 +121,7 @@ export class TitleBarComponent {
/** Log out the current user, disconnect from signaling, and navigate to login. */
logout() {
this._showMenu.set(false);
// Disconnect from signaling server this broadcasts "user_left" to all
// 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();

View File

@@ -9,7 +9,10 @@
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 }}"
>
<ng-icon name="lucideArrowLeft" class="w-3.5 h-3.5" />
<ng-icon
name="lucideArrowLeft"
class="w-3.5 h-3.5"
/>
@if (voiceSession()?.serverIcon) {
<img
[src]="voiceSession()?.serverIcon"
@@ -40,7 +43,10 @@
[class]="getCompactButtonClass(isMuted())"
title="Toggle Mute"
>
<ng-icon [name]="isMuted() ? 'lucideMicOff' : 'lucideMic'" class="w-4 h-4" />
<ng-icon
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
class="w-4 h-4"
/>
</button>
<button
@@ -49,7 +55,10 @@
[class]="getCompactButtonClass(isDeafened())"
title="Toggle Deafen"
>
<ng-icon name="lucideHeadphones" class="w-4 h-4" />
<ng-icon
name="lucideHeadphones"
class="w-4 h-4"
/>
</button>
<button
@@ -58,7 +67,10 @@
[class]="getCompactScreenShareClass()"
title="Toggle Screen Share"
>
<ng-icon [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" class="w-4 h-4" />
<ng-icon
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="w-4 h-4"
/>
</button>
<button
@@ -67,7 +79,10 @@
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"
>
<ng-icon name="lucidePhoneOff" class="w-4 h-4" />
<ng-icon
name="lucidePhoneOff"
class="w-4 h-4"
/>
</button>
</div>
</div>

View File

@@ -1,5 +1,12 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
import {
Component,
inject,
signal,
computed,
OnInit,
OnDestroy
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -163,7 +170,11 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
if (user?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
voiceState: { isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined }
}));
}

View File

@@ -18,11 +18,12 @@
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-white">
<ng-icon name="lucideMonitor" class="w-4 h-4" />
<ng-icon
name="lucideMonitor"
class="w-4 h-4"
/>
@if (activeScreenSharer()) {
<span class="text-sm font-medium">
{{ activeScreenSharer()?.displayName }} is sharing their screen
</span>
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
} @else {
<span class="text-sm font-medium">Someone is sharing their screen</span>
}
@@ -46,9 +47,15 @@
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
>
@if (isFullscreen()) {
<ng-icon name="lucideMinimize" class="w-4 h-4 text-white" />
<ng-icon
name="lucideMinimize"
class="w-4 h-4 text-white"
/>
} @else {
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
<ng-icon
name="lucideMaximize"
class="w-4 h-4 text-white"
/>
}
</button>
@if (isLocalShare()) {
@@ -58,7 +65,10 @@
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop sharing"
>
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
<ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button>
} @else {
<button
@@ -67,7 +77,10 @@
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop watching"
>
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
<ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button>
}
</div>
@@ -78,7 +91,10 @@
@if (!hasStream()) {
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
<div class="text-center text-muted-foreground">
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
<ng-icon
name="lucideMonitor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
/>
<p>Waiting for screen share...</p>
</div>
</div>

View File

@@ -1,5 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core';
import {
Component,
inject,
signal,
ElementRef,
ViewChild,
OnDestroy,
effect
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -235,6 +243,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
.catch(() => {});
} catch {}
});
this.hasStream.set(true);
}
}

View File

@@ -146,6 +146,7 @@ export class VoicePlaybackService {
audio.srcObject = null;
audio.remove();
});
this.remoteAudioElements.clear();
this.rawRemoteStreams.clear();
this.pendingRemoteStreams.clear();

View File

@@ -1,14 +1,14 @@
<div class="bg-card border-border p-4">
<!-- Connection Error Banner -->
@if (showConnectionError()) {
<div
class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2"
>
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
<span class="text-xs text-destructive">{{
connectionErrorMessage() || 'Connection error'
}}</span>
<button type="button" (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
<button
type="button"
(click)="retryConnection()"
class="ml-auto text-xs text-destructive hover:underline"
>
Retry
</button>
</div>
@@ -16,7 +16,10 @@
<!-- User Info -->
<div class="flex items-center gap-3 mb-4">
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" />
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm text-foreground truncate">
{{ currentUser()?.displayName || 'Unknown' }}
@@ -31,8 +34,15 @@
}
</p>
</div>
<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
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,25 +50,52 @@
<div class="flex items-center justify-center gap-2">
@if (isConnected()) {
<!-- Mute Toggle -->
<button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()">
<button
type="button"
(click)="toggleMute()"
[class]="getMuteButtonClass()"
>
@if (isMuted()) {
<ng-icon name="lucideMicOff" class="w-5 h-5" />
<ng-icon
name="lucideMicOff"
class="w-5 h-5"
/>
} @else {
<ng-icon name="lucideMic" class="w-5 h-5" />
<ng-icon
name="lucideMic"
class="w-5 h-5"
/>
}
</button>
<!-- Deafen Toggle -->
<button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
<button
type="button"
(click)="toggleDeafen()"
[class]="getDeafenButtonClass()"
>
<ng-icon
name="lucideHeadphones"
class="w-5 h-5"
/>
</button>
<!-- Screen Share Toggle -->
<button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
<button
type="button"
(click)="toggleScreenShare()"
[class]="getScreenShareButtonClass()"
>
@if (isScreenSharing()) {
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
<ng-icon
name="lucideMonitorOff"
class="w-5 h-5"
/>
} @else {
<ng-icon name="lucideMonitor" class="w-5 h-5" />
<ng-icon
name="lucideMonitor"
class="w-5 h-5"
/>
}
</button>
@@ -68,7 +105,10 @@
(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"
>
<ng-icon name="lucidePhoneOff" class="w-5 h-5" />
<ng-icon
name="lucidePhoneOff"
class="w-5 h-5"
/>
</button>
}
</div>

View File

@@ -43,7 +43,11 @@ interface AudioDevice {
@Component({
selector: 'app-voice-controls',
standalone: true,
imports: [CommonModule, NgIcon, UserAvatarComponent],
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideMic,
@@ -164,12 +168,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
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) {}
}
@@ -502,7 +509,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.setLatencyProfile(this.latencyProfile());
this.applyOutputDevice();
// Always sync the desired noise-reduction preference (even before
// a mic stream exists the flag will be honoured on connect).
// a mic stream exists - the flag will be honoured on connect).
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
} catch {}
}