Remote connection

This commit is contained in:
2026-01-09 19:49:38 +01:00
parent 87c722b5ae
commit 8c551a90f4
28 changed files with 3134 additions and 327 deletions

View File

@@ -2,6 +2,7 @@ import { Component, inject, signal, computed, effect, ElementRef, ViewChild, Aft
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AttachmentService, Attachment } from '../../core/services/attachment.service';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSend,
@@ -20,6 +21,10 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/u
import { Message } from '../../core/models';
import { WebRTCService } from '../../core/services/webrtc.service';
import { Subscription } from 'rxjs';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
@@ -99,9 +104,73 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
</button>
</div>
} @else {
<p class="text-foreground break-words whitespace-pre-wrap mt-1">
{{ message.content }}
</p>
<div class="prose prose-invert mt-1 break-words" (click)="onContentClick($event)" [innerHTML]="renderMarkdown(message.content)"></div>
@if (getAttachments(message.id).length > 0) {
<div class="mt-2 space-y-2">
@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>
</div>
<div class="text-xs text-muted-foreground">{{ ((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>
</div>
}
} @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>
</div>
<div class="flex items-center gap-2">
@if (!isUploader(att)) {
@if (!att.available) {
<div class="w-24 h-1.5 rounded bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-2">
<span>{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>Request</button>
} @else {
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>Cancel</button>
}
} @else {
<button
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
(click)="downloadAttachment(att)"
>Download</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</div>
</div>
</div>
}
}
</div>
}
}
<!-- Reactions -->
@@ -216,20 +285,71 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
</div>
}
<!-- Markdown Toolbar -->
@if (toolbarVisible()) {
<div class="pointer-events-auto" (mousedown)="$event.preventDefault()" (mouseenter)="onToolbarMouseEnter()" (mouseleave)="onToolbarMouseLeave()">
<div class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm">
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('**')"><b>B</b></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('*')"><i>I</i></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('~~')"><s>S</s></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline(inlineCodeToken)">&#96;</button>
<span class="mx-1 text-muted-foreground">|</span>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(1)">H1</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(2)">H2</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(3)">H3</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('> ')">Quote</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('- ')">• List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyOrderedList()">1. List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyCodeBlock()">Code</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyLink()">Link</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyImage()">Image</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHorizontalRule()">HR</button>
</div>
</div>
}
<!-- Message Input -->
<div class="p-4 border-t border-border">
<div class="flex gap-2">
<input
type="text"
[(ngModel)]="messageContent"
(keydown.enter)="sendMessage()"
(input)="onInputChange()"
placeholder="Type a message..."
class="flex-1 px-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div class="flex gap-2 items-start">
<div class="relative flex-1">
<textarea
#messageInputRef
rows="2"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange()"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message... (Markdown supported)"
class="w-full px-3 py-1 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
[class.border-primary]="dragActive()"
[class.border-dashed]="dragActive()"
></textarea>
@if (dragActive()) {
<div class="pointer-events-none absolute inset-0 rounded-lg border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center">
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button (click)="removePendingFile(file)" class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5">Remove</button>
</div>
}
</div>
}
</div>
<button
(click)="sendMessage()"
[disabled]="!messageContent.trim()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ng-icon name="lucideSend" class="w-4 h-4" />
@@ -241,9 +361,13 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
})
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
private store = inject(Store);
private webrtc = inject(WebRTCService);
private sanitizer = inject(DomSanitizer);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
messages = this.store.selectSignal(selectAllMessages);
loading = this.store.selectSignal(selectMessagesLoading);
@@ -264,6 +388,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private readonly typingTTL = 3000; // ms to keep a user as typing
private lastMessageCount = 0;
private initialScrollPending = true;
pendingFiles: File[] = [];
// Track typing users by name and expire them
private typingMap = new Map<string, { name: string; expiresAt: number }>();
@@ -274,6 +399,17 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
nowRef = signal<number>(Date.now());
private nowTimer: any;
toolbarVisible = signal(false);
private toolbarHovering = false;
inlineCodeToken = '`';
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();
});
// Messages length signal and effect to detect new messages without blocking change detection
messagesLength = computed(() => this.messages().length);
@@ -316,8 +452,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.scrollToBottom();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
this.loadCspImages();
return;
}
// Attempt to resolve any deferred images after each check
this.loadCspImages();
}
ngOnInit(): void {
@@ -329,6 +468,21 @@ 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();
@@ -363,11 +517,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
sendMessage(): void {
if (!this.messageContent.trim()) return;
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0) return;
const content = this.appendImageMarkdown(raw);
this.store.dispatch(
MessagesActions.sendMessage({
content: this.messageContent.trim(),
content,
replyToId: this.replyTo()?.id,
})
);
@@ -376,6 +533,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.clearReply();
this.shouldScrollToBottom = true;
this.showNewMessagesBar.set(false);
if (this.pendingFiles.length > 0) {
// Wait briefly for the message to appear in the list, then attach
setTimeout(() => this.attachFilesToLastOwnMessage(content), 100);
}
}
onInputChange(): void {
@@ -545,6 +707,378 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.typingOthersCount.set(others);
}
// Markdown rendering
renderMarkdown(content: string): SafeHtml {
marked.setOptions({ breaks: true });
const html = marked.parse(content ?? '') as string;
// Sanitize to a DOM fragment so we can post-process disallowed images
const frag = DOMPurify.sanitize(html, { RETURN_DOM_FRAGMENT: true }) as DocumentFragment;
const container = document.createElement('div');
container.appendChild(frag);
const imgs = Array.from(container.querySelectorAll('img'));
for (const img of imgs) {
const src = img.getAttribute('src') || '';
const isData = src.startsWith('data:');
const isBlob = src.startsWith('blob:');
let isSameOrigin = false;
try {
const resolved = new URL(src, window.location.href);
isSameOrigin = resolved.origin === window.location.origin;
} catch {
// non-URL values are treated as not allowed
isSameOrigin = false;
}
// Rewrite external images to deferred proxy and load as blob to satisfy CSP
if (!(isData || isBlob || isSameOrigin)) {
const apiBase = this.serverDirectory.getApiBaseUrl();
// Robust join to avoid relative paths and double slashes
const baseWithSlash = apiBase.endsWith('/') ? apiBase : apiBase + '/';
const proxied = new URL(`image-proxy?url=${encodeURIComponent(src)}`, baseWithSlash).toString();
img.setAttribute('data-src', proxied);
img.classList.add('csp-img');
// Tiny transparent placeholder to avoid empty src fetch
img.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAAAAACw=');
}
// Apply reasonable sizing and lazy loading
img.setAttribute('loading', 'lazy');
img.classList.add('rounded-md');
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.maxHeight = '320px';
}
const safeHtml = DOMPurify.sanitize(container.innerHTML);
return this.sanitizer.bypassSecurityTrustHtml(safeHtml);
}
// Resolve images marked for CSP-safe loading by converting to blob URLs
private async loadCspImages(): Promise<void> {
const root = this.messagesContainer?.nativeElement;
if (!root) return;
const imgs = Array.from(root.querySelectorAll('img.csp-img[data-src]')) as HTMLImageElement[];
if (imgs.length === 0) return;
for (const img of imgs) {
const url = img.getAttribute('data-src');
if (!url) continue;
try {
// Use cached blob URL if available to avoid refetching
const cached = this.imageBlobCache.get(url);
if (cached) {
img.src = cached;
} else {
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) continue;
const blob = await res.blob();
const obj = URL.createObjectURL(blob);
this.imageBlobCache.set(url, obj);
img.src = obj;
}
img.removeAttribute('data-src');
img.classList.remove('csp-img');
} catch {}
}
}
// Markdown toolbar actions
onEnter(evt: Event): void {
const e = evt as KeyboardEvent;
if (e.shiftKey) {
// allow newline
return;
}
e.preventDefault();
this.sendMessage();
}
private getSelection(): { start: number; end: number } {
const el = this.messageInputRef?.nativeElement;
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
}
private setSelection(start: number, end: number): void {
const el = this.messageInputRef?.nativeElement;
if (el) {
el.selectionStart = start;
el.selectionEnd = end;
el.focus();
}
}
applyInline(token: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
this.messageContent = newText;
const cursor = before.length + token.length + selected.length + token.length;
this.setSelection(cursor, cursor);
}
applyPrefix(prefix: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
applyHeading(level: number): void {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'Heading';
const after = this.messageContent.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const newText = `${before}${block}${after}`;
this.messageContent = newText;
const cursor = before.length + block.length;
this.setSelection(cursor, cursor);
}
applyOrderedList(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'item\nitem';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map((line, i) => `${i + 1}. ${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
applyCodeBlock(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'code';
const after = this.messageContent.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const newText = `${before}${fenced}${after}`;
this.messageContent = newText;
const cursor = before.length + fenced.length;
this.setSelection(cursor, cursor);
}
applyLink(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'link';
const after = this.messageContent.slice(end);
const link = `[${selected}](https://)`;
const newText = `${before}${link}${after}`;
this.messageContent = newText;
const cursorStart = before.length + link.length - 1; // position inside url
this.setSelection(cursorStart - 8, cursorStart - 1);
}
applyImage(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'alt';
const after = this.messageContent.slice(end);
const img = `![${selected}](https://)`;
const newText = `${before}${img}${after}`;
this.messageContent = newText;
const cursorStart = before.length + img.length - 1;
this.setSelection(cursorStart - 8, cursorStart - 1);
}
applyHorizontalRule(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const after = this.messageContent.slice(end);
const hr = `\n\n---\n\n`;
const newText = `${before}${hr}${after}`;
this.messageContent = newText;
const cursor = before.length + hr.length;
this.setSelection(cursor, cursor);
}
// Attachments: drag/drop and rendering
onDragEnter(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(true);
}
onDragOver(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(true);
}
onDragLeave(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(false);
}
onDrop(evt: DragEvent): void {
evt.preventDefault();
const files: File[] = [];
const items = evt.dataTransfer?.items;
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
}
} else if (evt.dataTransfer?.files?.length) {
for (let i = 0; i < evt.dataTransfer.files.length; i++) {
files.push(evt.dataTransfer.files[i]);
}
}
files.forEach((f) => this.pendingFiles.push(f));
// Keep toolbar visible so user sees options
this.toolbarVisible.set(true);
this.dragActive.set(false);
}
getAttachments(messageId: string): Attachment[] {
return this.attachmentsSvc.getForMessage(messageId);
}
formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let i = 0;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(1)} ${units[i]}`;
}
formatSpeed(bps?: number): string {
if (!bps || bps <= 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let speed = bps;
let i = 0;
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
}
removePendingFile(file: File): void {
const idx = this.pendingFiles.findIndex((f) => f === file);
if (idx >= 0) {
this.pendingFiles.splice(idx, 1);
}
}
downloadAttachment(att: Attachment): void {
if (!att.available || !att.objectUrl) return;
const a = document.createElement('a');
a.href = att.objectUrl;
a.download = att.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
requestAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestFile(messageId, att);
}
cancelAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.cancelRequest(messageId, att);
}
isUploader(att: Attachment): boolean {
const myUserId = this.currentUser()?.id;
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
}
private attachFilesToLastOwnMessage(content: string): void {
const me = this.currentUser()?.id;
if (!me) return;
const msg = [...this.messages()].reverse().find((m) => m.senderId === me && m.content === content && !m.isDeleted);
if (!msg) {
// Retry shortly until message appears
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
return;
}
const uploaderPeerId = this.currentUser()?.id || undefined;
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
this.pendingFiles = [];
}
// Detect image URLs and append Markdown embeds at the end
private appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
// Skip if already embedded as a Markdown image
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
onContentClick(evt: Event): void {
const target = evt.target as HTMLElement;
if (target && target.tagName.toLowerCase() === 'a') {
evt.preventDefault();
const href = (target as HTMLAnchorElement).href;
try {
const w: any = window as any;
if (w?.process?.type === 'renderer' && typeof w.require === 'function') {
const { shell } = w.require('electron');
shell.openExternal(href);
} else {
window.open(href, '_blank', 'noopener,noreferrer');
}
} catch {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}
onInputFocus(): void {
this.toolbarVisible.set(true);
}
onInputBlur(): void {
setTimeout(() => {
if (!this.toolbarHovering) {
this.toolbarVisible.set(false);
}
}, 150);
}
onToolbarMouseEnter(): void {
this.toolbarHovering = true;
}
onToolbarMouseLeave(): void {
this.toolbarHovering = false;
if (document.activeElement !== this.messageInputRef?.nativeElement) {
this.toolbarVisible.set(false);
}
}
// Snackbar: scroll to latest
readLatest(): void {
this.shouldScrollToBottom = true;