Big commit
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user