/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, effect, HostListener, inject, input, signal, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { toSignal } from '@angular/core/rxjs-interop'; import { map } from 'rxjs'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ViewportService } from '../../../../core/platform'; import { BottomSheetComponent, UserAvatarComponent } from '../../../../shared'; import { DirectCallService } from '../../../direct-call'; import { Attachment, AttachmentFacade } from '../../../attachment'; import { ThemeNodeDirective } from '../../../theme'; import { DirectMessageService } from '../../application/services/direct-message.service'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide'; import { ChatMessageComposerSubmitEvent, ChatMessageComposerComponent, ChatMessageDeleteEvent, ChatMessageEditEvent, ChatMessageImageContextMenuEvent, ChatMessageListComponent, ChatMessageOverlaysComponent, ChatMessageReactionEvent, ChatMessageReplyEvent, hasDedicatedChatEmbed, KlipyGif, KlipyGifPickerComponent, KlipyService, LinkMetadataService, type ChatMessageEmbedRemoveEvent } from '../../../chat'; import type { DirectMessageStatus, LinkMetadata, Message, User } from '../../../../shared-kernel'; interface DmStatusLabel { id: string; status: DirectMessageStatus; } @Component({ selector: 'app-dm-chat', standalone: true, imports: [ CommonModule, ChatMessageComposerComponent, ChatMessageListComponent, ChatMessageOverlaysComponent, KlipyGifPickerComponent, BottomSheetComponent, NgIcon, ThemeNodeDirective, UserAvatarComponent ], viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })], templateUrl: './dm-chat.component.html', host: { class: 'block h-full' } }) export class DmChatComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; private readonly route = inject(ActivatedRoute); private readonly store = inject(Store); private readonly electronBridge = inject(ElectronBridgeService); private readonly attachments = inject(AttachmentFacade); private readonly klipy = inject(KlipyService); private readonly linkMetadata = inject(LinkMetadataService); private readonly viewport = inject(ViewportService); private readonly metadataRequestKeys = new Set(); private openedConversationId: string | null = null; readonly isMobile = this.viewport.isMobile; readonly directCalls = inject(DirectCallService); readonly directMessages = inject(DirectMessageService); readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly allUsers = this.store.selectSignal(selectAllUsers); readonly showGifPicker = signal(false); readonly conversationId = input(null); readonly showCallButton = input(true); readonly composerBottomPadding = signal(140); readonly gifPickerAnchorRight = signal(16); readonly linkMetadataByMessageId = signal>({}); readonly replyTo = signal(null); readonly lightboxAttachment = signal(null); readonly imageContextMenu = signal(null); readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId()); readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); readonly conversation = this.directMessages.selectedConversation; readonly klipyEnabled = computed(() => this.klipy.isEnabled(null)); readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none'); readonly typingUsers = computed(() => { void this.directMessages.typingEntries(); return this.directMessages.typingUsers(this.conversation()?.id); }); readonly peerUser = computed(() => { const conversation = this.conversation(); return conversation ? this.peerUserFor(conversation) : null; }); readonly isGroupConversation = computed(() => { const conversation = this.conversation(); return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2); }); readonly participantUsers = computed(() => { const conversation = this.conversation(); const knownUsers = this.allUsers(); if (!conversation) { return []; } return conversation.participants.map((participantId) => { const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId); const participant = conversation.participantProfiles[participantId]; return ( knownUser ?? { id: participantId, oderId: participantId, username: participant?.username || participant?.displayName || participantId, displayName: participant?.displayName || participant?.username || participantId, description: participant?.description, profileUpdatedAt: participant?.profileUpdatedAt, avatarUrl: participant?.avatarUrl, avatarHash: participant?.avatarHash, avatarMime: participant?.avatarMime, avatarUpdatedAt: participant?.avatarUpdatedAt, status: 'disconnected', role: 'member', joinedAt: 0 } ); }); }); readonly messageStatuses = computed(() => { const conversation = this.conversation(); const currentUserId = this.currentUserId(); if (!conversation || !currentUserId) { return []; } return conversation.messages .filter((message) => message.senderId === currentUserId) .map((message) => ({ id: message.id, status: message.status })); }); readonly chatMessages = computed(() => { const conversation = this.conversation(); const metadataByMessageId = this.linkMetadataByMessageId(); if (!conversation) { return []; } return conversation.messages.map((message) => { const participant = conversation.participantProfiles[message.senderId]; const knownUser = this.participantUsers().find((user) => user.id === message.senderId || user.oderId === message.senderId); return { id: message.id, roomId: conversation.id, channelId: 'direct-message', senderId: message.senderId, senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId), content: message.content, timestamp: message.timestamp, editedAt: message.editedAt, reactions: message.reactions ?? [], isDeleted: !!message.isDeleted, replyToId: message.replyToId, linkMetadata: metadataByMessageId[message.id] }; }); }); readonly peerName = computed(() => { const conversation = this.conversation(); const currentUserId = this.currentUserId(); if (conversation && this.isGroupConversation()) { return conversation.title || this.groupConversationTitle(conversation); } const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId); return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message'; }); readonly peerCallIcon = computed(() => { const conversation = this.conversation(); if (conversation && this.isGroupConversation()) { return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone'; } const peer = this.peerUser(); return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone'; }); readonly canCallConversation = computed(() => { const conversation = this.conversation(); if (!conversation) { return false; } if (this.isGroupConversation()) { return conversation.participants.some((participantId) => participantId !== this.currentUserId()); } return !!this.peerUser(); }); constructor() { effect(() => { const conversationId = this.effectiveConversationId(); if (!conversationId) { this.openedConversationId = null; return; } if (conversationId !== this.openedConversationId) { this.openedConversationId = conversationId; void this.directMessages.openConversation(conversationId); } }); effect(() => { void this.effectiveConversationId(); void this.klipy.refreshAvailability(null); }); effect(() => { void this.refreshLinkMetadata(this.chatMessages()); }); effect(() => { const conversation = this.conversation(); const peerUser = this.peerUser(); if (conversation && !peerUser?.avatarUrl) { this.directMessages.requestPeerAvatarSync(conversation.id); } }); } @HostListener('window:resize') onWindowResize(): void { if (this.showGifPicker()) { this.syncGifPickerAnchor(); } } handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void { const conversation = this.conversation(); if (!conversation || (!event.content.trim() && event.pendingFiles.length === 0)) { return; } const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n'); void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => { this.replyTo.set(null); if (event.pendingFiles.length > 0) { this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`); this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined); } }); } handleTypingStarted(): void { const conversationId = this.conversation()?.id; if (conversationId) { this.directMessages.sendTyping(conversationId, true); } } async callConversation(): Promise { const conversation = this.conversation(); if (conversation && this.canCallConversation()) { await this.directCalls.startConversationCall(conversation); } } setReplyTo(message: ChatMessageReplyEvent): void { this.replyTo.set(message); } clearReply(): void { this.replyTo.set(null); } handleEditSaved(event: ChatMessageEditEvent): void { const conversation = this.conversation(); if (conversation) { void this.directMessages.editMessage(conversation.id, event.messageId, event.content); } } handleDeleteRequested(message: ChatMessageDeleteEvent): void { const conversation = this.conversation(); if (conversation && message.senderId === this.currentUserId()) { void this.directMessages.deleteMessage(conversation.id, message.id); } } handleReactionAdded(event: ChatMessageReactionEvent): void { const conversation = this.conversation(); if (conversation) { void this.directMessages.addReaction(conversation.id, event.messageId, event.emoji); } } handleReactionToggled(event: ChatMessageReactionEvent): void { const conversation = this.conversation(); if (conversation) { void this.directMessages.toggleReaction(conversation.id, event.messageId, event.emoji); } } toggleGifPicker(): void { if (!this.klipyEnabled()) { return; } this.showGifPicker.update((visible) => !visible); if (this.showGifPicker()) { requestAnimationFrame(() => this.syncGifPickerAnchor()); } } closeGifPicker(): void { this.showGifPicker.set(false); } handleGifSelected(gif: KlipyGif): void { this.closeGifPicker(); this.composer?.handleKlipyGifSelected(gif); } handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void { this.linkMetadataByMessageId.update((metadataByMessageId) => ({ ...metadataByMessageId, [event.messageId]: (metadataByMessageId[event.messageId] ?? []).filter((metadata) => metadata.url !== event.url) })); } 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 diskPath = this.getAttachmentDiskPath(attachment); if (diskPath && electronApi.saveExistingFileAs) { try { const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename); if (result.saved || result.cancelled) { return; } } catch { /* fall back to blob/browser download */ } } 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(); await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]); } catch { /* ignore */ } } private syncGifPickerAnchor(): void { const triggerRect = this.composer?.getKlipyTriggerRect(); if (!triggerRect) { this.gifPickerAnchorRight.set(16); return; } const viewportWidth = window.innerWidth; const popupWidth = viewportWidth >= 1280 ? 52 * 16 : viewportWidth >= 768 ? 42 * 16 : 34 * 16; const preferredRight = viewportWidth - triggerRect.right; const minRight = 16; const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); this.gifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)); } private async refreshLinkMetadata(messages: Message[]): Promise { const metadataByMessageId = this.linkMetadataByMessageId(); for (const message of messages) { if (metadataByMessageId[message.id]?.length) { continue; } const urls = this.linkMetadata.extractUrls(message.content) .filter((url) => !hasDedicatedChatEmbed(url)) .filter((url) => !this.metadataRequestKeys.has(this.metadataRequestKey(message.id, url))); if (urls.length === 0) { continue; } urls.forEach((url) => this.metadataRequestKeys.add(this.metadataRequestKey(message.id, url))); const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed); if (metadata.length === 0) { continue; } this.linkMetadataByMessageId.update((currentMetadata) => ({ ...currentMetadata, [message.id]: metadata })); } } private metadataRequestKey(messageId: string, url: string): string { return `${messageId}:${url}`; } private async getAttachmentBlob(attachment: Attachment): Promise { if (!attachment.objectUrl) { return null; } if (attachment.objectUrl.startsWith('file:')) { return null; } try { const response = await fetch(attachment.objectUrl); return await response.blob(); } catch { return null; } } private getAttachmentDiskPath(attachment: Attachment): string | null { return attachment.savedPath || attachment.filePath || 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 peerUserFor(conversation: NonNullable>): User | null { if (conversation.kind === 'group' || conversation.participants.length > 2) { return null; } const currentUserId = this.currentUserId(); const peerId = conversation.participants.find((participantId) => participantId !== currentUserId); if (!peerId) { return null; } return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null; } private groupConversationTitle(conversation: NonNullable>): string { const names = conversation.participants .filter((participantId) => participantId !== this.currentUserId()) .map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId); if (names.length <= 3) { return names.join(', '); } return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; } }