Big commit
This commit is contained in:
@@ -19,6 +19,17 @@
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('members')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'members'"
|
||||
[class.border-b-2]="activeTab() === 'members'"
|
||||
[class.border-primary]="activeTab() === 'members'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'members'"
|
||||
>
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
||||
Members
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('bans')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
@@ -38,8 +49,8 @@
|
||||
[class.border-primary]="activeTab() === 'permissions'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||
>
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
||||
Permissions
|
||||
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" />
|
||||
Perms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +136,65 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('members') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
||||
|
||||
@if (membersFiltered().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">
|
||||
No other members online
|
||||
</p>
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
||||
@if (user.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Role actions (only for non-hosts) -->
|
||||
@if (user.role !== 'host') {
|
||||
<div class="flex items-center gap-1">
|
||||
<select
|
||||
[ngModel]="user.role"
|
||||
(ngModelChange)="changeRole(user, $event)"
|
||||
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
(click)="kickMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
(click)="banMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('bans') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
selectOnlineUsers,
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, Room } from '../../../core/models';
|
||||
import { BanEntry, Room, User } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
|
||||
type AdminTab = 'settings' | 'bans' | 'permissions';
|
||||
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
@@ -50,11 +52,13 @@ type AdminTab = 'settings' | 'bans' | 'permissions';
|
||||
})
|
||||
export class AdminPanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
activeTab = signal<AdminTab>('settings');
|
||||
showDeleteConfirm = signal(false);
|
||||
@@ -157,4 +161,37 @@ export class AdminPanelComponent {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Members tab: get all users except self
|
||||
membersFiltered(): User[] {
|
||||
const me = this.currentUser();
|
||||
return this.onlineUsers().filter(u => u.id !== me?.id && u.oderId !== me?.oderId);
|
||||
}
|
||||
|
||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
kickMember(user: User): void {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
});
|
||||
}
|
||||
|
||||
banMember(user: User): void {
|
||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'ban',
|
||||
targetUserId: user.id,
|
||||
bannedBy: this.currentUser()?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy } from '@angular/core';
|
||||
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';
|
||||
@@ -13,11 +13,16 @@ import {
|
||||
lucideMoreVertical,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as MessagesActions from '../../store/messages/messages.actions';
|
||||
import { selectAllMessages, selectMessagesLoading } 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';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
@@ -42,12 +47,23 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
lucideMoreVertical,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Messages List -->
|
||||
<div #messagesContainer class="flex-1 overflow-y-auto p-4 space-y-4 relative" (scroll)="onScroll()">
|
||||
<!-- Syncing indicator -->
|
||||
@if (syncing() && !loading()) {
|
||||
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
|
||||
<span>Syncing messages…</span>
|
||||
</div>
|
||||
}
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
@@ -58,8 +74,21 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
<p class="text-sm">Be the first to say something!</p>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Infinite scroll: load-more sentinel at top -->
|
||||
@if (hasMoreMessages()) {
|
||||
<div class="flex items-center justify-center py-3">
|
||||
@if (loadingMore()) {
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
|
||||
} @else {
|
||||
<button (click)="loadMore()" class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary">
|
||||
Load older messages
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@for (message of messages(); track message.id) {
|
||||
<div
|
||||
[attr.data-message-id]="message.id"
|
||||
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
|
||||
[class.opacity-50]="message.isDeleted"
|
||||
>
|
||||
@@ -70,6 +99,20 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
|
||||
<!-- Message Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Reply indicator -->
|
||||
@if (message.replyToId) {
|
||||
@let repliedMsg = getRepliedMessage(message.replyToId);
|
||||
<div class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (click)="scrollToMessage(message.replyToId)">
|
||||
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
|
||||
<ng-icon name="lucideReply" class="w-3 h-3" />
|
||||
@if (repliedMsg) {
|
||||
<span class="font-medium">{{ repliedMsg.senderName }}</span>
|
||||
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
|
||||
} @else {
|
||||
<span class="italic">Original message not found</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
@@ -110,20 +153,69 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
@for (att of getAttachments(message.id); track att.id) {
|
||||
@if (att.isImage) {
|
||||
@if (att.available && att.objectUrl) {
|
||||
<img [src]="att.objectUrl" alt="image" class="rounded-md max-h-80 w-auto" />
|
||||
} @else {
|
||||
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
<!-- Available image with hover overlay -->
|
||||
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
|
||||
<img
|
||||
[src]="att.objectUrl"
|
||||
[alt]="att.filename"
|
||||
class="rounded-md max-h-80 w-auto cursor-pointer"
|
||||
(click)="openLightbox(att)"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
|
||||
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
(click)="openLightbox(att); $event.stopPropagation()"
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
|
||||
title="View full size"
|
||||
>
|
||||
<ng-icon name="lucideExpand" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
(click)="downloadAttachment(att); $event.stopPropagation()"
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<ng-icon name="lucideDownload" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else if ((att.receivedBytes || 0) > 0) {
|
||||
<!-- Downloading in progress -->
|
||||
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
|
||||
<ng-icon name="lucideImage" class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 rounded bg-muted">
|
||||
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
|
||||
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Unavailable — waiting for source -->
|
||||
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
|
||||
<ng-icon name="lucideImage" class="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
(click)="retryImageRequest(att, message.id)"
|
||||
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
||||
@@ -357,6 +449,76 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Lightbox Modal -->
|
||||
@if (lightboxAttachment()) {
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
(click)="closeLightbox()"
|
||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
|
||||
(keydown.escape)="closeLightbox()"
|
||||
tabindex="0"
|
||||
#lightboxBackdrop
|
||||
>
|
||||
<div class="relative max-w-[90vw] max-h-[90vh]" (click)="$event.stopPropagation()">
|
||||
<img
|
||||
[src]="lightboxAttachment()!.objectUrl"
|
||||
[alt]="lightboxAttachment()!.filename"
|
||||
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
|
||||
/>
|
||||
<!-- Top-right action bar -->
|
||||
<div class="absolute top-3 right-3 flex gap-2">
|
||||
<button
|
||||
(click)="downloadAttachment(lightboxAttachment()!)"
|
||||
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<ng-icon name="lucideDownload" class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
(click)="closeLightbox()"
|
||||
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Bottom info bar -->
|
||||
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
|
||||
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
|
||||
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
|
||||
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Image Context Menu -->
|
||||
@if (imageContextMenu()) {
|
||||
<div class="fixed inset-0 z-[110]" (click)="closeImageContextMenu()"></div>
|
||||
<div
|
||||
class="fixed z-[120] bg-card border border-border rounded-lg shadow-lg w-48 py-1"
|
||||
[style.left.px]="imageContextMenu()!.x"
|
||||
[style.top.px]="imageContextMenu()!.y"
|
||||
>
|
||||
<button
|
||||
(click)="copyImageToClipboard(imageContextMenu()!.attachment)"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
|
||||
Copy Image
|
||||
</button>
|
||||
<button
|
||||
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
|
||||
Save Image
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||
@@ -368,11 +530,40 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private attachmentsSvc = inject(AttachmentService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
|
||||
messages = this.store.selectSignal(selectAllMessages);
|
||||
private allMessages = this.store.selectSignal(selectAllMessages);
|
||||
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
// --- Infinite scroll (upwards) pagination ---
|
||||
private readonly PAGE_SIZE = 50;
|
||||
displayLimit = signal(this.PAGE_SIZE);
|
||||
loadingMore = signal(false);
|
||||
|
||||
/** All messages for the current channel (full list, unsliced) */
|
||||
private allChannelMessages = computed(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
return this.allMessages().filter(m =>
|
||||
m.roomId === roomId && (m.channelId || 'general') === channelId
|
||||
);
|
||||
});
|
||||
|
||||
/** Paginated view — only the most recent `displayLimit` messages */
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
if (all.length <= limit) return all;
|
||||
return all.slice(all.length - limit);
|
||||
});
|
||||
|
||||
/** Whether there are older messages that can be loaded */
|
||||
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
|
||||
loading = this.store.selectSignal(selectMessagesLoading);
|
||||
syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
private currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
messageContent = '';
|
||||
editContent = '';
|
||||
@@ -383,6 +574,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
readonly commonEmojis = COMMON_EMOJIS;
|
||||
|
||||
private shouldScrollToBottom = true;
|
||||
/** Keeps us pinned to bottom while images/attachments load after initial open */
|
||||
private initialScrollObserver: MutationObserver | null = null;
|
||||
private initialScrollTimer: any = null;
|
||||
private boundOnImageLoad: (() => void) | null = null;
|
||||
/** True while a programmatic scroll-to-bottom is in progress (suppresses onScroll). */
|
||||
private isAutoScrolling = false;
|
||||
private typingSub?: Subscription;
|
||||
private lastTypingSentAt = 0;
|
||||
private readonly typingTTL = 3000; // ms to keep a user as typing
|
||||
@@ -396,8 +593,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
typingOthersCount = signal<number>(0);
|
||||
// New messages snackbar state
|
||||
showNewMessagesBar = signal(false);
|
||||
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
|
||||
nowRef = signal<number>(Date.now());
|
||||
// Plain (non-reactive) reference time used only by formatTimestamp.
|
||||
// Updated periodically but NOT a signal, so it won't re-render every message.
|
||||
private nowRef = Date.now();
|
||||
private nowTimer: any;
|
||||
toolbarVisible = signal(false);
|
||||
private toolbarHovering = false;
|
||||
@@ -405,16 +603,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
dragActive = signal(false);
|
||||
// Cache blob URLs for proxied images to prevent repeated network fetches on re-render
|
||||
private imageBlobCache = new Map<string, string>();
|
||||
// Re-render when attachments update
|
||||
private attachmentsUpdatedEffect = effect(() => {
|
||||
// Subscribe to updates; no-op body
|
||||
void this.attachmentsSvc.updated();
|
||||
// Cache rendered markdown to preserve text selection across re-renders
|
||||
private markdownCache = new Map<string, SafeHtml>();
|
||||
|
||||
// Image lightbox modal state
|
||||
lightboxAttachment = signal<Attachment | null>(null);
|
||||
// Image right-click context menu state
|
||||
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
|
||||
private boundOnKeydown: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
// Reset scroll state when room/server changes (handles reuse of component on navigation)
|
||||
private onRoomChanged = effect(() => {
|
||||
void this.currentRoom(); // track room signal
|
||||
this.initialScrollPending = true;
|
||||
this.stopInitialScrollWatch();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = 0;
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
this.markdownCache.clear();
|
||||
});
|
||||
|
||||
// Messages length signal and effect to detect new messages without blocking change detection
|
||||
// Reset pagination when switching channels within the same room
|
||||
private onChannelChanged = effect(() => {
|
||||
void this.activeChannelId(); // track channel signal
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
this.initialScrollPending = true;
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = 0;
|
||||
this.markdownCache.clear();
|
||||
});
|
||||
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
|
||||
private attachmentsUpdatedEffect = effect(() => {
|
||||
void this.attachmentsSvc.updated();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
|
||||
// Track total channel messages (not paginated) for new-message detection
|
||||
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
|
||||
messagesLength = computed(() => this.messages().length);
|
||||
private onMessagesChanged = effect(() => {
|
||||
const currentCount = this.messagesLength();
|
||||
const currentCount = this.totalChannelMessagesLength();
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) {
|
||||
this.lastMessageCount = currentCount;
|
||||
@@ -446,12 +674,23 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
// First render after connect: scroll to bottom by default (no animation)
|
||||
// First render after connect: scroll to bottom instantly (no animation)
|
||||
// Only proceed once messages are actually rendered in the DOM
|
||||
if (this.initialScrollPending) {
|
||||
this.initialScrollPending = false;
|
||||
this.scrollToBottom();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = this.messages().length;
|
||||
if (this.messages().length > 0) {
|
||||
this.initialScrollPending = false;
|
||||
// Snap to bottom immediately, then keep watching for late layout changes
|
||||
this.isAutoScrolling = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => { this.isAutoScrolling = false; });
|
||||
this.startInitialScrollWatch();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = this.messages().length;
|
||||
} else if (!this.loading()) {
|
||||
// Room has no messages and loading is done
|
||||
this.initialScrollPending = false;
|
||||
this.lastMessageCount = 0;
|
||||
}
|
||||
this.loadCspImages();
|
||||
return;
|
||||
}
|
||||
@@ -468,21 +707,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
});
|
||||
|
||||
// If we're the uploader and our original file was lost (e.g., after navigation), prompt reselect
|
||||
this.attachmentsSvc.onMissingOriginal.subscribe(({ messageId, fileId, fromPeerId }) => {
|
||||
try {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
await this.attachmentsSvc.fulfillRequestWithFile(messageId, fileId, fromPeerId, file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Periodically purge expired typing entries
|
||||
const purge = () => {
|
||||
const now = Date.now();
|
||||
@@ -502,18 +726,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
// Initialize message count for snackbar trigger
|
||||
this.lastMessageCount = this.messages().length;
|
||||
|
||||
// Update reference time periodically (minute granularity)
|
||||
// Update reference time silently (non-reactive) so formatTimestamp
|
||||
// uses a reasonably fresh "now" without re-rendering every message.
|
||||
this.nowTimer = setInterval(() => {
|
||||
this.nowRef.set(Date.now());
|
||||
this.nowRef = Date.now();
|
||||
}, 60000);
|
||||
|
||||
// Global Escape key listener for lightbox & context menu
|
||||
this.boundOnKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
|
||||
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.typingSub?.unsubscribe();
|
||||
this.stopInitialScrollWatch();
|
||||
if (this.nowTimer) {
|
||||
clearInterval(this.nowTimer);
|
||||
this.nowTimer = null;
|
||||
}
|
||||
if (this.boundOnKeydown) {
|
||||
document.removeEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(): void {
|
||||
@@ -526,6 +764,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
MessagesActions.sendMessage({
|
||||
content,
|
||||
replyToId: this.replyTo()?.id,
|
||||
channelId: this.activeChannelId(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -589,6 +828,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.replyTo.set(null);
|
||||
}
|
||||
|
||||
getRepliedMessage(messageId: string): Message | undefined {
|
||||
return this.allMessages().find(m => m.id === messageId);
|
||||
}
|
||||
|
||||
scrollToMessage(messageId: string): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
if (!container) return;
|
||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-primary/10');
|
||||
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
toggleEmojiPicker(messageId: string): void {
|
||||
this.showEmojiPicker.update((current) =>
|
||||
current === messageId ? null : messageId
|
||||
@@ -641,20 +895,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef());
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const now = new Date(this.nowRef);
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
if (days === 0) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (days < 7) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
|
||||
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));
|
||||
|
||||
if (dayDiff === 0) {
|
||||
return time;
|
||||
} else if (dayDiff === 1) {
|
||||
return 'Yesterday ' + time;
|
||||
} else if (dayDiff < 7) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +921,63 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start observing the messages container for DOM mutations
|
||||
* and image load events. Every time the container's content
|
||||
* changes size (new nodes, images finishing load) we instantly
|
||||
* snap to the bottom. Automatically stops after a timeout or
|
||||
* when the user scrolls up.
|
||||
*/
|
||||
private startInitialScrollWatch(): void {
|
||||
this.stopInitialScrollWatch(); // clean up any prior watcher
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
const snap = () => {
|
||||
if (this.messagesContainer) {
|
||||
const e = this.messagesContainer.nativeElement;
|
||||
this.isAutoScrolling = true;
|
||||
e.scrollTop = e.scrollHeight;
|
||||
// Clear flag after browser fires the synchronous scroll event
|
||||
requestAnimationFrame(() => { this.isAutoScrolling = false; });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. MutationObserver catches new DOM nodes (attachments rendered, etc.)
|
||||
this.initialScrollObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(snap);
|
||||
});
|
||||
this.initialScrollObserver.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src'], // img src swaps
|
||||
});
|
||||
|
||||
// 2. Capture-phase 'load' listener catches images finishing load
|
||||
this.boundOnImageLoad = () => requestAnimationFrame(snap);
|
||||
el.addEventListener('load', this.boundOnImageLoad, true);
|
||||
|
||||
// 3. Auto-stop after 5s so we don't fight user scrolling
|
||||
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
|
||||
}
|
||||
|
||||
private stopInitialScrollWatch(): void {
|
||||
if (this.initialScrollObserver) {
|
||||
this.initialScrollObserver.disconnect();
|
||||
this.initialScrollObserver = null;
|
||||
}
|
||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||
this.boundOnImageLoad = null;
|
||||
}
|
||||
if (this.initialScrollTimer) {
|
||||
clearTimeout(this.initialScrollTimer);
|
||||
this.initialScrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBottomSmooth(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
@@ -688,12 +1000,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
onScroll(): void {
|
||||
if (!this.messagesContainer) return;
|
||||
// Ignore scroll events caused by programmatic snap-to-bottom
|
||||
if (this.isAutoScrolling) return;
|
||||
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
||||
if (this.shouldScrollToBottom) {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
// Any user-initiated scroll during the initial load period
|
||||
// immediately hands control back to the user
|
||||
if (this.initialScrollObserver) {
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
// Infinite scroll upwards — load older messages when near the top
|
||||
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/** Load older messages by expanding the display window, preserving scroll position */
|
||||
loadMore(): void {
|
||||
if (this.loadingMore() || !this.hasMoreMessages()) return;
|
||||
this.loadingMore.set(true);
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
const prevScrollHeight = el?.scrollHeight ?? 0;
|
||||
|
||||
this.displayLimit.update(limit => limit + this.PAGE_SIZE);
|
||||
|
||||
// After Angular renders the new messages, restore scroll position
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const newScrollHeight = el.scrollHeight;
|
||||
el.scrollTop += newScrollHeight - prevScrollHeight;
|
||||
}
|
||||
this.loadingMore.set(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private recomputeTypingDisplay(now: number): void {
|
||||
@@ -707,8 +1053,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.typingOthersCount.set(others);
|
||||
}
|
||||
|
||||
// Markdown rendering
|
||||
// Markdown rendering (cached so re-renders don't replace innerHTML and kill text selection)
|
||||
renderMarkdown(content: string): SafeHtml {
|
||||
const cached = this.markdownCache.get(content);
|
||||
if (cached) return cached;
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
const html = marked.parse(content ?? '') as string;
|
||||
// Sanitize to a DOM fragment so we can post-process disallowed images
|
||||
@@ -750,7 +1099,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
|
||||
const safeHtml = DOMPurify.sanitize(container.innerHTML);
|
||||
return this.sanitizer.bypassSecurityTrustHtml(safeHtml);
|
||||
const result = this.sanitizer.bypassSecurityTrustHtml(safeHtml);
|
||||
this.markdownCache.set(content, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Resolve images marked for CSP-safe loading by converting to blob URLs
|
||||
@@ -995,6 +1346,74 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
||||
}
|
||||
|
||||
// ---- Image lightbox ----
|
||||
openLightbox(att: Attachment): void {
|
||||
if (att.available && att.objectUrl) {
|
||||
this.lightboxAttachment.set(att);
|
||||
}
|
||||
}
|
||||
|
||||
closeLightbox(): void {
|
||||
this.lightboxAttachment.set(null);
|
||||
}
|
||||
|
||||
// ---- Image context menu ----
|
||||
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
|
||||
}
|
||||
|
||||
closeImageContextMenu(): void {
|
||||
this.imageContextMenu.set(null);
|
||||
}
|
||||
|
||||
async copyImageToClipboard(att: Attachment): Promise<void> {
|
||||
this.closeImageContextMenu();
|
||||
if (!att.objectUrl) return;
|
||||
try {
|
||||
const resp = await fetch(att.objectUrl);
|
||||
const blob = await resp.blob();
|
||||
// Convert to PNG for clipboard compatibility
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob }),
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy image to clipboard:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private convertToPng(blob: Blob): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (blob.type === 'image/png') {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { reject(new Error('Canvas not supported')); return; }
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob((pngBlob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (pngBlob) resolve(pngBlob);
|
||||
else reject(new Error('PNG conversion failed'));
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
retryImageRequest(att: Attachment, messageId: string): void {
|
||||
this.attachmentsSvc.requestImageFromAnyPeer(messageId, att);
|
||||
}
|
||||
|
||||
private attachFilesToLastOwnMessage(content: string): void {
|
||||
const me = this.currentUser()?.id;
|
||||
if (!me) return;
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
<div class="h-full flex flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
<!-- Channel header bar -->
|
||||
<div class="h-12 flex items-center gap-2 px-4 border-b border-border bg-card flex-shrink-0">
|
||||
<span class="text-muted-foreground text-lg">#</span>
|
||||
<span class="font-medium text-foreground text-sm">{{ activeChannelName }}</span>
|
||||
<div class="flex-1"></div>
|
||||
@if (isAdmin()) {
|
||||
<button
|
||||
(click)="toggleAdminPanel()"
|
||||
class="p-1.5 rounded hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Server Settings"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left rail is global; chat area fills remaining space -->
|
||||
|
||||
<!-- Chat Area -->
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Screen Share Viewer -->
|
||||
@@ -15,15 +29,24 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Admin Panel (slide-over) -->
|
||||
@if (showAdminPanel() && isAdmin()) {
|
||||
<aside class="w-80 flex-shrink-0 border-l border-border overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-card">
|
||||
<span class="text-sm font-medium text-foreground">Server Settings</span>
|
||||
<button (click)="toggleAdminPanel()" class="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground">
|
||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<app-admin-panel />
|
||||
</aside>
|
||||
}
|
||||
|
||||
<!-- Sidebar always visible -->
|
||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
||||
<app-rooms-side-panel class="h-full" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls moved to sidebar bottom -->
|
||||
|
||||
<!-- Mobile overlay removed; sidebar remains visible by default -->
|
||||
} @else {
|
||||
<!-- No Room Selected -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/scre
|
||||
import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
|
||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
||||
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
|
||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
@@ -32,6 +32,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
ChatMessagesComponent,
|
||||
ScreenShareViewerComponent,
|
||||
RoomsSidePanelComponent,
|
||||
AdminPanelComponent,
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -49,11 +50,20 @@ export class ChatRoomComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
showMenu = signal(false);
|
||||
showAdminPanel = signal(false);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
// Sidebar always visible; panel toggles removed
|
||||
get activeChannelName(): string {
|
||||
const id = this.activeChannelId();
|
||||
const ch = this.textChannels().find(c => c.id === id);
|
||||
return ch ? ch.name : id;
|
||||
}
|
||||
|
||||
// Header moved to TitleBar
|
||||
toggleAdminPanel() {
|
||||
this.showAdminPanel.update(v => !v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,135 +36,133 @@
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Text Channels -->
|
||||
<div class="p-3">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Text Channels</h4>
|
||||
<div class="space-y-1">
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> general
|
||||
</button>
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> random
|
||||
</button>
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button (click)="createChannel('text')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Text Channel">
|
||||
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
@for (ch of textChannels(); track ch.id) {
|
||||
<button
|
||||
class="w-full px-2 py-1.5 text-sm rounded flex items-center gap-2 text-left transition-colors"
|
||||
[class.bg-secondary]="activeChannelId() === ch.id"
|
||||
[class.text-foreground]="activeChannelId() === ch.id"
|
||||
[class.font-medium]="activeChannelId() === ch.id"
|
||||
[class.text-foreground/60]="activeChannelId() !== ch.id"
|
||||
[class.hover:bg-secondary/60]="activeChannelId() !== ch.id"
|
||||
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
|
||||
(click)="selectTextChannel(ch.id)"
|
||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||
>
|
||||
<span class="text-muted-foreground text-base">#</span>
|
||||
@if (renamingChannelId() === ch.id) {
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
[value]="ch.name"
|
||||
(keydown.enter)="confirmRename($event)"
|
||||
(keydown.escape)="cancelRename()"
|
||||
(blur)="confirmRename($event)"
|
||||
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
} @else {
|
||||
<span class="truncate">{{ ch.name }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Channels -->
|
||||
<div class="p-3 pt-0">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Voice Channels</h4>
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button (click)="createChannel('voice')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Voice Channel">
|
||||
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (!voiceEnabled()) {
|
||||
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
<!-- General Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('general')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('general')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔊</span> General
|
||||
</span>
|
||||
@if (voiceOccupancy('general') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('general').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('general'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
@for (ch of voiceChannels(); track ch.id) {
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-1.5 text-sm rounded hover:bg-secondary/60 flex items-center justify-between text-left transition-colors"
|
||||
(click)="joinVoice(ch.id)"
|
||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
||||
@if (renamingChannelId() === ch.id) {
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
[value]="ch.name"
|
||||
(keydown.enter)="confirmRename($event)"
|
||||
(keydown.escape)="cancelRename()"
|
||||
(blur)="confirmRename($event)"
|
||||
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
} @else {
|
||||
<span>{{ ch.name }}</span>
|
||||
}
|
||||
</span>
|
||||
@if (voiceOccupancy(ch.id) > 0) {
|
||||
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- AFK Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('afk')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('afk')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔕</span> AFK
|
||||
</span>
|
||||
@if (voiceOccupancy('afk') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
|
||||
</button>
|
||||
<!-- Voice users connected to this channel -->
|
||||
@if (voiceUsersInRoom(ch.id).length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('afk').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('afk'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +215,10 @@
|
||||
</h4>
|
||||
<div class="space-y-1">
|
||||
@for (user of onlineUsersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
|
||||
(contextmenu)="openUserContextMenu($event, user)"
|
||||
>
|
||||
<div class="relative">
|
||||
@if (user.avatarUrl) {
|
||||
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
||||
@@ -229,7 +230,16 @@
|
||||
<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">{{ user.displayName }}</p>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
||||
@if (user.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
@@ -270,3 +280,80 @@
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
<!-- Channel context menu -->
|
||||
@if (showChannelMenu()) {
|
||||
<div class="fixed inset-0 z-40" (click)="closeChannelMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-44 py-1" [style.left.px]="channelMenuX()" [style.top.px]="channelMenuY()">
|
||||
<button (click)="resyncMessages()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Resync Messages
|
||||
</button>
|
||||
@if (canManageChannels()) {
|
||||
<div class="border-t border-border my-1"></div>
|
||||
<button (click)="startRename()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Rename Channel
|
||||
</button>
|
||||
<button (click)="deleteChannel()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
|
||||
Delete Channel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- User context menu (kick / role management) -->
|
||||
@if (showUserMenu()) {
|
||||
<div class="fixed inset-0 z-40" (click)="closeUserMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-48 py-1" [style.left.px]="userMenuX()" [style.top.px]="userMenuY()">
|
||||
@if (isAdmin()) {
|
||||
<!-- Role management -->
|
||||
@if (contextMenuUser()?.role === 'member') {
|
||||
<button (click)="changeUserRole('moderator')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Promote to Moderator
|
||||
</button>
|
||||
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Promote to Admin
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'moderator') {
|
||||
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Promote to Admin
|
||||
</button>
|
||||
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'admin') {
|
||||
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
<div class="border-t border-border my-1"></div>
|
||||
<button (click)="kickUserAction()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
|
||||
Kick User
|
||||
</button>
|
||||
} @else {
|
||||
<div class="px-3 py-1.5 text-sm text-muted-foreground">No actions available</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Create channel dialog -->
|
||||
@if (showCreateChannelDialog()) {
|
||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelCreateChannel()"></div>
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[320px]">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-3">Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel</h4>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newChannelName"
|
||||
placeholder="Channel name"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
||||
(keydown.enter)="confirmCreateChannel()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button (click)="cancelCreateChannel()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm">Cancel</button>
|
||||
<button (click)="confirmCreateChannel()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide';
|
||||
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide';
|
||||
import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import * as RoomsActions from '../../../store/rooms/rooms.actions';
|
||||
import * as MessagesActions from '../../../store/messages/messages.actions';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||
import { Channel, User } from '../../../core/models';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type TabView = 'channels' | 'users';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, VoiceControlsComponent],
|
||||
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
|
||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
|
||||
],
|
||||
templateUrl: './rooms-side-panel.component.html',
|
||||
})
|
||||
@@ -31,6 +36,30 @@ export class RoomsSidePanelComponent {
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
|
||||
// Channel context menu state
|
||||
showChannelMenu = signal(false);
|
||||
channelMenuX = signal(0);
|
||||
channelMenuY = signal(0);
|
||||
contextChannel = signal<Channel | null>(null);
|
||||
|
||||
// Rename state
|
||||
renamingChannelId = signal<string | null>(null);
|
||||
|
||||
// Create channel dialog state
|
||||
showCreateChannelDialog = signal(false);
|
||||
createChannelType = signal<'text' | 'voice'>('text');
|
||||
newChannelName = '';
|
||||
|
||||
// User context menu state
|
||||
showUserMenu = signal(false);
|
||||
userMenuX = signal(0);
|
||||
userMenuY = signal(0);
|
||||
contextMenuUser = signal<User | null>(null);
|
||||
|
||||
// Filter out current user from online users list
|
||||
onlineUsersFiltered() {
|
||||
@@ -40,6 +69,162 @@ export class RoomsSidePanelComponent {
|
||||
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
|
||||
}
|
||||
|
||||
canManageChannels(): boolean {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
if (!room || !user) return false;
|
||||
// Owner always can
|
||||
if (room.hostId === user.id) return true;
|
||||
const perms = room.permissions || {};
|
||||
if (user.role === 'admin' && perms.adminsManageRooms) return true;
|
||||
if (user.role === 'moderator' && perms.moderatorsManageRooms) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Text channel selection ----
|
||||
selectTextChannel(channelId: string) {
|
||||
if (this.renamingChannelId()) return; // don't switch while renaming
|
||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||
}
|
||||
|
||||
// ---- Channel context menu ----
|
||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||
evt.preventDefault();
|
||||
this.contextChannel.set(channel);
|
||||
this.channelMenuX.set(evt.clientX);
|
||||
this.channelMenuY.set(evt.clientY);
|
||||
this.showChannelMenu.set(true);
|
||||
}
|
||||
|
||||
closeChannelMenu() {
|
||||
this.showChannelMenu.set(false);
|
||||
}
|
||||
|
||||
startRename() {
|
||||
const ch = this.contextChannel();
|
||||
this.closeChannelMenu();
|
||||
if (ch) {
|
||||
this.renamingChannelId.set(ch.id);
|
||||
}
|
||||
}
|
||||
|
||||
confirmRename(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const name = input.value.trim();
|
||||
const channelId = this.renamingChannelId();
|
||||
if (channelId && name) {
|
||||
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
|
||||
}
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
cancelRename() {
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
deleteChannel() {
|
||||
const ch = this.contextChannel();
|
||||
this.closeChannelMenu();
|
||||
if (ch) {
|
||||
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
|
||||
}
|
||||
}
|
||||
|
||||
resyncMessages() {
|
||||
this.closeChannelMenu();
|
||||
const room = this.currentRoom();
|
||||
if (!room) {
|
||||
console.warn('[Resync] No current room');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch startSync for UI spinner
|
||||
this.store.dispatch(MessagesActions.startSync());
|
||||
|
||||
// Request inventory from all connected peers
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
console.log(`[Resync] Requesting inventory from ${peers.length} peer(s) for room ${room.id}`);
|
||||
if (peers.length === 0) {
|
||||
console.warn('[Resync] No connected peers — sync will time out');
|
||||
}
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
||||
} catch (e) {
|
||||
console.error(`[Resync] Failed to send to peer ${pid}:`, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Create channel ----
|
||||
createChannel(type: 'text' | 'voice') {
|
||||
this.createChannelType.set(type);
|
||||
this.newChannelName = '';
|
||||
this.showCreateChannelDialog.set(true);
|
||||
}
|
||||
|
||||
confirmCreateChannel() {
|
||||
const name = this.newChannelName.trim();
|
||||
if (!name) return;
|
||||
const type = this.createChannelType();
|
||||
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
|
||||
const channel: Channel = {
|
||||
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
|
||||
name,
|
||||
type,
|
||||
position: existing.length,
|
||||
};
|
||||
this.store.dispatch(RoomsActions.addChannel({ channel }));
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
cancelCreateChannel() {
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
// ---- User context menu (kick/role) ----
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
if (!this.isAdmin()) return;
|
||||
this.contextMenuUser.set(user);
|
||||
this.userMenuX.set(evt.clientX);
|
||||
this.userMenuY.set(evt.clientY);
|
||||
this.showUserMenu.set(true);
|
||||
}
|
||||
|
||||
closeUserMenu() {
|
||||
this.showUserMenu.set(false);
|
||||
}
|
||||
|
||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||
const user = this.contextMenuUser();
|
||||
this.closeUserMenu();
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
// Broadcast role change to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
kickUserAction() {
|
||||
const user = this.contextMenuUser();
|
||||
this.closeUserMenu();
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
// Broadcast kick to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Voice ----
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
@@ -51,10 +236,21 @@ export class RoomsSidePanelComponent {
|
||||
const current = this.currentUser();
|
||||
|
||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||
// Also handle stale voice state: if the store says connected but voice isn't actually active,
|
||||
// clear it so the user can join.
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
// Connected to voice in a different server - user must disconnect first
|
||||
console.warn('Already connected to voice in another server. Disconnect first before joining.');
|
||||
return;
|
||||
if (!this.webrtc.isVoiceConnected()) {
|
||||
// Stale state – clear it so the user can proceed
|
||||
if (current.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
console.warn('Already connected to voice in another server. Disconnect first before joining.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If switching channels within the same server, just update the room
|
||||
@@ -73,7 +269,7 @@ export class RoomsSidePanelComponent {
|
||||
}));
|
||||
}
|
||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||
this.webrtc.startVoiceHeartbeat(roomId);
|
||||
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
@@ -83,7 +279,9 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
// Update voice session for floating controls
|
||||
if (room) {
|
||||
const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId;
|
||||
// Find label from channel list
|
||||
const vc = this.voiceChannels().find(c => c.id === roomId);
|
||||
const voiceRoomName = vc ? `🔊 ${vc.name}` : roomId;
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
@@ -131,7 +329,6 @@ export class RoomsSidePanelComponent {
|
||||
voiceOccupancy(roomId: string): number {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
// Only count users connected to voice in this specific server and room
|
||||
return users.filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
@@ -140,14 +337,11 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
|
||||
viewShare(userId: string) {
|
||||
// Focus viewer on a user's stream if present
|
||||
// Requires WebRTCService to expose a remote streams registry.
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
viewStream(userId: string) {
|
||||
// Focus viewer on a user's stream - dispatches event to screen-share-viewer
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
@@ -155,25 +349,18 @@ export class RoomsSidePanelComponent {
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
if (me?.id === userId) {
|
||||
// Local user: use signal
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
|
||||
// For remote users, check the store state first (authoritative)
|
||||
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
// Store says not sharing - trust this over stream presence
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fall back to checking stream if store state is undefined
|
||||
const stream = this.webrtc.getRemoteStream(userId);
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
// Only show users connected to voice in this specific server and room
|
||||
return this.onlineUsers().filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
@@ -184,7 +371,6 @@ export class RoomsSidePanelComponent {
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
// Check that voice is connected AND both the server AND room match
|
||||
return !!(
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
|
||||
@@ -77,6 +77,9 @@ export class TitleBarComponent {
|
||||
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server – this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
try {
|
||||
localStorage.removeItem('metoyou_currentUserId');
|
||||
} catch {}
|
||||
|
||||
@@ -226,6 +226,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
console.warn('navigator.mediaDevices not available (requires HTTPS or localhost)');
|
||||
return;
|
||||
}
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
@@ -251,6 +255,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
console.error('Cannot join call: navigator.mediaDevices not available (requires HTTPS or localhost)');
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
|
||||
Reference in New Issue
Block a user