diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index befd1b7..ac0c5c3 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -1,5 +1,6 @@ import { app, + clipboard, desktopCapturer, dialog, ipcMain, @@ -7,6 +8,8 @@ import { } from 'electron'; import * as fs from 'fs'; import * as fsp from 'fs/promises'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; import { getDesktopSettingsSnapshot, updateDesktopSettings } from '../desktop-settings'; import { activateLinuxScreenShareAudioRouting, @@ -16,6 +19,166 @@ import { stopLinuxScreenShareMonitorCapture } from '../audio/linux-screen-share-routing'; +const DEFAULT_MIME_TYPE = 'application/octet-stream'; +const FILE_CLIPBOARD_FORMATS = [ + 'x-special/gnome-copied-files', + 'text/uri-list', + 'public.file-url', + 'FileNameW' +] as const; +const MIME_TYPES_BY_EXTENSION: Record = { + '.7z': 'application/x-7z-compressed', + '.aac': 'audio/aac', + '.avi': 'video/x-msvideo', + '.bmp': 'image/bmp', + '.csv': 'text/csv', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.gif': 'image/gif', + '.gz': 'application/gzip', + '.heic': 'image/heic', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.json': 'application/json', + '.m4a': 'audio/mp4', + '.md': 'text/markdown', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.ogg': 'audio/ogg', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.rar': 'application/vnd.rar', + '.svg': 'image/svg+xml', + '.tar': 'application/x-tar', + '.txt': 'text/plain', + '.wav': 'audio/wav', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xml': 'application/xml', + '.zip': 'application/zip' +}; + +interface ClipboardFilePayload { + data: string; + lastModified: number; + mime: string; + name: string; + path?: string; +} + +function isSupportedClipboardFileFormat(format: string): boolean { + return FILE_CLIPBOARD_FORMATS.some( + (supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase() + ); +} + +function extractClipboardFilePaths(buffer: Buffer, format: string): string[] { + const textVariants = new Set(); + const utf8Text = buffer.toString('utf8').replace(/\0/g, '').trim(); + + if (utf8Text) { + textVariants.add(utf8Text); + } + + if (format.toLowerCase() === 'filenamew') { + const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n').trim(); + + if (utf16Text) { + textVariants.add(utf16Text); + } + } + + const filePaths = new Set(); + + for (const text of textVariants) { + for (const filePath of parseClipboardFilePathText(text)) { + filePaths.add(filePath); + } + } + + return [...filePaths]; +} + +function parseClipboardFilePathText(text: string): string[] { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter( + (line) => + !!line && + !line.startsWith('#') && + line.toLowerCase() !== 'copy' && + line.toLowerCase() !== 'cut' + ) + .flatMap((line) => { + if (line.startsWith('file://')) { + try { + return [fileURLToPath(line)]; + } catch { + return []; + } + } + + return path.isAbsolute(line) ? [line] : []; + }); +} + +function inferMimeType(filePath: string): string { + return MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? DEFAULT_MIME_TYPE; +} + +async function readClipboardFiles(): Promise { + const formats = clipboard.availableFormats(); + const filePaths = new Set(); + + for (const format of formats) { + if (!isSupportedClipboardFileFormat(format)) { + continue; + } + + try { + const buffer = clipboard.readBuffer(format); + + for (const filePath of extractClipboardFilePaths(buffer, format)) { + filePaths.add(filePath); + } + } catch { + /* ignore unreadable clipboard formats */ + } + } + + const files: ClipboardFilePayload[] = []; + + for (const filePath of filePaths) { + try { + const stats = await fsp.stat(filePath); + + if (!stats.isFile()) { + continue; + } + + const data = await fsp.readFile(filePath); + + files.push({ + data: data.toString('base64'), + lastModified: Math.round(stats.mtimeMs), + mime: inferMimeType(filePath), + name: path.basename(filePath), + path: filePath + }); + } catch { + /* ignore files that are no longer readable */ + } + } + + return files; +} + export function setupSystemHandlers(): void { ipcMain.handle('open-external', async (_event, url: string) => { if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) { @@ -88,6 +251,10 @@ export function setupSystemHandlers(): void { return data.toString('base64'); }); + ipcMain.handle('read-clipboard-files', async () => { + return await readClipboardFiles(); + }); + ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => { const buffer = Buffer.from(base64Data, 'base64'); diff --git a/electron/preload.ts b/electron/preload.ts index 4dfbff3..6e541ae 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -32,6 +32,14 @@ export interface LinuxScreenShareMonitorAudioEndedPayload { reason?: string; } +export interface ClipboardFilePayload { + data: string; + lastModified: number; + mime: string; + name: string; + path?: string; +} + export interface ElectronAPI { minimizeWindow: () => void; maximizeWindow: () => void; @@ -58,6 +66,7 @@ export interface ElectronAPI { restartRequired: boolean; }>; relaunchApp: () => Promise; + readClipboardFiles: () => Promise; readFile: (filePath: string) => Promise; writeFile: (filePath: string, data: string) => Promise; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; @@ -114,6 +123,7 @@ const electronAPI: ElectronAPI = { getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'), setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch), relaunchApp: () => ipcRenderer.invoke('relaunch-app'), + readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'), readFile: (filePath) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data), diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html index 4244302..718e660 100644 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html @@ -178,6 +178,7 @@ (blur)="onInputBlur()" (keydown.enter)="onEnter($event)" (input)="onInputChange(); autoResizeTextarea()" + (paste)="onPaste($event)" (dragenter)="onDragEnter($event)" (dragover)="onDragOver($event)" (dragleave)="onDragLeave($event)" diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts index f5d4756..5c09e49 100644 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts +++ b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts @@ -25,6 +25,26 @@ import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indic import { ChatMarkdownService } from '../../services/chat-markdown.service'; import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models'; +interface ClipboardFilePayload { + data: string; + lastModified: number; + mime: string; + name: string; + path?: string; +} + +interface ClipboardElectronApi { + readClipboardFiles?: () => Promise; +} + +type ClipboardWindow = Window & { + electronAPI?: ClipboardElectronApi; +}; + +type LocalFileWithPath = File & { + path?: string; +}; + @Component({ selector: 'app-chat-message-composer', standalone: true, @@ -256,7 +276,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { } onDragEnter(event: DragEvent): void { - if (!this.hasPotentialFilePayload(event)) + if (!this.hasPotentialFilePayload(event.dataTransfer)) return; event.preventDefault(); @@ -266,7 +286,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { } onDragOver(event: DragEvent): void { - if (!this.hasPotentialFilePayload(event)) + if (!this.hasPotentialFilePayload(event.dataTransfer)) return; event.preventDefault(); @@ -296,17 +316,30 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { event.preventDefault(); event.stopPropagation(); this.dragDepth = 0; - const droppedFiles = this.extractDroppedFiles(event); + const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer); if (droppedFiles.length === 0) { this.dragActive.set(false); return; } - this.pendingFiles.push(...droppedFiles); - this.toolbarVisible.set(true); + this.addPendingFiles(droppedFiles); this.dragActive.set(false); - this.emitHeight(); + } + + async onPaste(event: ClipboardEvent): Promise { + if (!this.hasPotentialFilePayload(event.clipboardData, false)) + return; + + event.preventDefault(); + event.stopPropagation(); + + const pastedFiles = await this.extractPastedFiles(event); + + if (pastedFiles.length === 0) + return; + + this.addPendingFiles(pastedFiles); } autoResizeTextarea(): void { @@ -375,8 +408,26 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { } } - private hasPotentialFilePayload(event: DragEvent): boolean { - const dataTransfer = event.dataTransfer; + private addPendingFiles(files: File[]): void { + if (files.length === 0) + return; + + const mergedFiles = this.mergeUniqueFiles(this.pendingFiles, files); + + if (mergedFiles.length === this.pendingFiles.length) + return; + + this.pendingFiles = mergedFiles; + this.toolbarVisible.set(true); + this.emitHeight(); + + requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus()); + } + + private hasPotentialFilePayload( + dataTransfer: DataTransfer | null, + treatMissingTypesAsPotentialFile = true + ): boolean { if (!dataTransfer) return false; @@ -397,14 +448,15 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { const types = dataTransfer.types; if (!types || types.length === 0) - return true; + return treatMissingTypesAsPotentialFile; for (const type of types) { if ( type === 'Files' || type === 'application/x-moz-file' || type === 'public.file-url' || - type === 'text/uri-list' + type === 'text/uri-list' || + type === 'x-special/gnome-copied-files' ) { return true; } @@ -413,9 +465,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { return false; } - private extractDroppedFiles(event: DragEvent): File[] { - const droppedFiles: File[] = []; - const items = event.dataTransfer?.items ?? null; + private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] { + const extractedFiles: File[] = []; + const items = dataTransfer?.items ?? null; if (items && items.length) { for (const item of items) { @@ -423,32 +475,128 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { const file = item.getAsFile(); if (file) { - droppedFiles.push(file); + this.pushUniqueFile(extractedFiles, file); } } } } - const files = event.dataTransfer?.files; + const files = dataTransfer?.files; if (!files?.length) - return droppedFiles; + return extractedFiles; for (const file of files) { - const exists = droppedFiles.some( - (existingFile) => - existingFile.name === file.name && - existingFile.size === file.size && - existingFile.type === file.type && - existingFile.lastModified === file.lastModified - ); + this.pushUniqueFile(extractedFiles, file); + } - if (!exists) { - droppedFiles.push(file); + return extractedFiles; + } + + private mergeUniqueFiles(existingFiles: File[], incomingFiles: File[]): File[] { + const mergedFiles = [...existingFiles]; + + for (const file of incomingFiles) { + this.pushUniqueFile(mergedFiles, file); + } + + return mergedFiles; + } + + private pushUniqueFile(target: File[], candidate: File): void { + const exists = target.some((file) => this.areFilesEquivalent(file, candidate)); + + if (!exists) { + target.push(candidate); + } + } + + private areFilesEquivalent(left: File, right: File): boolean { + const leftPath = this.getLocalFilePath(left); + const rightPath = this.getLocalFilePath(right); + + if (leftPath && rightPath) { + return leftPath === rightPath; + } + + if (left.name !== right.name || left.size !== right.size) { + return false; + } + + const leftType = left.type.trim(); + const rightType = right.type.trim(); + + if (leftType && rightType && leftType !== rightType) { + return false; + } + + const leftLastModified = Number.isFinite(left.lastModified) ? left.lastModified : 0; + const rightLastModified = Number.isFinite(right.lastModified) ? right.lastModified : 0; + + if (!leftLastModified || !rightLastModified) { + return true; + } + + return Math.abs(leftLastModified - rightLastModified) <= 1000; + } + + private getLocalFilePath(file: File): string { + return ((file as LocalFileWithPath).path || '').trim(); + } + + private async extractPastedFiles(event: ClipboardEvent): Promise { + const directFiles = this.extractFilesFromTransfer(event.clipboardData); + + if (directFiles.length > 0) + return directFiles; + + return await this.readFilesFromElectronClipboard(); + } + + private async readFilesFromElectronClipboard(): Promise { + const electronApi = (window as ClipboardWindow).electronAPI; + + if (!electronApi?.readClipboardFiles) + return []; + + try { + const clipboardFiles = await electronApi.readClipboardFiles(); + + return clipboardFiles.map((clipboardFile) => this.createFileFromClipboardPayload(clipboardFile)); + } catch { + return []; + } + } + + private createFileFromClipboardPayload(payload: ClipboardFilePayload): File { + const file = new File([this.base64ToArrayBuffer(payload.data)], payload.name, { + lastModified: payload.lastModified, + type: payload.mime + }); + + if (payload.path) { + try { + Object.defineProperty(file, 'path', { + configurable: true, + value: payload.path + }); + } catch { + (file as LocalFileWithPath).path = payload.path; } } - return droppedFiles; + return file; + } + + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + + for (let index = 0; index < binaryString.length; index++) { + bytes[index] = binaryString.charCodeAt(index); + } + + return bytes.buffer; } private buildOutgoingMessageContent(raw: string): string { diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html index 3ccc904..67f24ef 100644 --- a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html +++ b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html @@ -42,32 +42,36 @@ @if (isEditing()) { -
- + +
+ + +
} @else { @if (msg.isDeleted) { diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.scss b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.scss index c246434..0dc88ea 100644 --- a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.scss +++ b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.scss @@ -179,3 +179,11 @@ } } } + +.edit-textarea { + min-height: 42px; + max-height: 520px; + overflow-y: hidden; + resize: none; + transition: height 0.12s ease; +} diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts index 0de80ce..c744047 100644 --- a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts +++ b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts @@ -4,11 +4,13 @@ import { FormsModule } from '@angular/forms'; import { Component, computed, + ElementRef, effect, inject, input, output, - signal + signal, + ViewChild } from '@angular/core'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { @@ -122,6 +124,8 @@ interface ChatMessageAttachmentViewModel extends Attachment { } }) export class ChatMessageItemComponent { + @ViewChild('editTextareaRef') editTextareaRef?: ElementRef; + private readonly attachmentsSvc = inject(AttachmentService); private readonly klipy = inject(KlipyService); private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); @@ -169,6 +173,28 @@ export class ChatMessageItemComponent { startEdit(): void { this.editContent = this.message().content; this.isEditing.set(true); + + requestAnimationFrame(() => { + this.autoResizeEditTextarea(); + + const element = this.editTextareaRef?.nativeElement; + + if (!element) + return; + + element.focus(); + element.setSelectionRange(element.value.length, element.value.length); + }); + } + + onEditEnter(event: Event): void { + const keyEvent = event as KeyboardEvent; + + if (keyEvent.shiftKey) + return; + + keyEvent.preventDefault(); + this.saveEdit(); } saveEdit(): void { @@ -188,6 +214,17 @@ export class ChatMessageItemComponent { this.editContent = ''; } + autoResizeEditTextarea(): void { + const element = this.editTextareaRef?.nativeElement; + + if (!element) + return; + + element.style.height = 'auto'; + element.style.height = Math.min(element.scrollHeight, 520) + 'px'; + element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden'; + } + toggleEmojiPicker(): void { this.showEmojiPicker.update((current) => !current); }