Cleaning up comments
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, id-denylist, max-statements-per-line, @typescript-eslint/prefer-for-of */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
} 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 { Message } from '../../../core/models/index';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
@@ -112,10 +112,6 @@ const COMMON_EMOJIS = [
|
||||
'(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>;
|
||||
@@ -128,7 +124,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private markdown = inject(ChatMarkdownService);
|
||||
|
||||
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
|
||||
remarkProcessor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
@@ -137,12 +132,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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;
|
||||
@@ -152,7 +145,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
);
|
||||
});
|
||||
|
||||
/** Paginated view - only the most recent `displayLimit` messages */
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
@@ -163,7 +155,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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);
|
||||
@@ -192,7 +183,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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.
|
||||
@@ -208,9 +198,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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;
|
||||
|
||||
@@ -224,7 +212,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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);
|
||||
@@ -232,13 +219,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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(() => {
|
||||
@@ -359,7 +345,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.pendingKlipyGif.set(null);
|
||||
this.clearReply();
|
||||
this.shouldScrollToBottom = true;
|
||||
// Reset textarea height after sending
|
||||
requestAnimationFrame(() => this.autoResizeTextarea());
|
||||
this.showNewMessagesBar.set(false);
|
||||
|
||||
@@ -369,11 +354,10 @@ 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
|
||||
if (now - this.lastTypingSentAt > 1000) {
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
this.lastTypingSentAt = now;
|
||||
@@ -381,13 +365,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -402,13 +384,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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 }));
|
||||
@@ -417,22 +397,18 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -450,14 +426,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 }));
|
||||
@@ -465,7 +439,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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;
|
||||
@@ -486,12 +459,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -512,7 +483,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -964,12 +934,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return droppedFiles;
|
||||
}
|
||||
|
||||
/** 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',
|
||||
@@ -986,7 +954,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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';
|
||||
@@ -1006,23 +973,19 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Whether an attachment can be played inline as video. */
|
||||
isVideoAttachment(att: Attachment): boolean {
|
||||
return att.mime.startsWith('video/');
|
||||
}
|
||||
|
||||
/** Whether an attachment can be played inline as audio. */
|
||||
isAudioAttachment(att: Attachment): boolean {
|
||||
return att.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
/** Whether the user must explicitly accept a media download before playback. */
|
||||
requiresMediaDownloadAcceptance(att: Attachment): boolean {
|
||||
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
|
||||
att.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||
}
|
||||
|
||||
/** User-facing status copy for an unavailable audio/video attachment. */
|
||||
getMediaAttachmentStatusText(att: Attachment): string {
|
||||
if (att.requestError)
|
||||
return att.requestError;
|
||||
@@ -1038,7 +1001,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
: 'Waiting for audio source…';
|
||||
}
|
||||
|
||||
/** Action label for requesting an audio/video attachment. */
|
||||
getMediaAttachmentActionLabel(att: Attachment): string {
|
||||
if (this.requiresMediaDownloadAcceptance(att)) {
|
||||
return att.requestError ? 'Retry download' : 'Accept download';
|
||||
@@ -1047,7 +1009,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return att.requestError ? 'Retry' : 'Request';
|
||||
}
|
||||
|
||||
/** Remove a pending file from the upload queue. */
|
||||
removePendingFile(file: File): void {
|
||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||
|
||||
@@ -1056,7 +1017,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Download a completed attachment to the user's device. */
|
||||
async downloadAttachment(att: Attachment): Promise<void> {
|
||||
if (!att.available || !att.objectUrl)
|
||||
return;
|
||||
@@ -1126,38 +1086,30 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
});
|
||||
}
|
||||
|
||||
/** 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();
|
||||
@@ -1166,12 +1118,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
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();
|
||||
|
||||
@@ -1185,9 +1135,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
||||
} catch (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private convertToPng(blob: Blob): Promise<Blob> {
|
||||
@@ -1269,7 +1217,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return `})`;
|
||||
}
|
||||
|
||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
@@ -1282,7 +1229,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.updateScrollPadding();
|
||||
}
|
||||
|
||||
/** Keep scroll container bottom-padding in sync with the floating bottom bar height. */
|
||||
private updateScrollPadding(): void {
|
||||
requestAnimationFrame(() => {
|
||||
const bar = this.bottomBar?.nativeElement;
|
||||
@@ -1295,12 +1241,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
});
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -1309,12 +1253,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}, 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;
|
||||
|
||||
@@ -1323,19 +1265,16 @@ 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);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
@Component({
|
||||
|
||||
Reference in New Issue
Block a user