643 lines
16 KiB
TypeScript
643 lines
16 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import {
|
|
AfterViewInit,
|
|
Component,
|
|
ElementRef,
|
|
OnDestroy,
|
|
ViewChild,
|
|
inject,
|
|
input,
|
|
output,
|
|
signal
|
|
} from '@angular/core';
|
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
import {
|
|
lucideImage,
|
|
lucideReply,
|
|
lucideSend,
|
|
lucideX
|
|
} from '@ng-icons/lucide';
|
|
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
|
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
|
import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
|
|
import { Message } from '../../../../../../shared-kernel';
|
|
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
|
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
|
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
|
|
|
type LocalFileWithPath = File & {
|
|
path?: string;
|
|
};
|
|
|
|
const DEFAULT_TEXTAREA_HEIGHT = 62;
|
|
|
|
@Component({
|
|
selector: 'app-chat-message-composer',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
NgIcon,
|
|
TypingIndicatorComponent
|
|
],
|
|
viewProviders: [
|
|
provideIcons({
|
|
lucideImage,
|
|
lucideReply,
|
|
lucideSend,
|
|
lucideX
|
|
})
|
|
],
|
|
templateUrl: './chat-message-composer.component.html',
|
|
styleUrl: './chat-message-composer.component.scss',
|
|
host: {
|
|
'(document:keydown)': 'onDocKeydown($event)',
|
|
'(document:keyup)': 'onDocKeyup($event)'
|
|
}
|
|
})
|
|
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
|
|
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
|
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
|
|
|
|
readonly replyTo = input<Message | null>(null);
|
|
readonly showKlipyGifPicker = input(false);
|
|
|
|
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
|
readonly typingStarted = output();
|
|
readonly replyCleared = output();
|
|
readonly heightChanged = output<number>();
|
|
readonly klipyGifPickerToggleRequested = output();
|
|
|
|
readonly klipy = inject(KlipyService);
|
|
private readonly markdown = inject(ChatMarkdownService);
|
|
private readonly electronBridge = inject(ElectronBridgeService);
|
|
|
|
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
|
readonly toolbarVisible = signal(false);
|
|
readonly dragActive = signal(false);
|
|
readonly inputHovered = signal(false);
|
|
readonly ctrlHeld = signal(false);
|
|
readonly textareaExpanded = signal(false);
|
|
|
|
messageContent = '';
|
|
pendingFiles: File[] = [];
|
|
inlineCodeToken = '`';
|
|
|
|
private toolbarHovering = false;
|
|
private dragDepth = 0;
|
|
private lastTypingSentAt = 0;
|
|
private resizeObserver: ResizeObserver | null = null;
|
|
|
|
ngAfterViewInit(): void {
|
|
this.autoResizeTextarea();
|
|
this.observeHeight();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.resizeObserver?.disconnect();
|
|
this.resizeObserver = null;
|
|
}
|
|
|
|
sendMessage(): void {
|
|
const raw = this.messageContent.trim();
|
|
|
|
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
|
return;
|
|
|
|
const content = this.buildOutgoingMessageContent(raw);
|
|
|
|
this.messageSubmitted.emit({
|
|
content,
|
|
pendingFiles: [...this.pendingFiles]
|
|
});
|
|
|
|
this.messageContent = '';
|
|
this.pendingFiles = [];
|
|
this.pendingKlipyGif.set(null);
|
|
this.replyCleared.emit();
|
|
|
|
requestAnimationFrame(() => {
|
|
this.autoResizeTextarea();
|
|
this.messageInputRef?.nativeElement.focus();
|
|
});
|
|
}
|
|
|
|
onInputChange(): void {
|
|
const now = Date.now();
|
|
|
|
if (now - this.lastTypingSentAt > 1000) {
|
|
this.typingStarted.emit();
|
|
this.lastTypingSentAt = now;
|
|
}
|
|
}
|
|
|
|
clearReply(): void {
|
|
this.replyCleared.emit();
|
|
}
|
|
|
|
onEnter(event: Event): void {
|
|
const keyEvent = event as KeyboardEvent;
|
|
|
|
if (keyEvent.shiftKey)
|
|
return;
|
|
|
|
keyEvent.preventDefault();
|
|
this.sendMessage();
|
|
}
|
|
|
|
applyInline(token: string): void {
|
|
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
applyPrefix(prefix: string): void {
|
|
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
applyHeading(level: number): void {
|
|
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
applyOrderedList(): void {
|
|
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
applyCodeBlock(): void {
|
|
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
applyLink(): void {
|
|
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
applyImage(): void {
|
|
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
applyHorizontalRule(): void {
|
|
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
|
|
|
|
this.messageContent = result.text;
|
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
|
}
|
|
|
|
toggleKlipyGifPicker(): void {
|
|
if (!this.klipy.isEnabled())
|
|
return;
|
|
|
|
this.klipyGifPickerToggleRequested.emit();
|
|
}
|
|
|
|
getKlipyTriggerRect(): DOMRect | null {
|
|
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
|
}
|
|
|
|
handleKlipyGifSelected(gif: KlipyGif): void {
|
|
this.pendingKlipyGif.set(gif);
|
|
|
|
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
|
|
this.sendMessage();
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
|
}
|
|
|
|
removePendingKlipyGif(): void {
|
|
this.pendingKlipyGif.set(null);
|
|
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
|
}
|
|
|
|
getPendingKlipyGifPreviewUrl(): string {
|
|
const gif = this.pendingKlipyGif();
|
|
|
|
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
|
|
}
|
|
|
|
formatBytes(bytes: number): string {
|
|
const units = [
|
|
'B',
|
|
'KB',
|
|
'MB',
|
|
'GB'
|
|
];
|
|
|
|
let size = bytes;
|
|
let unitIndex = 0;
|
|
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024;
|
|
unitIndex++;
|
|
}
|
|
|
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
removePendingFile(file: File): void {
|
|
const index = this.pendingFiles.findIndex((pendingFile) => pendingFile === file);
|
|
|
|
if (index >= 0) {
|
|
this.pendingFiles.splice(index, 1);
|
|
this.emitHeight();
|
|
}
|
|
}
|
|
|
|
onDragEnter(event: DragEvent): void {
|
|
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
|
return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.dragDepth++;
|
|
this.dragActive.set(true);
|
|
}
|
|
|
|
onDragOver(event: DragEvent): void {
|
|
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
|
return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
|
|
this.dragActive.set(true);
|
|
}
|
|
|
|
onDragLeave(event: DragEvent): void {
|
|
if (!this.dragActive())
|
|
return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.dragDepth = Math.max(0, this.dragDepth - 1);
|
|
|
|
if (this.dragDepth === 0) {
|
|
this.dragActive.set(false);
|
|
}
|
|
}
|
|
|
|
onDrop(event: DragEvent): void {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.dragDepth = 0;
|
|
const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer);
|
|
|
|
if (droppedFiles.length === 0) {
|
|
this.dragActive.set(false);
|
|
return;
|
|
}
|
|
|
|
this.addPendingFiles(droppedFiles);
|
|
this.dragActive.set(false);
|
|
}
|
|
|
|
async onPaste(event: ClipboardEvent): Promise<void> {
|
|
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 {
|
|
const element = this.messageInputRef?.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';
|
|
this.syncTextareaExpandedState();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
onDocKeydown(event: KeyboardEvent): void {
|
|
if (event.key === 'Control') {
|
|
this.ctrlHeld.set(true);
|
|
}
|
|
}
|
|
|
|
onDocKeyup(event: KeyboardEvent): void {
|
|
if (event.key === 'Control') {
|
|
this.ctrlHeld.set(false);
|
|
}
|
|
}
|
|
|
|
private getSelection(): { start: number; end: number } {
|
|
const element = this.messageInputRef?.nativeElement;
|
|
|
|
return {
|
|
start: element?.selectionStart ?? this.messageContent.length,
|
|
end: element?.selectionEnd ?? this.messageContent.length
|
|
};
|
|
}
|
|
|
|
private setSelection(start: number, end: number): void {
|
|
const element = this.messageInputRef?.nativeElement;
|
|
|
|
if (element) {
|
|
element.selectionStart = start;
|
|
element.selectionEnd = end;
|
|
element.focus();
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
if (dataTransfer.files?.length)
|
|
return true;
|
|
|
|
const items = dataTransfer.items;
|
|
|
|
if (items?.length) {
|
|
for (const item of items) {
|
|
if (item.kind === 'file') {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const types = dataTransfer.types;
|
|
|
|
if (!types || types.length === 0)
|
|
return treatMissingTypesAsPotentialFile;
|
|
|
|
for (const type of types) {
|
|
if (
|
|
type === 'Files' ||
|
|
type === 'application/x-moz-file' ||
|
|
type === 'public.file-url' ||
|
|
type === 'text/uri-list' ||
|
|
type === 'x-special/gnome-copied-files'
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] {
|
|
const extractedFiles: File[] = [];
|
|
const items = dataTransfer?.items ?? null;
|
|
|
|
if (items && items.length) {
|
|
for (const item of items) {
|
|
if (item.kind === 'file') {
|
|
const file = item.getAsFile();
|
|
|
|
if (file) {
|
|
this.pushUniqueFile(extractedFiles, file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const files = dataTransfer?.files;
|
|
|
|
if (!files?.length)
|
|
return extractedFiles;
|
|
|
|
for (const file of files) {
|
|
this.pushUniqueFile(extractedFiles, 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<File[]> {
|
|
const directFiles = this.extractFilesFromTransfer(event.clipboardData);
|
|
|
|
if (directFiles.length > 0)
|
|
return directFiles;
|
|
|
|
return await this.readFilesFromElectronClipboard();
|
|
}
|
|
|
|
private async readFilesFromElectronClipboard(): Promise<File[]> {
|
|
const electronApi = this.electronBridge.getApi();
|
|
|
|
if (!electronApi)
|
|
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 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 {
|
|
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
|
|
const gif = this.pendingKlipyGif();
|
|
|
|
if (!gif)
|
|
return withEmbeddedImages;
|
|
|
|
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
|
|
|
|
return withEmbeddedImages ? `${withEmbeddedImages}\n${gifMarkdown}` : gifMarkdown;
|
|
}
|
|
|
|
private buildKlipyGifMarkdown(gif: KlipyGif): string {
|
|
return `})`;
|
|
}
|
|
|
|
private observeHeight(): void {
|
|
const root = this.composerRoot?.nativeElement;
|
|
|
|
if (!root)
|
|
return;
|
|
|
|
this.syncTextareaExpandedState();
|
|
this.emitHeight();
|
|
|
|
if (typeof ResizeObserver === 'undefined')
|
|
return;
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.syncTextareaExpandedState();
|
|
this.emitHeight();
|
|
});
|
|
|
|
this.resizeObserver.observe(root);
|
|
}
|
|
|
|
private syncTextareaExpandedState(): void {
|
|
const textarea = this.messageInputRef?.nativeElement;
|
|
|
|
this.textareaExpanded.set(Boolean(textarea && textarea.offsetHeight > DEFAULT_TEXTAREA_HEIGHT));
|
|
}
|
|
|
|
private emitHeight(): void {
|
|
const root = this.composerRoot?.nativeElement;
|
|
|
|
if (root) {
|
|
this.heightChanged.emit(root.offsetHeight);
|
|
}
|
|
}
|
|
}
|