Refactor and code designing
This commit is contained in:
File diff suppressed because it is too large
Load Diff
462
src/app/features/chat/chat-messages/chat-messages.component.html
Normal file
462
src/app/features/chat/chat-messages/chat-messages.component.html
Normal file
@@ -0,0 +1,462 @@
|
||||
<div class="chat-layout relative h-full">
|
||||
<!-- Messages List -->
|
||||
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
|
||||
<!-- 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>
|
||||
</div>
|
||||
} @else if (messages().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<p class="text-lg">No messages yet</p>
|
||||
<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"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<app-user-avatar [name]="message.senderName" size="md" class="flex-shrink-0" />
|
||||
|
||||
<!-- 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">
|
||||
{{ formatTimestamp(message.timestamp) }}
|
||||
</span>
|
||||
@if (message.editedAt) {
|
||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (editingMessageId() === message.id) {
|
||||
<!-- Edit Mode -->
|
||||
<div class="mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="editContent"
|
||||
(keydown.enter)="saveEdit(message.id)"
|
||||
(keydown.escape)="cancelEdit()"
|
||||
class="flex-1 px-3 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
(click)="saveEdit(message.id)"
|
||||
class="p-1 text-primary hover:bg-primary/10 rounded"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
(click)="cancelEdit()"
|
||||
class="p-1 text-muted-foreground hover:bg-secondary rounded"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<remark [markdown]="message.content" [processor]="remarkProcessor">
|
||||
<ng-template [remarkTemplate]="'code'" let-node>
|
||||
@if (node.lang === 'mermaid') {
|
||||
<remark-mermaid [code]="node.value"/>
|
||||
} @else {
|
||||
<pre><code>{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
</remark>
|
||||
</div>
|
||||
@if (getAttachments(message.id).length > 0) {
|
||||
<div class="mt-2 space-y-2">
|
||||
@for (att of getAttachments(message.id); track att.id) {
|
||||
@if (att.isImage) {
|
||||
@if (att.available && att.objectUrl) {
|
||||
<!-- 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="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-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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (!isUploader(att)) {
|
||||
@if (!att.available) {
|
||||
<div class="w-24 h-1.5 rounded bg-muted">
|
||||
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span>{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</span>
|
||||
@if (att.speedBps) {
|
||||
<span>• {{ formatSpeed(att.speedBps) }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (!(att.receivedBytes || 0)) {
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
|
||||
(click)="requestAttachment(att, message.id)"
|
||||
>Request</button>
|
||||
} @else {
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
|
||||
(click)="cancelAttachment(att, message.id)"
|
||||
>Cancel</button>
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
|
||||
(click)="downloadAttachment(att)"
|
||||
>Download</button>
|
||||
}
|
||||
} @else {
|
||||
<div class="text-xs text-muted-foreground">Shared from your device</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Reactions -->
|
||||
@if (message.reactions.length > 0) {
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
@for (reaction of getGroupedReactions(message); track reaction.emoji) {
|
||||
<button
|
||||
(click)="toggleReaction(message.id, reaction.emoji)"
|
||||
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-secondary hover:bg-secondary/80 transition-colors"
|
||||
[class.ring-1]="reaction.hasCurrentUser"
|
||||
[class.ring-primary]="reaction.hasCurrentUser"
|
||||
>
|
||||
<span>{{ reaction.emoji }}</span>
|
||||
<span class="text-muted-foreground">{{ reaction.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Message Actions (visible on hover) -->
|
||||
@if (!message.isDeleted) {
|
||||
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg">
|
||||
<!-- Emoji Picker Toggle -->
|
||||
<div class="relative">
|
||||
<button
|
||||
(click)="toggleEmojiPicker(message.id)"
|
||||
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideSmile" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@if (showEmojiPicker() === message.id) {
|
||||
<div class="absolute bottom-full right-0 mb-2 p-2 bg-card border border-border rounded-lg shadow-lg flex gap-1 z-10">
|
||||
@for (emoji of commonEmojis; track emoji) {
|
||||
<button
|
||||
(click)="addReaction(message.id, emoji)"
|
||||
class="p-1 hover:bg-secondary rounded transition-colors text-lg"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Reply -->
|
||||
<button
|
||||
(click)="setReplyTo(message)"
|
||||
class="p-1.5 hover:bg-secondary transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<!-- Edit (own messages only) -->
|
||||
@if (isOwnMessage(message)) {
|
||||
<button
|
||||
(click)="startEdit(message)"
|
||||
class="p-1.5 hover:bg-secondary transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideEdit" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Delete (own messages or admin) -->
|
||||
@if (isOwnMessage(message) || isAdmin()) {
|
||||
<button
|
||||
(click)="deleteMessage(message)"
|
||||
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4 text-destructive" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<!-- New messages snackbar (center bottom inside container) -->
|
||||
@if (showNewMessagesBar()) {
|
||||
<div class="sticky bottom-4 flex justify-center pointer-events-none">
|
||||
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
|
||||
<span class="text-sm text-muted-foreground">New messages</span>
|
||||
<button (click)="readLatest()" class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm">Read latest</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar: floats over messages -->
|
||||
<div #bottomBar class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||
|
||||
<!-- Reply Preview -->
|
||||
@if (replyTo()) {
|
||||
<div class="px-4 py-2 bg-secondary/50 flex items-center gap-2 pointer-events-auto">
|
||||
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground flex-1">
|
||||
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
|
||||
</span>
|
||||
<button (click)="clearReply()" class="p-1 hover:bg-secondary rounded">
|
||||
<ng-icon name="lucideX" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Typing Indicator -->
|
||||
<app-typing-indicator />
|
||||
|
||||
<!-- Markdown Toolbar -->
|
||||
@if (toolbarVisible()) {
|
||||
<div class="pointer-events-auto" (mousedown)="$event.preventDefault()" (mouseenter)="onToolbarMouseEnter()" (mouseleave)="onToolbarMouseLeave()">
|
||||
<div class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm">
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('**')"><b>B</b></button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('*')"><i>I</i></button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('~~')"><s>S</s></button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline(inlineCodeToken)">`</button>
|
||||
<span class="mx-1 text-muted-foreground">|</span>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(1)">H1</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(2)">H2</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(3)">H3</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('> ')">Quote</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('- ')">• List</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyOrderedList()">1. List</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyCodeBlock()">Code</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyLink()">Link</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyImage()">Image</button>
|
||||
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHorizontalRule()">HR</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Message Input -->
|
||||
<div class="p-4 border-border">
|
||||
<div
|
||||
class="chat-input-wrapper relative"
|
||||
(mouseenter)="inputHovered.set(true)"
|
||||
(mouseleave)="inputHovered.set(false)"
|
||||
>
|
||||
<textarea
|
||||
#messageInputRef
|
||||
rows="1"
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
(input)="onInputChange(); autoResizeTextarea()"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
placeholder="Type a message..."
|
||||
class="chat-textarea w-full pl-3 pr-12 py-2 rounded-2xl border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.border-dashed]="dragActive()"
|
||||
[class.ctrl-resize]="ctrlHeld()"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
|
||||
class="send-btn absolute right-2 bottom-[15px] w-8 h-8 rounded-full bg-primary text-primary-foreground grid place-items-center hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
[class.visible]="inputHovered() || messageContent.trim().length > 0"
|
||||
>
|
||||
<ng-icon name="lucideSend" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@if (dragActive()) {
|
||||
<div class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center">
|
||||
<div class="text-sm text-muted-foreground">Drop files to attach</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingFiles.length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (file of pendingFiles; track file.name) {
|
||||
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
|
||||
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
|
||||
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
|
||||
<button (click)="removePendingFile(file)" class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5">Remove</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</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()) {
|
||||
<app-context-menu [x]="imageContextMenu()!.x" [y]="imageContextMenu()!.y" (closed)="closeImageContextMenu()">
|
||||
<button (click)="copyImageToClipboard(imageContextMenu()!.attachment)" class="context-menu-item-icon">
|
||||
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
|
||||
Copy Image
|
||||
</button>
|
||||
<button (click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()" class="context-menu-item-icon">
|
||||
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
|
||||
Save Image
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
223
src/app/features/chat/chat-messages/chat-messages.component.scss
Normal file
223
src/app/features/chat/chat-messages/chat-messages.component.scss
Normal file
@@ -0,0 +1,223 @@
|
||||
/* ── Chat layout: messages scroll behind input ──── */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-messages-scroll {
|
||||
/* Fallback; dynamically overridden by updateScrollPadding() */
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.chat-bottom-bar {
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
background: hsl(var(--background) / 0.85);
|
||||
/* Inset from right so the scrollbar track stays visible */
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
/* Gradient fade-in above the bottom bar — blur only, no darkening */
|
||||
.chat-bottom-fade {
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
|
||||
}
|
||||
|
||||
/* ── Chat textarea redesign ────────────────────── */
|
||||
|
||||
.chat-textarea {
|
||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
|
||||
background: var(--textarea-bg);
|
||||
|
||||
/* Auto-resize: start at 62px, grow upward */
|
||||
height: 62px;
|
||||
min-height: 62px;
|
||||
max-height: 520px;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
transition: height 0.12s ease;
|
||||
|
||||
/* Show manual resize handle only while Ctrl is held */
|
||||
&.ctrl-resize {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
/* Send button: hidden by default, fades in on wrapper hover */
|
||||
.send-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.85);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ngx-remark markdown styles ──────────────── */
|
||||
|
||||
.chat-markdown {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--foreground));
|
||||
|
||||
::ng-deep {
|
||||
remark {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
a {
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
margin: 0.5em 0 0.25em;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
h1 { font-size: 1.5em; }
|
||||
h2 { font-size: 1.3em; }
|
||||
h3 { font-size: 1.15em; }
|
||||
|
||||
ul, ol {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
ul { list-style-type: disc; }
|
||||
ol { list-style-type: decimal; }
|
||||
|
||||
li {
|
||||
margin: 0.125em 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid hsl(var(--primary) / 0.5);
|
||||
margin: 0.5em 0;
|
||||
padding: 0.25em 0.75em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--secondary) / 0.3);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--secondary));
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
margin: 0.5em 0;
|
||||
padding: 0.75em 1em;
|
||||
background: hsl(var(--secondary));
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(var(--border));
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
font-size: 0.875em;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 0.35em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: hsl(var(--secondary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 320px;
|
||||
border-radius: var(--radius);
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Ensure consecutive paragraphs have minimal spacing for chat feel
|
||||
p + p {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
// Mermaid diagrams: prevent SVG from blocking clicks on the rest of the app
|
||||
remark-mermaid {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
|
||||
svg {
|
||||
pointer-events: none;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
996
src/app/features/chat/chat-messages/chat-messages.component.ts
Normal file
996
src/app/features/chat/chat-messages/chat-messages.component.ts
Normal file
@@ -0,0 +1,996 @@
|
||||
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';
|
||||
import { AttachmentService, Attachment } from '../../../core/services/attachment.service';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSend,
|
||||
lucideSmile,
|
||||
lucideEdit,
|
||||
lucideTrash2,
|
||||
lucideReply,
|
||||
lucideMoreVertical,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
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';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
|
||||
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
|
||||
import { RemarkModule, MermaidComponent } from 'ngx-remark';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
|
||||
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-messages',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSend,
|
||||
lucideSmile,
|
||||
lucideEdit,
|
||||
lucideTrash2,
|
||||
lucideReply,
|
||||
lucideMoreVertical,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
}),
|
||||
],
|
||||
templateUrl: './chat-messages.component.html',
|
||||
styleUrls: ['./chat-messages.component.scss'],
|
||||
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
|
||||
host: {
|
||||
'(document:keydown)': 'onDocKeydown($event)',
|
||||
'(document:keyup)': 'onDocKeyup($event)',
|
||||
},
|
||||
})
|
||||
/**
|
||||
* Real-time chat messages view with infinite scroll, markdown rendering,
|
||||
* emoji reactions, file attachments, and image lightbox support.
|
||||
*/
|
||||
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
||||
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
|
||||
@ViewChild('bottomBar') bottomBar!: ElementRef;
|
||||
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private attachmentsSvc = inject(AttachmentService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
|
||||
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
|
||||
remarkProcessor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks) as any;
|
||||
|
||||
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(message =>
|
||||
message.roomId === roomId && (message.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 = '';
|
||||
editingMessageId = signal<string | null>(null);
|
||||
replyTo = signal<Message | null>(null);
|
||||
showEmojiPicker = signal<string | null>(null);
|
||||
|
||||
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 lastTypingSentAt = 0;
|
||||
private lastMessageCount = 0;
|
||||
private initialScrollPending = true;
|
||||
pendingFiles: File[] = [];
|
||||
// New messages snackbar state
|
||||
showNewMessagesBar = signal(false);
|
||||
// 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;
|
||||
inlineCodeToken = '`';
|
||||
dragActive = signal(false);
|
||||
inputHovered = signal(false);
|
||||
ctrlHeld = signal(false);
|
||||
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
|
||||
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
// 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: ((event: 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);
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
// 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.totalChannelMessagesLength();
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip during initial scroll setup
|
||||
if (this.initialScrollPending) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
if (newMessages) {
|
||||
if (distanceFromBottom <= 300) {
|
||||
// Smooth auto-scroll only when near bottom; schedule after render
|
||||
this.scheduleScrollToBottomSmooth();
|
||||
this.showNewMessagesBar.set(false);
|
||||
} else {
|
||||
// Schedule snackbar update to avoid blocking change detection
|
||||
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||
}
|
||||
}
|
||||
this.lastMessageCount = currentCount;
|
||||
});
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
// First render after connect: scroll to bottom instantly (no animation)
|
||||
// Only proceed once messages are actually rendered in the DOM
|
||||
if (this.initialScrollPending) {
|
||||
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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.updateScrollPadding();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize message count for snackbar trigger
|
||||
this.lastMessageCount = this.messages().length;
|
||||
|
||||
// Update reference time silently (non-reactive) so formatTimestamp
|
||||
// uses a reasonably fresh "now" without re-rendering every message.
|
||||
this.nowTimer = setInterval(() => {
|
||||
this.nowRef = Date.now();
|
||||
}, 60000);
|
||||
|
||||
// Global Escape key listener for lightbox & context menu
|
||||
this.boundOnKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
|
||||
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
if (this.nowTimer) {
|
||||
clearInterval(this.nowTimer);
|
||||
this.nowTimer = null;
|
||||
}
|
||||
if (this.boundOnKeydown) {
|
||||
document.removeEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send the current message content (with optional attachments) and reset the input. */
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
if (!raw && this.pendingFiles.length === 0) return;
|
||||
|
||||
const content = this.appendImageMarkdown(raw);
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.sendMessage({
|
||||
content,
|
||||
replyToId: this.replyTo()?.id,
|
||||
channelId: this.activeChannelId(),
|
||||
})
|
||||
);
|
||||
|
||||
this.messageContent = '';
|
||||
this.clearReply();
|
||||
this.shouldScrollToBottom = true;
|
||||
// Reset textarea height after sending
|
||||
requestAnimationFrame(() => this.autoResizeTextarea());
|
||||
this.showNewMessagesBar.set(false);
|
||||
|
||||
if (this.pendingFiles.length > 0) {
|
||||
// Wait briefly for the message to appear in the list, then attach
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(content), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/** Throttle and broadcast a typing indicator when the user types. */
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
this.lastTypingSentAt = now;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin editing an existing message, populating the edit input. */
|
||||
startEdit(message: Message): void {
|
||||
this.editingMessageId.set(message.id);
|
||||
this.editContent = message.content;
|
||||
}
|
||||
|
||||
/** Save the edited message content and exit edit mode. */
|
||||
saveEdit(messageId: string): void {
|
||||
if (!this.editContent.trim()) return;
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.editMessage({
|
||||
messageId,
|
||||
content: this.editContent.trim(),
|
||||
})
|
||||
);
|
||||
|
||||
this.cancelEdit();
|
||||
}
|
||||
|
||||
/** Cancel the current edit and clear the edit state. */
|
||||
cancelEdit(): void {
|
||||
this.editingMessageId.set(null);
|
||||
this.editContent = '';
|
||||
}
|
||||
|
||||
/** Delete a message (own or admin-delete if the user has admin privileges). */
|
||||
deleteMessage(message: Message): void {
|
||||
if (this.isOwnMessage(message)) {
|
||||
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
|
||||
} else if (this.isAdmin()) {
|
||||
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the message to reply to. */
|
||||
setReplyTo(message: Message): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
|
||||
/** Clear the current reply-to reference. */
|
||||
clearReply(): void {
|
||||
this.replyTo.set(null);
|
||||
}
|
||||
|
||||
/** Find the original message that a reply references. */
|
||||
getRepliedMessage(messageId: string): Message | undefined {
|
||||
return this.allMessages().find(message => message.id === messageId);
|
||||
}
|
||||
|
||||
/** Smooth-scroll to a specific message element and briefly highlight it. */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the emoji picker for a message. */
|
||||
toggleEmojiPicker(messageId: string): void {
|
||||
this.showEmojiPicker.update((current) =>
|
||||
current === messageId ? null : messageId
|
||||
);
|
||||
}
|
||||
|
||||
/** Add a reaction emoji to a message. */
|
||||
addReaction(messageId: string, emoji: string): void {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
this.showEmojiPicker.set(null);
|
||||
}
|
||||
|
||||
/** Toggle the reaction for the current user on a message. */
|
||||
toggleReaction(messageId: string, emoji: string): void {
|
||||
const message = this.messages().find((msg) => msg.id === messageId);
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!message || !currentUserId) return;
|
||||
|
||||
const hasReacted = message.reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
|
||||
);
|
||||
|
||||
if (hasReacted) {
|
||||
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
|
||||
} else {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether a message was sent by the current user. */
|
||||
isOwnMessage(message: Message): boolean {
|
||||
return message.senderId === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
/** Aggregate reactions by emoji, returning counts and whether the current user reacted. */
|
||||
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
|
||||
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
message.reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||
groups.set(reaction.emoji, {
|
||||
count: existing.count + 1,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([emoji, data]) => ({
|
||||
emoji,
|
||||
...data,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Format a timestamp as a relative or absolute time string. */
|
||||
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' });
|
||||
|
||||
// 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' }) + ' ' + time;
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
} catch {
|
||||
// Fallback if smooth not supported
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleScrollToBottomSmooth(): void {
|
||||
// Use double rAF to ensure DOM updated and layout computed before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => this.scrollToBottomSmooth());
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Markdown toolbar actions
|
||||
/** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */
|
||||
onEnter(evt: Event): void {
|
||||
const keyEvent = evt as KeyboardEvent;
|
||||
if (keyEvent.shiftKey) {
|
||||
// allow newline
|
||||
return;
|
||||
}
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
if (el) {
|
||||
el.selectionStart = start;
|
||||
el.selectionEnd = end;
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */
|
||||
applyInline(token: string): void {
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const selected = this.messageContent.slice(start, end) || 'text';
|
||||
const after = this.messageContent.slice(end);
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
this.setSelection(cursor, cursor);
|
||||
}
|
||||
|
||||
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
|
||||
applyPrefix(prefix: string): void {
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const selected = this.messageContent.slice(start, end) || 'text';
|
||||
const after = this.messageContent.slice(end);
|
||||
const lines = selected.split('\n').map(line => `${prefix}${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
const newText = `${before}${newSelected}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursor = before.length + newSelected.length;
|
||||
this.setSelection(cursor, cursor);
|
||||
}
|
||||
|
||||
/** Insert a markdown heading at the given level around the current selection. */
|
||||
applyHeading(level: number): void {
|
||||
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const selected = this.messageContent.slice(start, end) || 'Heading';
|
||||
const after = this.messageContent.slice(end);
|
||||
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
||||
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
|
||||
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||
const newText = `${before}${block}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursor = before.length + block.length;
|
||||
this.setSelection(cursor, cursor);
|
||||
}
|
||||
|
||||
/** Convert selected lines into a numbered markdown list. */
|
||||
applyOrderedList(): void {
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const selected = this.messageContent.slice(start, end) || 'item\nitem';
|
||||
const after = this.messageContent.slice(end);
|
||||
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
const newText = `${before}${newSelected}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursor = before.length + newSelected.length;
|
||||
this.setSelection(cursor, cursor);
|
||||
}
|
||||
|
||||
/** Wrap the selection in a fenced markdown code block. */
|
||||
applyCodeBlock(): void {
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const selected = this.messageContent.slice(start, end) || 'code';
|
||||
const after = this.messageContent.slice(end);
|
||||
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const newText = `${before}${fenced}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursor = before.length + fenced.length;
|
||||
this.setSelection(cursor, cursor);
|
||||
}
|
||||
|
||||
/** Insert a markdown link around the current selection. */
|
||||
applyLink(): void {
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const selected = this.messageContent.slice(start, end) || 'link';
|
||||
const after = this.messageContent.slice(end);
|
||||
const link = `[${selected}](https://)`;
|
||||
const newText = `${before}${link}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursorStart = before.length + link.length - 1; // position inside url
|
||||
this.setSelection(cursorStart - 8, cursorStart - 1);
|
||||
}
|
||||
|
||||
/** Insert a markdown image embed around the current selection. */
|
||||
applyImage(): void {
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const selected = this.messageContent.slice(start, end) || 'alt';
|
||||
const after = this.messageContent.slice(end);
|
||||
const img = ``;
|
||||
const newText = `${before}${img}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
this.setSelection(cursorStart - 8, cursorStart - 1);
|
||||
}
|
||||
|
||||
/** Insert a horizontal rule at the cursor position. */
|
||||
applyHorizontalRule(): void {
|
||||
const { start, end } = this.getSelection();
|
||||
const before = this.messageContent.slice(0, start);
|
||||
const after = this.messageContent.slice(end);
|
||||
const hr = `\n\n---\n\n`;
|
||||
const newText = `${before}${hr}${after}`;
|
||||
this.messageContent = newText;
|
||||
const cursor = before.length + hr.length;
|
||||
this.setSelection(cursor, cursor);
|
||||
}
|
||||
|
||||
/** Handle drag-enter to activate the drop zone overlay. */
|
||||
// Attachments: drag/drop and rendering
|
||||
onDragEnter(evt: DragEvent): void {
|
||||
evt.preventDefault();
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
/** Keep the drop zone active while dragging over. */
|
||||
onDragOver(evt: DragEvent): void {
|
||||
evt.preventDefault();
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
/** Deactivate the drop zone when dragging leaves. */
|
||||
onDragLeave(evt: DragEvent): void {
|
||||
evt.preventDefault();
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
|
||||
/** Handle dropped files, adding them to the pending upload queue. */
|
||||
onDrop(evt: DragEvent): void {
|
||||
evt.preventDefault();
|
||||
const files: File[] = [];
|
||||
const items = evt.dataTransfer?.items;
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
} else if (evt.dataTransfer?.files?.length) {
|
||||
for (let i = 0; i < evt.dataTransfer.files.length; i++) {
|
||||
files.push(evt.dataTransfer.files[i]);
|
||||
}
|
||||
}
|
||||
files.forEach((file) => this.pendingFiles.push(file));
|
||||
// Keep toolbar visible so user sees options
|
||||
this.toolbarVisible.set(true);
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
|
||||
/** Return all file attachments associated with a message. */
|
||||
getAttachments(messageId: string): Attachment[] {
|
||||
return this.attachmentsSvc.getForMessage(messageId);
|
||||
}
|
||||
|
||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||
formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let i = 0;
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
return `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Format a transfer speed in bytes/second to a human-readable string. */
|
||||
formatSpeed(bps?: number): string {
|
||||
if (!bps || bps <= 0) return '0 B/s';
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
let speed = bps;
|
||||
let i = 0;
|
||||
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Remove a pending file from the upload queue. */
|
||||
removePendingFile(file: File): void {
|
||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||
if (idx >= 0) {
|
||||
this.pendingFiles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Download a completed attachment to the user's device. */
|
||||
downloadAttachment(att: Attachment): void {
|
||||
if (!att.available || !att.objectUrl) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = att.objectUrl;
|
||||
a.download = att.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
/** Request a file attachment to be transferred from the uploader peer. */
|
||||
requestAttachment(att: Attachment, messageId: string): void {
|
||||
this.attachmentsSvc.requestFile(messageId, att);
|
||||
}
|
||||
|
||||
/** Cancel an in-progress attachment transfer request. */
|
||||
cancelAttachment(att: Attachment, messageId: string): void {
|
||||
this.attachmentsSvc.cancelRequest(messageId, att);
|
||||
}
|
||||
|
||||
/** Check whether the current user is the original uploader of an attachment. */
|
||||
isUploader(att: Attachment): boolean {
|
||||
const myUserId = this.currentUser()?.id;
|
||||
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
||||
}
|
||||
|
||||
/** Open the image lightbox for a completed image attachment. */
|
||||
// ---- Image lightbox ----
|
||||
openLightbox(att: Attachment): void {
|
||||
if (att.available && att.objectUrl) {
|
||||
this.lightboxAttachment.set(att);
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the image lightbox. */
|
||||
closeLightbox(): void {
|
||||
this.lightboxAttachment.set(null);
|
||||
}
|
||||
|
||||
/** Open a context menu on right-click of an image attachment. */
|
||||
// ---- Image context menu ----
|
||||
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
|
||||
}
|
||||
|
||||
/** Close the image context menu. */
|
||||
closeImageContextMenu(): void {
|
||||
this.imageContextMenu.set(null);
|
||||
}
|
||||
|
||||
/** Copy an image attachment to the system clipboard as PNG. */
|
||||
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 (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/** Retry fetching an image from any available peer. */
|
||||
retryImageRequest(att: Attachment, messageId: string): void {
|
||||
this.attachmentsSvc.requestImageFromAnyPeer(messageId, att);
|
||||
}
|
||||
|
||||
private attachFilesToLastOwnMessage(content: string): void {
|
||||
const me = this.currentUser()?.id;
|
||||
if (!me) return;
|
||||
const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted);
|
||||
if (!msg) {
|
||||
// Retry shortly until message appears
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
|
||||
return;
|
||||
}
|
||||
const uploaderPeerId = this.currentUser()?.id || undefined;
|
||||
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
|
||||
this.pendingFiles = [];
|
||||
}
|
||||
|
||||
// Detect image URLs and append Markdown embeds at the end
|
||||
private appendImageMarkdown(content: string): string {
|
||||
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
|
||||
const urls = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const text = content;
|
||||
while ((match = imageUrlRegex.exec(text)) !== null) {
|
||||
urls.add(match[1]);
|
||||
}
|
||||
|
||||
if (urls.size === 0) return content;
|
||||
|
||||
let append = '';
|
||||
for (const url of urls) {
|
||||
// Skip if already embedded as a Markdown image
|
||||
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
|
||||
if (!alreadyEmbedded) {
|
||||
append += `\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return append ? content + append : content;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 520) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
this.updateScrollPadding();
|
||||
}
|
||||
|
||||
/** Keep scroll container bottom-padding in sync with the floating bottom bar height. */
|
||||
private updateScrollPadding(): void {
|
||||
requestAnimationFrame(() => {
|
||||
const bar = this.bottomBar?.nativeElement;
|
||||
const scroll = this.messagesContainer?.nativeElement;
|
||||
if (!bar || !scroll) return;
|
||||
scroll.style.paddingBottom = bar.offsetHeight + 20 + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the markdown toolbar when the input gains focus. */
|
||||
onInputFocus(): void {
|
||||
this.toolbarVisible.set(true);
|
||||
}
|
||||
|
||||
/** Hide the markdown toolbar after a brief delay when the input loses focus. */
|
||||
onInputBlur(): void {
|
||||
setTimeout(() => {
|
||||
if (!this.toolbarHovering) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/** Track mouse entry on the toolbar to prevent premature hiding. */
|
||||
onToolbarMouseEnter(): void {
|
||||
this.toolbarHovering = true;
|
||||
}
|
||||
|
||||
/** Track mouse leave on the toolbar; hide if input is not focused. */
|
||||
onToolbarMouseLeave(): void {
|
||||
this.toolbarHovering = false;
|
||||
if (document.activeElement !== this.messageInputRef?.nativeElement) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle Ctrl key down for enabling manual resize. */
|
||||
onDocKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') this.ctrlHeld.set(true);
|
||||
}
|
||||
|
||||
/** Handle Ctrl key up for disabling manual resize. */
|
||||
onDocKeyup(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') this.ctrlHeld.set(false);
|
||||
}
|
||||
|
||||
/** Scroll to the newest message and dismiss the new-messages snackbar. */
|
||||
readLatest(): void {
|
||||
this.shouldScrollToBottom = true;
|
||||
this.scrollToBottomSmooth();
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@if (typingDisplay().length > 0) {
|
||||
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
|
||||
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
|
||||
{{ typingDisplay().join(', ') }}
|
||||
@if (typingOthersCount() > 0) {
|
||||
and {{ typingOthersCount() }} others are typing...
|
||||
} @else {
|
||||
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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';
|
||||
|
||||
const TYPING_TTL = 3_000;
|
||||
const PURGE_INTERVAL = 1_000;
|
||||
const MAX_SHOWN = 4;
|
||||
|
||||
@Component({
|
||||
selector: 'app-typing-indicator',
|
||||
standalone: true,
|
||||
templateUrl: './typing-indicator.component.html',
|
||||
host: {
|
||||
'class': 'block',
|
||||
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));',
|
||||
},
|
||||
})
|
||||
export class TypingIndicatorComponent {
|
||||
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||
|
||||
typingDisplay = signal<string[]>([]);
|
||||
typingOthersCount = signal<number>(0);
|
||||
|
||||
constructor() {
|
||||
const webrtc = inject(WebRTCService);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
|
||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
|
||||
tap((msg: any) => {
|
||||
const now = Date.now();
|
||||
this.typingMap.set(String(msg.oderId), {
|
||||
name: String(msg.displayName),
|
||||
expiresAt: now + TYPING_TTL,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const purge$ = interval(PURGE_INTERVAL).pipe(
|
||||
map(() => Date.now()),
|
||||
filter((now) => {
|
||||
let changed = false;
|
||||
for (const [key, entry] of this.typingMap) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.typingMap.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}),
|
||||
);
|
||||
|
||||
merge(typing$, purge$)
|
||||
.pipe(takeUntilDestroyed(destroyRef))
|
||||
.subscribe(() => this.recomputeDisplay());
|
||||
}
|
||||
|
||||
private recomputeDisplay(): void {
|
||||
const now = Date.now();
|
||||
const names = Array.from(this.typingMap.values())
|
||||
.filter((e) => e.expiresAt > now)
|
||||
.map((e) => e.name);
|
||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { Component, inject, signal, computed } 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 {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideShield,
|
||||
lucideCrown,
|
||||
lucideMoreVertical,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
} from '../../store/users/users.selectors';
|
||||
import { User } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideShield,
|
||||
lucideCrown,
|
||||
lucideMoreVertical,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="h-full flex flex-col bg-card border-l border-border">
|
||||
<!-- 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)"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<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()"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<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
|
||||
(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
|
||||
(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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ban Dialog -->
|
||||
@if (showBanDialog()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeBanDialog()">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Ban User</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
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 class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
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>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
(click)="closeBanDialog()"
|
||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmBan()"
|
||||
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
Ban User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UserListComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
voiceUsers = computed(() => this.onlineUsers().filter(u => !!u.voiceState?.isConnected));
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
showUserMenu = signal<string | null>(null);
|
||||
showBanDialog = signal(false);
|
||||
userToBan = signal<User | null>(null);
|
||||
banReason = '';
|
||||
banDuration = '86400000'; // Default 1 day
|
||||
|
||||
toggleUserMenu(userId: string): void {
|
||||
this.showUserMenu.update((current) => (current === userId ? null : userId));
|
||||
}
|
||||
|
||||
isCurrentUser(user: User): boolean {
|
||||
return user.id === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
muteUser(user: User): void {
|
||||
if (user.voiceState?.isMutedByAdmin) {
|
||||
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
|
||||
} else {
|
||||
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
||||
}
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
kickUser(user: User): void {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
banUser(user: User): void {
|
||||
this.userToBan.set(user);
|
||||
this.showBanDialog.set(true);
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
closeBanDialog(): void {
|
||||
this.showBanDialog.set(false);
|
||||
this.userToBan.set(null);
|
||||
this.banReason = '';
|
||||
this.banDuration = '86400000';
|
||||
}
|
||||
|
||||
confirmBan(): void {
|
||||
const user = this.userToBan();
|
||||
if (!user) return;
|
||||
|
||||
const duration = parseInt(this.banDuration, 10);
|
||||
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.banUser({
|
||||
userId: user.id,
|
||||
reason: this.banReason || undefined,
|
||||
expiresAt,
|
||||
})
|
||||
);
|
||||
|
||||
this.closeBanDialog();
|
||||
}
|
||||
}
|
||||
147
src/app/features/chat/user-list/user-list.component.html
Normal file
147
src/app/features/chat/user-list/user-list.component.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!-- 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)"
|
||||
>
|
||||
<!-- 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()"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<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
|
||||
(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
|
||||
(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>
|
||||
}
|
||||
</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 class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
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>
|
||||
}
|
||||
124
src/app/features/chat/user-list/user-list.component.ts
Normal file
124
src/app/features/chat/user-list/user-list.component.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Component, inject, signal, computed } 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 {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideShield,
|
||||
lucideCrown,
|
||||
lucideMoreVertical,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideShield,
|
||||
lucideCrown,
|
||||
lucideMoreVertical,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
}),
|
||||
],
|
||||
templateUrl: './user-list.component.html',
|
||||
})
|
||||
/**
|
||||
* Displays the list of online users with voice state indicators and admin actions.
|
||||
*/
|
||||
export class UserListComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
showUserMenu = signal<string | null>(null);
|
||||
showBanDialog = signal(false);
|
||||
userToBan = signal<User | null>(null);
|
||||
banReason = '';
|
||||
banDuration = '86400000'; // Default 1 day
|
||||
|
||||
/** Toggle the context menu for a specific user. */
|
||||
toggleUserMenu(userId: string): void {
|
||||
this.showUserMenu.update((current) => (current === userId ? null : userId));
|
||||
}
|
||||
|
||||
/** Check whether the given user is the currently authenticated user. */
|
||||
isCurrentUser(user: User): boolean {
|
||||
return user.id === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
/** Toggle server-side mute on a user (admin action). */
|
||||
muteUser(user: User): void {
|
||||
if (user.voiceState?.isMutedByAdmin) {
|
||||
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
|
||||
} else {
|
||||
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
||||
}
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
/** Kick a user from the server (admin action). */
|
||||
kickUser(user: User): void {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
/** Open the ban confirmation dialog for a user (admin action). */
|
||||
banUser(user: User): void {
|
||||
this.userToBan.set(user);
|
||||
this.showBanDialog.set(true);
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
/** Close the ban dialog and reset its form fields. */
|
||||
closeBanDialog(): void {
|
||||
this.showBanDialog.set(false);
|
||||
this.userToBan.set(null);
|
||||
this.banReason = '';
|
||||
this.banDuration = '86400000';
|
||||
}
|
||||
|
||||
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
|
||||
confirmBan(): void {
|
||||
const user = this.userToBan();
|
||||
if (!user) return;
|
||||
|
||||
const duration = parseInt(this.banDuration, 10);
|
||||
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.banUser({
|
||||
userId: user.id,
|
||||
reason: this.banReason || undefined,
|
||||
expiresAt,
|
||||
})
|
||||
);
|
||||
|
||||
this.closeBanDialog();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user