/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, ViewChild, computed, effect, inject, signal } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { switchMap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { v4 as uuidv4 } from 'uuid'; import { ViewportService } from '../../../../core/platform'; import { BottomSheetComponent } from '../../../../shared'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { Attachment, AttachmentDownloadService, AttachmentFacade } from '../../../attachment'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { MessagesActions } from '../../../../store/messages/messages.actions'; import { selectActiveChannelMessages, selectCurrentRoomMessages, selectConversationExhausted, selectMessagesLoading, selectMessagesLoadingOlder, 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 { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { ThemeNodeDirective } from '../../../theme'; 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 { stepLightboxIndex } from '../../domain/rules/chat-message-lightbox.rules'; import { planChatMessageSend } from '../../domain/rules/chat-message-send.rules'; import { ChatLightboxState, ChatMessageComposerSubmitEvent, ChatMessageDeleteEvent, ChatMessageEditEvent, ChatMessageEmbedRemoveEvent, ChatMessageAttachmentEvent, ChatMessageImageContextMenuEvent, ChatMessageImageLightboxEvent, ChatMessageReactionEvent, ChatMessageReplyEvent } from './models/chat-messages.model'; @Component({ selector: 'app-chat-messages', standalone: true, imports: [ ChatMessageComposerComponent, KlipyGifPickerComponent, ChatMessageListComponent, ChatMessageOverlaysComponent, BottomSheetComponent, ThemeNodeDirective, ...APP_TRANSLATE_IMPORTS ], templateUrl: './chat-messages.component.html', styleUrl: './chat-messages.component.scss' }) export class ChatMessagesComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent; private readonly store = inject(Store); private readonly webrtc = inject(RealtimeSessionFacade); private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentDownload = inject(AttachmentDownloadService); private readonly klipy = inject(KlipyService); private readonly viewport = inject(ViewportService); readonly isMobile = this.viewport.isMobile; readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages); readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly loading = this.store.selectSignal(selectMessagesLoading); readonly syncing = this.store.selectSignal(selectMessagesSyncing); readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder); readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`); readonly conversationExhausted = toSignal( toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))), { initialValue: false } ); readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); readonly composerBottomPadding = signal(140); readonly klipyGifPickerAnchorRight = signal(16); readonly replyTo = signal(null); readonly showKlipyGifPicker = signal(false); readonly lightboxState = signal(null); readonly galleryMessageId = signal(null); readonly galleryAttachmentOrder = signal([]); readonly galleryAttachments = computed(() => { const messageId = this.galleryMessageId(); const attachmentIds = this.galleryAttachmentOrder(); void this.attachmentsSvc.updated; if (!messageId || attachmentIds.length === 0) { return null; } const attachmentsById = new Map( this.attachmentsSvc.getForMessage(messageId).map((attachment) => [attachment.id, attachment]) ); return attachmentIds .map((attachmentId) => attachmentsById.get(attachmentId)) .filter((attachment): attachment is Attachment => !!attachment); }); readonly imageContextMenu = signal(null); constructor() { effect(() => { void this.klipy.refreshAvailability(this.currentRoom()); }); } @HostListener('window:resize') onWindowResize(): void { if (this.showKlipyGifPicker()) { this.syncKlipyGifPickerAnchor(); } } handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void { this.messageList?.scrollToBottomAfterLocalSend(); const plan = planChatMessageSend({ generateId: uuidv4, content: event.content, pendingFiles: event.pendingFiles, replyToId: this.replyTo()?.id, channelId: this.activeChannelId() }); this.store.dispatch(MessagesActions.sendMessage(plan.action)); this.clearReply(); if (plan.attachmentBinding) { this.attachFilesToMessage(plan.attachmentBinding.messageId, plan.attachmentBinding.files); } } handleTypingStarted(): void { const roomId = this.currentRoom()?.id; if (!roomId) { return; } try { this.webrtc.sendRawMessage({ type: 'typing', serverId: roomId, channelId: this.activeChannelId() ?? 'general' }); } 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); } handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void { this.store.dispatch( MessagesActions.removeLinkEmbed({ messageId: event.messageId, url: event.url }) ); } handleLoadOlderRequested(event: { beforeTimestamp: number; limit: number }): void { const roomId = this.currentRoom()?.id; if (!roomId) return; this.store.dispatch( MessagesActions.loadOlderMessages({ roomId, channelId: this.activeChannelId() ?? 'general', beforeTimestamp: event.beforeTimestamp, limit: event.limit }) ); } 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(event: ChatMessageImageLightboxEvent): void { const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl); const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id); if (index < 0) { return; } this.attachmentsSvc.pinDisplayBlobs(attachments); this.lightboxState.set({ attachments, index }); } closeLightbox(): void { const state = this.lightboxState(); if (state) { this.attachmentsSvc.unpinDisplayBlobs(state.attachments); } this.lightboxState.set(null); } stepLightbox(delta: number): void { const state = this.lightboxState(); if (!state) { return; } const nextIndex = stepLightboxIndex(state.index, delta, state.attachments.length); if (nextIndex === null) { return; } this.lightboxState.set({ attachments: state.attachments, index: nextIndex }); } openImageGallery(attachments: Attachment[]): void { if (attachments.length < 2) { return; } const messageId = attachments[0]?.messageId; if (!messageId) { return; } const displayableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl); if (displayableImages.length > 0) { this.attachmentsSvc.pinDisplayBlobs(displayableImages); } this.galleryMessageId.set(messageId); this.galleryAttachmentOrder.set(attachments.map((attachment) => attachment.id)); } closeImageGallery(): void { const gallery = this.galleryAttachments(); if (gallery) { this.attachmentsSvc.unpinDisplayBlobs(gallery); } this.galleryMessageId.set(null); this.galleryAttachmentOrder.set([]); } openImageContextMenu(event: ChatMessageImageContextMenuEvent): void { this.imageContextMenu.set(event); } closeImageContextMenu(): void { this.imageContextMenu.set(null); } async downloadAttachment(attachment: Attachment): Promise { await this.attachmentDownload.downloadToUserLocation(attachment); } retryGalleryImage(event: ChatMessageAttachmentEvent): void { const { messageId, attachment } = event; const liveAttachment = this.attachmentsSvc.getForMessage(messageId).find((entry) => entry.id === attachment.id) ?? attachment; if ((liveAttachment.receivedBytes ?? 0) > 0 || this.attachmentsSvc.hasPendingRequest(messageId, liveAttachment.id)) { this.attachmentsSvc.cancelRequest(messageId, liveAttachment); } void this.attachmentsSvc.requestImageFromAnyPeer(messageId, liveAttachment); } cancelGalleryImage(event: ChatMessageAttachmentEvent): void { this.attachmentsSvc.cancelRequest(event.messageId, event.attachment); } 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 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 attachFilesToMessage(messageId: string, pendingFiles: File[]): void { const currentUserId = this.currentUser()?.id; const roomId = this.currentRoom()?.id; if (roomId) { this.attachmentsSvc.rememberMessageRoom(messageId, roomId); } this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined); } }