Cleaning up comments

This commit is contained in:
2026-03-06 05:21:41 +01:00
parent fe2347b54e
commit 10467dfccb
50 changed files with 51 additions and 885 deletions

View File

@@ -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 `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
/** 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();

View File

@@ -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({