Big commit

This commit is contained in:
2026-03-02 00:13:34 +01:00
parent d146138fca
commit 6d7465ff18
54 changed files with 5999 additions and 2291 deletions

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy } from '@angular/core';
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
@@ -13,11 +13,16 @@ import {
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
} from '@ng-icons/lucide';
import * as MessagesActions from '../../store/messages/messages.actions';
import { selectAllMessages, selectMessagesLoading } from '../../store/messages/messages.selectors';
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId } from '../../store/rooms/rooms.selectors';
import { Message } from '../../core/models';
import { WebRTCService } from '../../core/services/webrtc.service';
import { Subscription } from 'rxjs';
@@ -42,12 +47,23 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
}),
],
template: `
<div class="flex flex-col h-full">
<!-- Messages List -->
<div #messagesContainer class="flex-1 overflow-y-auto p-4 space-y-4 relative" (scroll)="onScroll()">
<!-- Syncing indicator -->
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
<span>Syncing messages…</span>
</div>
}
@if (loading()) {
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
@@ -58,8 +74,21 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
<!-- Infinite scroll: load-more sentinel at top -->
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
} @else {
<button (click)="loadMore()" class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary">
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<div
[attr.data-message-id]="message.id"
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
[class.opacity-50]="message.isDeleted"
>
@@ -70,6 +99,20 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
<!-- Message Content -->
<div class="flex-1 min-w-0">
<!-- Reply indicator -->
@if (message.replyToId) {
@let repliedMsg = getRepliedMessage(message.replyToId);
<div class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (click)="scrollToMessage(message.replyToId)">
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
<ng-icon name="lucideReply" class="w-3 h-3" />
@if (repliedMsg) {
<span class="font-medium">{{ repliedMsg.senderName }}</span>
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
<span class="text-xs text-muted-foreground">
@@ -110,20 +153,69 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
@for (att of getAttachments(message.id); track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<img [src]="att.objectUrl" alt="image" class="rounded-md max-h-80 w-auto" />
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<!-- Available image with hover overlay -->
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="rounded-md max-h-80 w-auto cursor-pointer"
(click)="openLightbox(att)"
/>
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="View full size"
>
<ng-icon name="lucideExpand" class="w-4 h-4" />
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-4 h-4" />
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<!-- Downloading in progress -->
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-primary" />
</div>
<div class="text-xs text-muted-foreground">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 rounded bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
</div>
} @else {
<!-- Unavailable — waiting for source -->
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-muted-foreground" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
</div>
</div>
<button
(click)="retryImageRequest(att, message.id)"
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
>
Retry
</button>
</div>
}
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
@@ -357,6 +449,76 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
</div>
</div>
</div>
<!-- Image Lightbox Modal -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
#lightboxBackdrop
>
<div class="relative max-w-[90vw] max-h-[90vh]" (click)="$event.stopPropagation()">
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<!-- Top-right action bar -->
<div class="absolute top-3 right-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-5 h-5" />
</button>
<button
(click)="closeLightbox()"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Close"
>
<ng-icon name="lucideX" class="w-5 h-5" />
</button>
</div>
<!-- Bottom info bar -->
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
<!-- Image Context Menu -->
@if (imageContextMenu()) {
<div class="fixed inset-0 z-[110]" (click)="closeImageContextMenu()"></div>
<div
class="fixed z-[120] bg-card border border-border rounded-lg shadow-lg w-48 py-1"
[style.left.px]="imageContextMenu()!.x"
[style.top.px]="imageContextMenu()!.y"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment)"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
>
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
Copy Image
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
>
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
Save Image
</button>
</div>
}
`,
})
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@@ -368,11 +530,40 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private sanitizer = inject(DomSanitizer);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef);
messages = this.store.selectSignal(selectAllMessages);
private allMessages = this.store.selectSignal(selectAllMessages);
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
// --- Infinite scroll (upwards) pagination ---
private readonly PAGE_SIZE = 50;
displayLimit = signal(this.PAGE_SIZE);
loadingMore = signal(false);
/** All messages for the current channel (full list, unsliced) */
private allChannelMessages = computed(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(m =>
m.roomId === roomId && (m.channelId || 'general') === channelId
);
});
/** Paginated view — only the most recent `displayLimit` messages */
messages = computed(() => {
const all = this.allChannelMessages();
const limit = this.displayLimit();
if (all.length <= limit) return all;
return all.slice(all.length - limit);
});
/** Whether there are older messages that can be loaded */
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
loading = this.store.selectSignal(selectMessagesLoading);
syncing = this.store.selectSignal(selectMessagesSyncing);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
private currentRoom = this.store.selectSignal(selectCurrentRoom);
messageContent = '';
editContent = '';
@@ -383,6 +574,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
readonly commonEmojis = COMMON_EMOJIS;
private shouldScrollToBottom = true;
/** Keeps us pinned to bottom while images/attachments load after initial open */
private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: any = null;
private boundOnImageLoad: (() => void) | null = null;
/** True while a programmatic scroll-to-bottom is in progress (suppresses onScroll). */
private isAutoScrolling = false;
private typingSub?: Subscription;
private lastTypingSentAt = 0;
private readonly typingTTL = 3000; // ms to keep a user as typing
@@ -396,8 +593,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
typingOthersCount = signal<number>(0);
// New messages snackbar state
showNewMessagesBar = signal(false);
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
nowRef = signal<number>(Date.now());
// Plain (non-reactive) reference time used only by formatTimestamp.
// Updated periodically but NOT a signal, so it won't re-render every message.
private nowRef = Date.now();
private nowTimer: any;
toolbarVisible = signal(false);
private toolbarHovering = false;
@@ -405,16 +603,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
dragActive = signal(false);
// Cache blob URLs for proxied images to prevent repeated network fetches on re-render
private imageBlobCache = new Map<string, string>();
// Re-render when attachments update
private attachmentsUpdatedEffect = effect(() => {
// Subscribe to updates; no-op body
void this.attachmentsSvc.updated();
// Cache rendered markdown to preserve text selection across re-renders
private markdownCache = new Map<string, SafeHtml>();
// Image lightbox modal state
lightboxAttachment = signal<Attachment | null>(null);
// Image right-click context menu state
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
private boundOnKeydown: ((e: KeyboardEvent) => void) | null = null;
// Reset scroll state when room/server changes (handles reuse of component on navigation)
private onRoomChanged = effect(() => {
void this.currentRoom(); // track room signal
this.initialScrollPending = true;
this.stopInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE);
this.markdownCache.clear();
});
// Messages length signal and effect to detect new messages without blocking change detection
// Reset pagination when switching channels within the same room
private onChannelChanged = effect(() => {
void this.activeChannelId(); // track channel signal
this.displayLimit.set(this.PAGE_SIZE);
this.initialScrollPending = true;
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.markdownCache.clear();
});
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
private attachmentsUpdatedEffect = effect(() => {
void this.attachmentsSvc.updated();
this.cdr.markForCheck();
});
// Track total channel messages (not paginated) for new-message detection
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
messagesLength = computed(() => this.messages().length);
private onMessagesChanged = effect(() => {
const currentCount = this.messagesLength();
const currentCount = this.totalChannelMessagesLength();
const el = this.messagesContainer?.nativeElement;
if (!el) {
this.lastMessageCount = currentCount;
@@ -446,12 +674,23 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const el = this.messagesContainer?.nativeElement;
if (!el) return;
// First render after connect: scroll to bottom by default (no animation)
// First render after connect: scroll to bottom instantly (no animation)
// Only proceed once messages are actually rendered in the DOM
if (this.initialScrollPending) {
this.initialScrollPending = false;
this.scrollToBottom();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
if (this.messages().length > 0) {
this.initialScrollPending = false;
// Snap to bottom immediately, then keep watching for late layout changes
this.isAutoScrolling = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { this.isAutoScrolling = false; });
this.startInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
} else if (!this.loading()) {
// Room has no messages and loading is done
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
this.loadCspImages();
return;
}
@@ -468,21 +707,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
});
// If we're the uploader and our original file was lost (e.g., after navigation), prompt reselect
this.attachmentsSvc.onMissingOriginal.subscribe(({ messageId, fileId, fromPeerId }) => {
try {
const input = document.createElement('input');
input.type = 'file';
input.onchange = async () => {
const file = input.files?.[0];
if (file) {
await this.attachmentsSvc.fulfillRequestWithFile(messageId, fileId, fromPeerId, file);
}
};
input.click();
} catch {}
});
// Periodically purge expired typing entries
const purge = () => {
const now = Date.now();
@@ -502,18 +726,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
// Initialize message count for snackbar trigger
this.lastMessageCount = this.messages().length;
// Update reference time periodically (minute granularity)
// Update reference time silently (non-reactive) so formatTimestamp
// uses a reasonably fresh "now" without re-rendering every message.
this.nowTimer = setInterval(() => {
this.nowRef.set(Date.now());
this.nowRef = Date.now();
}, 60000);
// Global Escape key listener for lightbox & context menu
this.boundOnKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
}
};
document.addEventListener('keydown', this.boundOnKeydown);
}
ngOnDestroy(): void {
this.typingSub?.unsubscribe();
this.stopInitialScrollWatch();
if (this.nowTimer) {
clearInterval(this.nowTimer);
this.nowTimer = null;
}
if (this.boundOnKeydown) {
document.removeEventListener('keydown', this.boundOnKeydown);
}
}
sendMessage(): void {
@@ -526,6 +764,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
MessagesActions.sendMessage({
content,
replyToId: this.replyTo()?.id,
channelId: this.activeChannelId(),
})
);
@@ -589,6 +828,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.replyTo.set(null);
}
getRepliedMessage(messageId: string): Message | undefined {
return this.allMessages().find(m => m.id === messageId);
}
scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement;
if (!container) return;
const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-primary/10');
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
}
}
toggleEmojiPicker(messageId: string): void {
this.showEmojiPicker.update((current) =>
current === messageId ? null : messageId
@@ -641,20 +895,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date(this.nowRef());
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const now = new Date(this.nowRef);
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (days === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0) {
return time;
} else if (dayDiff === 1) {
return 'Yesterday ' + time;
} else if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
}
@@ -666,6 +921,63 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
}
/**
* Start observing the messages container for DOM mutations
* and image load events. Every time the container's content
* changes size (new nodes, images finishing load) we instantly
* snap to the bottom. Automatically stops after a timeout or
* when the user scrolls up.
*/
private startInitialScrollWatch(): void {
this.stopInitialScrollWatch(); // clean up any prior watcher
const el = this.messagesContainer?.nativeElement;
if (!el) return;
const snap = () => {
if (this.messagesContainer) {
const e = this.messagesContainer.nativeElement;
this.isAutoScrolling = true;
e.scrollTop = e.scrollHeight;
// Clear flag after browser fires the synchronous scroll event
requestAnimationFrame(() => { this.isAutoScrolling = false; });
}
};
// 1. MutationObserver catches new DOM nodes (attachments rendered, etc.)
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snap);
});
this.initialScrollObserver.observe(el, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src'], // img src swaps
});
// 2. Capture-phase 'load' listener catches images finishing load
this.boundOnImageLoad = () => requestAnimationFrame(snap);
el.addEventListener('load', this.boundOnImageLoad, true);
// 3. Auto-stop after 5s so we don't fight user scrolling
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
}
private stopInitialScrollWatch(): void {
if (this.initialScrollObserver) {
this.initialScrollObserver.disconnect();
this.initialScrollObserver = null;
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null;
}
if (this.initialScrollTimer) {
clearTimeout(this.initialScrollTimer);
this.initialScrollTimer = null;
}
}
private scrollToBottomSmooth(): void {
if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement;
@@ -688,12 +1000,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
onScroll(): void {
if (!this.messagesContainer) return;
// Ignore scroll events caused by programmatic snap-to-bottom
if (this.isAutoScrolling) return;
const el = this.messagesContainer.nativeElement;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
this.shouldScrollToBottom = distanceFromBottom <= 300;
if (this.shouldScrollToBottom) {
this.showNewMessagesBar.set(false);
}
// Any user-initiated scroll during the initial load period
// immediately hands control back to the user
if (this.initialScrollObserver) {
this.stopInitialScrollWatch();
}
// Infinite scroll upwards — load older messages when near the top
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
}
/** Load older messages by expanding the display window, preserving scroll position */
loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages()) return;
this.loadingMore.set(true);
const el = this.messagesContainer?.nativeElement;
const prevScrollHeight = el?.scrollHeight ?? 0;
this.displayLimit.update(limit => limit + this.PAGE_SIZE);
// After Angular renders the new messages, restore scroll position
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (el) {
const newScrollHeight = el.scrollHeight;
el.scrollTop += newScrollHeight - prevScrollHeight;
}
this.loadingMore.set(false);
});
});
}
private recomputeTypingDisplay(now: number): void {
@@ -707,8 +1053,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.typingOthersCount.set(others);
}
// Markdown rendering
// Markdown rendering (cached so re-renders don't replace innerHTML and kill text selection)
renderMarkdown(content: string): SafeHtml {
const cached = this.markdownCache.get(content);
if (cached) return cached;
marked.setOptions({ breaks: true });
const html = marked.parse(content ?? '') as string;
// Sanitize to a DOM fragment so we can post-process disallowed images
@@ -750,7 +1099,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
const safeHtml = DOMPurify.sanitize(container.innerHTML);
return this.sanitizer.bypassSecurityTrustHtml(safeHtml);
const result = this.sanitizer.bypassSecurityTrustHtml(safeHtml);
this.markdownCache.set(content, result);
return result;
}
// Resolve images marked for CSP-safe loading by converting to blob URLs
@@ -995,6 +1346,74 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
}
// ---- Image lightbox ----
openLightbox(att: Attachment): void {
if (att.available && att.objectUrl) {
this.lightboxAttachment.set(att);
}
}
closeLightbox(): void {
this.lightboxAttachment.set(null);
}
// ---- Image context menu ----
openImageContextMenu(event: MouseEvent, att: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
}
closeImageContextMenu(): void {
this.imageContextMenu.set(null);
}
async copyImageToClipboard(att: Attachment): Promise<void> {
this.closeImageContextMenu();
if (!att.objectUrl) return;
try {
const resp = await fetch(att.objectUrl);
const blob = await resp.blob();
// Convert to PNG for clipboard compatibility
const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob }),
]);
} catch (err) {
console.error('Failed to copy image to clipboard:', err);
}
}
private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
if (blob.type === 'image/png') {
resolve(blob);
return;
}
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) { reject(new Error('Canvas not supported')); return; }
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url);
if (pngBlob) resolve(pngBlob);
else reject(new Error('PNG conversion failed'));
}, 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
img.src = url;
});
}
retryImageRequest(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestImageFromAnyPeer(messageId, att);
}
private attachFilesToLastOwnMessage(content: string): void {
const me = this.currentUser()?.id;
if (!me) return;