Now formatted correctly with eslint
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user