Add eslint
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
||||
<div class="chat-layout relative h-full">
|
||||
<!-- Messages List -->
|
||||
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-len, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
lucideCopy
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
@@ -25,7 +26,6 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/user
|
||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
|
||||
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
|
||||
@@ -55,16 +55,15 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
}),
|
||||
lucideCopy
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-messages.component.html',
|
||||
styleUrls: ['./chat-messages.component.scss'],
|
||||
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
|
||||
host: {
|
||||
'(document:keydown)': 'onDocKeydown($event)',
|
||||
'(document:keyup)': 'onDocKeyup($event)',
|
||||
},
|
||||
'(document:keyup)': 'onDocKeyup($event)'
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Real-time chat messages view with infinite scroll, markdown rendering,
|
||||
@@ -100,6 +99,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private allChannelMessages = computed(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
return this.allMessages().filter(message =>
|
||||
message.roomId === roomId && (message.channelId || 'general') === channelId
|
||||
);
|
||||
@@ -109,7 +109,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
if (all.length <= limit) return all;
|
||||
|
||||
if (all.length <= limit)
|
||||
return all;
|
||||
|
||||
return all.slice(all.length - limit);
|
||||
});
|
||||
|
||||
@@ -191,6 +194,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private onMessagesChanged = effect(() => {
|
||||
const currentCount = this.totalChannelMessagesLength();
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!el) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
@@ -204,6 +208,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
|
||||
if (newMessages) {
|
||||
if (distanceFromBottom <= 300) {
|
||||
// Smooth auto-scroll only when near bottom; schedule after render
|
||||
@@ -214,12 +219,15 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
this.lastMessageCount = currentCount;
|
||||
});
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
// First render after connect: scroll to bottom instantly (no animation)
|
||||
// Only proceed once messages are actually rendered in the DOM
|
||||
@@ -238,8 +246,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.initialScrollPending = false;
|
||||
this.lastMessageCount = 0;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateScrollPadding();
|
||||
}
|
||||
|
||||
@@ -257,18 +267,22 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.boundOnKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
|
||||
|
||||
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
|
||||
if (this.nowTimer) {
|
||||
clearInterval(this.nowTimer);
|
||||
this.nowTimer = null;
|
||||
}
|
||||
|
||||
if (this.boundOnKeydown) {
|
||||
document.removeEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
@@ -277,7 +291,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Send the current message content (with optional attachments) and reset the input. */
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
if (!raw && this.pendingFiles.length === 0) return;
|
||||
|
||||
if (!raw && this.pendingFiles.length === 0)
|
||||
return;
|
||||
|
||||
const content = this.markdown.appendImageMarkdown(raw);
|
||||
|
||||
@@ -285,7 +301,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
MessagesActions.sendMessage({
|
||||
content,
|
||||
replyToId: this.replyTo()?.id,
|
||||
channelId: this.activeChannelId(),
|
||||
channelId: this.activeChannelId()
|
||||
})
|
||||
);
|
||||
|
||||
@@ -305,6 +321,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Throttle and broadcast a typing indicator when the user types. */
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
@@ -321,12 +338,13 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Save the edited message content and exit edit mode. */
|
||||
saveEdit(messageId: string): void {
|
||||
if (!this.editContent.trim()) return;
|
||||
if (!this.editContent.trim())
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.editMessage({
|
||||
messageId,
|
||||
content: this.editContent.trim(),
|
||||
content: this.editContent.trim()
|
||||
})
|
||||
);
|
||||
|
||||
@@ -366,8 +384,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Smooth-scroll to a specific message element and briefly highlight it. */
|
||||
scrollToMessage(messageId: string): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-primary/10');
|
||||
@@ -393,7 +415,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const message = this.messages().find((msg) => msg.id === messageId);
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!message || !currentUserId) return;
|
||||
if (!message || !currentUserId)
|
||||
return;
|
||||
|
||||
const hasReacted = message.reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
|
||||
@@ -418,15 +441,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
message.reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||
|
||||
groups.set(reaction.emoji, {
|
||||
count: existing.count + 1,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([emoji, data]) => ({
|
||||
emoji,
|
||||
...data,
|
||||
...data
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -435,7 +459,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef);
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||
@@ -454,6 +477,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private scrollToBottom(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
@@ -470,11 +494,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.stopInitialScrollWatch(); // clean up any prior watcher
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
const snap = () => {
|
||||
if (this.messagesContainer) {
|
||||
const e = this.messagesContainer.nativeElement;
|
||||
|
||||
this.isAutoScrolling = true;
|
||||
e.scrollTop = e.scrollHeight;
|
||||
// Clear flag after browser fires the synchronous scroll event
|
||||
@@ -490,7 +517,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src'], // img src swaps
|
||||
attributeFilter: ['src'] // img src swaps
|
||||
});
|
||||
|
||||
// 2. Capture-phase 'load' listener catches images finishing load
|
||||
@@ -506,10 +533,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.initialScrollObserver.disconnect();
|
||||
this.initialScrollObserver = null;
|
||||
}
|
||||
|
||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||
this.boundOnImageLoad = null;
|
||||
}
|
||||
|
||||
if (this.initialScrollTimer) {
|
||||
clearTimeout(this.initialScrollTimer);
|
||||
this.initialScrollTimer = null;
|
||||
@@ -519,12 +548,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private scrollToBottomSmooth(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
} catch {
|
||||
// Fallback if smooth not supported
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
}
|
||||
@@ -538,21 +569,28 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */
|
||||
onScroll(): void {
|
||||
if (!this.messagesContainer) return;
|
||||
if (!this.messagesContainer)
|
||||
return;
|
||||
|
||||
// Ignore scroll events caused by programmatic snap-to-bottom
|
||||
if (this.isAutoScrolling) return;
|
||||
if (this.isAutoScrolling)
|
||||
return;
|
||||
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
|
||||
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
||||
|
||||
if (this.shouldScrollToBottom) {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
// Any user-initiated scroll during the initial load period
|
||||
// immediately hands control back to the user
|
||||
if (this.initialScrollObserver) {
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
|
||||
// Infinite scroll upwards — load older messages when near the top
|
||||
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
@@ -561,7 +599,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Load older messages by expanding the display window, preserving scroll position */
|
||||
loadMore(): void {
|
||||
if (this.loadingMore() || !this.hasMoreMessages()) return;
|
||||
if (this.loadingMore() || !this.hasMoreMessages())
|
||||
return;
|
||||
|
||||
this.loadingMore.set(true);
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
@@ -574,8 +614,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const newScrollHeight = el.scrollHeight;
|
||||
|
||||
el.scrollTop += newScrollHeight - prevScrollHeight;
|
||||
}
|
||||
|
||||
this.loadingMore.set(false);
|
||||
});
|
||||
});
|
||||
@@ -585,21 +627,25 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */
|
||||
onEnter(evt: Event): void {
|
||||
const keyEvent = evt as KeyboardEvent;
|
||||
|
||||
if (keyEvent.shiftKey) {
|
||||
// allow newline
|
||||
return;
|
||||
}
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (el) {
|
||||
el.selectionStart = start;
|
||||
el.selectionEnd = end;
|
||||
@@ -610,6 +656,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */
|
||||
applyInline(token: string): void {
|
||||
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -617,6 +664,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
|
||||
applyPrefix(prefix: string): void {
|
||||
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -624,6 +672,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a markdown heading at the given level around the current selection. */
|
||||
applyHeading(level: number): void {
|
||||
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -631,6 +680,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Convert selected lines into a numbered markdown list. */
|
||||
applyOrderedList(): void {
|
||||
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -638,6 +688,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Wrap the selection in a fenced markdown code block. */
|
||||
applyCodeBlock(): void {
|
||||
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -645,6 +696,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a markdown link around the current selection. */
|
||||
applyLink(): void {
|
||||
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -652,6 +704,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a markdown image embed around the current selection. */
|
||||
applyImage(): void {
|
||||
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -659,6 +712,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Insert a horizontal rule at the cursor position. */
|
||||
applyHorizontalRule(): void {
|
||||
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -687,12 +741,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
evt.preventDefault();
|
||||
const files: File[] = [];
|
||||
const items = evt.dataTransfer?.items;
|
||||
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
|
||||
if (file)
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
} else if (evt.dataTransfer?.files?.length) {
|
||||
@@ -700,6 +758,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
files.push(evt.dataTransfer.files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file) => this.pendingFiles.push(file));
|
||||
// Keep toolbar visible so user sees options
|
||||
this.toolbarVisible.set(true);
|
||||
@@ -714,25 +773,34 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||
formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
let size = bytes;
|
||||
let i = 0;
|
||||
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
|
||||
return `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Format a transfer speed in bytes/second to a human-readable string. */
|
||||
formatSpeed(bps?: number): string {
|
||||
if (!bps || bps <= 0) return '0 B/s';
|
||||
if (!bps || bps <= 0)
|
||||
return '0 B/s';
|
||||
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
|
||||
let speed = bps;
|
||||
let i = 0;
|
||||
|
||||
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
|
||||
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Remove a pending file from the upload queue. */
|
||||
removePendingFile(file: File): void {
|
||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||
|
||||
if (idx >= 0) {
|
||||
this.pendingFiles.splice(idx, 1);
|
||||
}
|
||||
@@ -740,8 +808,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Download a completed attachment to the user's device. */
|
||||
downloadAttachment(att: Attachment): void {
|
||||
if (!att.available || !att.objectUrl) return;
|
||||
if (!att.available || !att.objectUrl)
|
||||
return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = att.objectUrl;
|
||||
a.download = att.filename;
|
||||
document.body.appendChild(a);
|
||||
@@ -762,6 +833,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Check whether the current user is the original uploader of an attachment. */
|
||||
isUploader(att: Attachment): boolean {
|
||||
const myUserId = this.currentUser()?.id;
|
||||
|
||||
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
||||
}
|
||||
|
||||
@@ -794,14 +866,18 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Copy an image attachment to the system clipboard as PNG. */
|
||||
async copyImageToClipboard(att: Attachment): Promise<void> {
|
||||
this.closeImageContextMenu();
|
||||
if (!att.objectUrl) return;
|
||||
|
||||
if (!att.objectUrl)
|
||||
return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(att.objectUrl);
|
||||
const blob = await resp.blob();
|
||||
// Convert to PNG for clipboard compatibility
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob }),
|
||||
new ClipboardItem({ 'image/png': pngBlob })
|
||||
]);
|
||||
} catch (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
@@ -814,22 +890,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) { reject(new Error('Canvas not supported')); return; }
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob((pngBlob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (pngBlob) resolve(pngBlob);
|
||||
else reject(new Error('PNG conversion failed'));
|
||||
|
||||
if (pngBlob)
|
||||
resolve(pngBlob);
|
||||
else
|
||||
reject(new Error('PNG conversion failed'));
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@@ -841,14 +927,20 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
private attachFilesToLastOwnMessage(content: string): void {
|
||||
const me = this.currentUser()?.id;
|
||||
if (!me) return;
|
||||
|
||||
if (!me)
|
||||
return;
|
||||
|
||||
const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted);
|
||||
|
||||
if (!msg) {
|
||||
// Retry shortly until message appears
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaderPeerId = this.currentUser()?.id || undefined;
|
||||
|
||||
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
|
||||
this.pendingFiles = [];
|
||||
}
|
||||
@@ -856,7 +948,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 520) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
@@ -868,7 +963,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
requestAnimationFrame(() => {
|
||||
const bar = this.bottomBar?.nativeElement;
|
||||
const scroll = this.messagesContainer?.nativeElement;
|
||||
if (!bar || !scroll) return;
|
||||
|
||||
if (!bar || !scroll)
|
||||
return;
|
||||
|
||||
scroll.style.paddingBottom = bar.offsetHeight + 20 + 'px';
|
||||
});
|
||||
}
|
||||
@@ -895,6 +993,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Track mouse leave on the toolbar; hide if input is not focused. */
|
||||
onToolbarMouseLeave(): void {
|
||||
this.toolbarHovering = false;
|
||||
|
||||
if (document.activeElement !== this.messageInputRef?.nativeElement) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
@@ -902,12 +1001,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Handle Ctrl key down for enabling manual resize. */
|
||||
onDocKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') this.ctrlHeld.set(true);
|
||||
if (event.key === 'Control')
|
||||
this.ctrlHeld.set(true);
|
||||
}
|
||||
|
||||
/** Handle Ctrl key up for disabling manual resize. */
|
||||
onDocKeyup(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') this.ctrlHeld.set(false);
|
||||
if (event.key === 'Control')
|
||||
this.ctrlHeld.set(false);
|
||||
}
|
||||
|
||||
/** Scroll to the newest message and dismiss the new-messages snackbar. */
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ChatMarkdownService {
|
||||
const after = content.slice(end);
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ export class ChatMarkdownService {
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ export class ChatMarkdownService {
|
||||
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -58,6 +61,7 @@ export class ChatMarkdownService {
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ export class ChatMarkdownService {
|
||||
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -80,6 +85,7 @@ export class ChatMarkdownService {
|
||||
const link = `[${selected}](https://)`;
|
||||
const text = `${before}${link}${after}`;
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
@@ -92,6 +98,7 @@ export class ChatMarkdownService {
|
||||
const img = ``;
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
@@ -99,26 +106,33 @@ export class ChatMarkdownService {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const after = content.slice(end);
|
||||
const hr = `\n\n---\n\n`;
|
||||
const hr = '\n\n---\n\n';
|
||||
const text = `${before}${hr}${after}`;
|
||||
const cursor = before.length + hr.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
appendImageMarkdown(content: string): string {
|
||||
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
|
||||
const urls = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const text = content;
|
||||
|
||||
while ((match = imageUrlRegex.exec(text)) !== null) {
|
||||
urls.add(match[1]);
|
||||
}
|
||||
|
||||
if (urls.size === 0) return content;
|
||||
if (urls.size === 0)
|
||||
return content;
|
||||
|
||||
let append = '';
|
||||
|
||||
for (const url of urls) {
|
||||
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
|
||||
|
||||
if (!alreadyEmbedded) {
|
||||
append += `\n`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
|
||||
import { Component, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -13,8 +14,8 @@ const MAX_SHOWN = 4;
|
||||
templateUrl: './typing-indicator.component.html',
|
||||
host: {
|
||||
'class': 'block',
|
||||
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));',
|
||||
},
|
||||
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
|
||||
}
|
||||
})
|
||||
export class TypingIndicatorComponent {
|
||||
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||
@@ -25,30 +26,31 @@ export class TypingIndicatorComponent {
|
||||
constructor() {
|
||||
const webrtc = inject(WebRTCService);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
|
||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
|
||||
tap((msg: any) => {
|
||||
const now = Date.now();
|
||||
|
||||
this.typingMap.set(String(msg.oderId), {
|
||||
name: String(msg.displayName),
|
||||
expiresAt: now + TYPING_TTL,
|
||||
expiresAt: now + TYPING_TTL
|
||||
});
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const purge$ = interval(PURGE_INTERVAL).pipe(
|
||||
map(() => Date.now()),
|
||||
filter((now) => {
|
||||
let changed = false;
|
||||
|
||||
for (const [key, entry] of this.typingMap) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.typingMap.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
merge(typing$, purge$)
|
||||
@@ -61,6 +63,7 @@ export class TypingIndicatorComponent {
|
||||
const names = Array.from(this.typingMap.values())
|
||||
.filter((e) => e.expiresAt > now)
|
||||
.map((e) => e.name);
|
||||
|
||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(click)="toggleUserMenu(user.id)"
|
||||
(keydown.enter)="toggleUserMenu(user.id)"
|
||||
(keydown.space)="toggleUserMenu(user.id)"
|
||||
(keyup.enter)="toggleUserMenu(user.id)"
|
||||
(keyup.space)="toggleUserMenu(user.id)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
@@ -65,9 +71,13 @@
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="muteUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
||||
>
|
||||
@@ -81,6 +91,7 @@
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
||||
>
|
||||
@@ -88,6 +99,7 @@
|
||||
<span>Kick</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="banUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
||||
>
|
||||
@@ -121,19 +133,21 @@
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<label for="ban-reason-input" class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
id="ban-reason-input"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<label for="ban-duration-select" class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
id="ban-duration-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="3600000">1 hour</option>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,14 +14,14 @@ import {
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
@@ -40,10 +41,10 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
}),
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './user-list.component.html',
|
||||
templateUrl: './user-list.component.html'
|
||||
})
|
||||
/**
|
||||
* Displays the list of online users with voice state indicators and admin actions.
|
||||
@@ -79,6 +80,7 @@ export class UserListComponent {
|
||||
} else {
|
||||
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
||||
}
|
||||
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
@@ -106,7 +108,9 @@ export class UserListComponent {
|
||||
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
|
||||
confirmBan(): void {
|
||||
const user = this.userToBan();
|
||||
if (!user) return;
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
const duration = parseInt(this.banDuration, 10);
|
||||
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
||||
@@ -115,7 +119,7 @@ export class UserListComponent {
|
||||
UsersActions.banUser({
|
||||
userId: user.id,
|
||||
reason: this.banReason || undefined,
|
||||
expiresAt,
|
||||
expiresAt
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user