Files
Toju/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts
2026-03-23 00:42:08 +01:00

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 `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
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);
}
}
}