/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, ViewChild, computed, inject, signal } from '@angular/core'; import { Store } from '@ngrx/store'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { Attachment, AttachmentFacade } from '../../../attachment'; import { KlipyGif } from '../../application/klipy.service'; import { MessagesActions } from '../../../../store/messages/messages.actions'; import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../../store/messages/messages.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors'; import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { Message } from '../../../../shared-kernel'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component'; import { ChatMessageComposerSubmitEvent, ChatMessageDeleteEvent, ChatMessageEditEvent, ChatMessageImageContextMenuEvent, ChatMessageReactionEvent, ChatMessageReplyEvent } from './models/chat-messages.models'; @Component({ selector: 'app-chat-messages', standalone: true, imports: [ ChatMessageComposerComponent, KlipyGifPickerComponent, ChatMessageListComponent, ChatMessageOverlaysComponent ], templateUrl: './chat-messages.component.html', styleUrl: './chat-messages.component.scss' }) export class ChatMessagesComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; private readonly electronBridge = inject(ElectronBridgeService); private readonly store = inject(Store); private readonly webrtc = inject(RealtimeSessionFacade); private readonly attachmentsSvc = inject(AttachmentFacade); readonly allMessages = this.store.selectSignal(selectAllMessages); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly loading = this.store.selectSignal(selectMessagesLoading); readonly syncing = this.store.selectSignal(selectMessagesSyncing); readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); readonly channelMessages = computed(() => { const channelId = this.activeChannelId(); const roomId = this.currentRoom()?.id; return this.allMessages().filter( (message) => message.roomId === roomId && (message.channelId || 'general') === channelId ); }); readonly conversationKey = computed( () => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}` ); readonly composerBottomPadding = signal(140); readonly klipyGifPickerAnchorRight = signal(16); readonly replyTo = signal(null); readonly showKlipyGifPicker = signal(false); readonly lightboxAttachment = signal(null); readonly imageContextMenu = signal(null); @HostListener('window:resize') onWindowResize(): void { if (this.showKlipyGifPicker()) { this.syncKlipyGifPickerAnchor(); } } handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void { this.store.dispatch( MessagesActions.sendMessage({ content: event.content, replyToId: this.replyTo()?.id, channelId: this.activeChannelId() }) ); this.clearReply(); if (event.pendingFiles.length > 0) { setTimeout(() => this.attachFilesToLastOwnMessage(event.content, event.pendingFiles), 100); } } handleTypingStarted(): void { try { this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId }); } catch { /* ignore */ } } setReplyTo(message: ChatMessageReplyEvent): void { this.replyTo.set(message); } clearReply(): void { this.replyTo.set(null); } handleEditSaved(event: ChatMessageEditEvent): void { this.store.dispatch( MessagesActions.editMessage({ messageId: event.messageId, content: event.content }) ); } handleDeleteRequested(message: ChatMessageDeleteEvent): void { if (this.isOwnMessage(message)) { this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id })); } else if (this.isAdmin()) { this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id })); } } handleReactionAdded(event: ChatMessageReactionEvent): void { this.store.dispatch( MessagesActions.addReaction({ messageId: event.messageId, emoji: event.emoji }) ); } handleReactionToggled(event: ChatMessageReactionEvent): void { const message = this.channelMessages().find((entry) => entry.id === event.messageId); const currentUserId = this.currentUser()?.id; if (!message || !currentUserId) return; const hasReacted = message.reactions.some( (reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId ); if (hasReacted) { this.store.dispatch( MessagesActions.removeReaction({ messageId: event.messageId, emoji: event.emoji }) ); } else { this.store.dispatch( MessagesActions.addReaction({ messageId: event.messageId, emoji: event.emoji }) ); } } handleComposerHeightChanged(height: number): void { this.composerBottomPadding.set(height + 20); } toggleKlipyGifPicker(): void { const nextState = !this.showKlipyGifPicker(); this.showKlipyGifPicker.set(nextState); if (nextState) { requestAnimationFrame(() => this.syncKlipyGifPickerAnchor()); } } closeKlipyGifPicker(): void { this.showKlipyGifPicker.set(false); } handleKlipyGifSelected(gif: KlipyGif): void { this.closeKlipyGifPicker(); this.composer?.handleKlipyGifSelected(gif); } private syncKlipyGifPickerAnchor(): void { const triggerRect = this.composer?.getKlipyTriggerRect(); if (!triggerRect) { this.klipyGifPickerAnchorRight.set(16); return; } const viewportWidth = window.innerWidth; const popupWidth = this.getKlipyGifPickerWidth(viewportWidth); const preferredRight = viewportWidth - triggerRect.right; const minRight = 16; const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); this.klipyGifPickerAnchorRight.set( Math.min(Math.max(Math.round(preferredRight), minRight), maxRight) ); } private getKlipyGifPickerWidth(viewportWidth: number): number { if (viewportWidth >= 1280) return 52 * 16; if (viewportWidth >= 768) return 42 * 16; if (viewportWidth >= 640) return 34 * 16; return Math.max(0, viewportWidth - 32); } openLightbox(attachment: Attachment): void { if (attachment.available && attachment.objectUrl) { this.lightboxAttachment.set(attachment); } } closeLightbox(): void { this.lightboxAttachment.set(null); } openImageContextMenu(event: ChatMessageImageContextMenuEvent): void { this.imageContextMenu.set(event); } closeImageContextMenu(): void { this.imageContextMenu.set(null); } async downloadAttachment(attachment: Attachment): Promise { if (!attachment.available || !attachment.objectUrl) return; const electronApi = this.electronBridge.getApi(); if (electronApi) { const blob = await this.getAttachmentBlob(attachment); if (blob) { try { const result = await electronApi.saveFileAs( attachment.filename, await this.blobToBase64(blob) ); if (result.saved || result.cancelled) return; } catch { /* fall back to browser download */ } } } const link = document.createElement('a'); link.href = attachment.objectUrl; link.download = attachment.filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); } async copyImageToClipboard(attachment: Attachment): Promise { this.closeImageContextMenu(); if (!attachment.objectUrl) return; try { const response = await fetch(attachment.objectUrl); const blob = await response.blob(); const pngBlob = await this.convertToPng(blob); await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]); } catch { /* ignore */ } } private isOwnMessage(message: Message): boolean { return message.senderId === this.currentUser()?.id; } private async getAttachmentBlob(attachment: Attachment): Promise { if (!attachment.objectUrl) return null; try { const response = await fetch(attachment.objectUrl); return await response.blob(); } catch { return null; } } private blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== 'string') { reject(new Error('Failed to encode attachment')); return; } const [, base64 = ''] = reader.result.split(',', 2); resolve(base64); }; reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment')); reader.readAsDataURL(blob); }); } private convertToPng(blob: Blob): Promise { return new Promise((resolve, reject) => { if (blob.type === 'image/png') { resolve(blob); return; } const image = new Image(); const url = URL.createObjectURL(blob); image.onload = () => { const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext('2d'); if (!context) { reject(new Error('Canvas not supported')); return; } context.drawImage(image, 0, 0); canvas.toBlob((pngBlob) => { URL.revokeObjectURL(url); if (pngBlob) resolve(pngBlob); else reject(new Error('PNG conversion failed')); }, 'image/png'); }; image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); }; image.src = url; }); } private attachFilesToLastOwnMessage(content: string, pendingFiles: File[]): void { const currentUserId = this.currentUser()?.id; if (!currentUserId) return; const message = [...this.channelMessages()] .reverse() .find( (entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted ); if (!message) { setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150); return; } this.attachmentsSvc.publishAttachments(message.id, pendingFiles, currentUserId || undefined); } }