Remote connection
This commit is contained in:
@@ -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)">`</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 = ``;
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user