import { CommonModule } from '@angular/common'; import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { PeerCallModalComponent } from './peer-call-modal.component'; import { ChatSessionService } from './chat-session.service'; import { JsonFileViewerComponent } from './json-file-viewer.component'; import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models'; @Component({ selector: 'app-chat-page', imports: [ CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerCallModalComponent, ], templateUrl: './chat-page.component.html', styleUrl: './chat-page.component.scss', }) export class ChatPageComponent implements OnDestroy { private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly ngZone = inject(NgZone); private readonly routeParamMap = toSignal(this.route.paramMap, { initialValue: this.route.snapshot.paramMap, }); private composerSelectionStart = 0; private composerSelectionEnd = 0; private voiceRecorder: MediaRecorder | null = null; private voiceStream: MediaStream | null = null; private voiceChunks: Blob[] = []; private discardRecordedVoice = false; private recordingPeerId: string | null = null; private dictationRecorder: MediaRecorder | null = null; private dictationStream: MediaStream | null = null; private dictationChunks: Blob[] = []; private dictationBaseText = ''; private discardRecordedDictation = false; private dictationCompletionPromise: Promise | null = null; private resolveDictationCompletion: (() => void) | null = null; private dictationApplyToken = 0; private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null; @ViewChild('callAudioElement') set callAudioElementRef(value: ElementRef | undefined) { this.callAudioElement = value; this.syncCallAudioSource(); } private callAudioElement?: ElementRef; @ViewChild('conversationContainer') set conversationContainerRef(value: ElementRef | undefined) { this.conversationContainer = value; } private conversationContainer?: ElementRef; @ViewChild('fullscreenConversationContainer') set fullscreenConversationContainerRef(value: ElementRef | undefined) { this.fullscreenConversationContainer = value; } private fullscreenConversationContainer?: ElementRef; messageText = ''; readonly forwardingEntryId = signal(null); readonly callChoicePeerId = signal(null); readonly conversationModalOpen = signal(false); readonly peerDropdownOpen = signal(false); readonly emojiPickerOpen = signal(false); readonly isRecordingVoice = signal(false); readonly isDictating = signal(false); readonly isTranscribingDictation = signal(false); readonly emojiOptions = [ 'πŸ˜€', '😁', 'πŸ˜‚', '🀣', '😊', 'πŸ˜‰', '😍', '😘', '😎', 'πŸ€”', 'πŸ˜…', '😭', '😑', '😴', 'πŸ™ƒ', 'πŸ‘', 'πŸ‘Ž', 'πŸ‘', 'πŸ™', '🀝', 'πŸŽ‰', 'πŸ”₯', '❀️', 'πŸ’‘', 'βœ…', 'πŸš€', 'πŸ‘€', 'πŸ“Ή', 'πŸ“Ž', 'πŸ’¬', '🌍', '⚑', '⭐', '🎡', 'πŸ“·', 'πŸ—‘οΈ', '⏩', 'πŸ›‘', 'πŸ™Œ', 'πŸ‘Œ', ]; readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); readonly displayedPeer = computed(() => this.peer() ?? this.session.peers()[0] ?? null); readonly currentUser = computed(() => this.session.currentUser()); readonly callModalPeerId = computed(() => this.session.activeVoiceCallPeerId() ?? this.session.incomingVoiceCallPeerId() ?? this.session.outgoingVoiceCallPeerId() ?? null, ); readonly callModalPeer = computed(() => { const peerId = this.callModalPeerId(); return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null; }); readonly callChoicePeer = computed(() => { const peerId = this.callChoicePeerId(); return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null; }); readonly conversation = computed(() => this.session .messages() .filter((entry) => entry.peerId === this.peerId()), ); readonly remoteCallAudioStream = computed(() => this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''), ); readonly callModalMode = computed(() => this.session.callModeForPeer(this.callModalPeerId() ?? '') ?? 'video'); readonly localCallStream = computed(() => this.session.localCallStreamForPeer(this.callModalPeerId() ?? '')); readonly remoteCallVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.callModalPeerId() ?? '')); readonly callModalVisible = computed(() => !!this.callModalPeer()); readonly callModalState = computed<'incoming' | 'outgoing' | 'active'>(() => { const peerId = this.callModalPeerId(); if (!peerId) { return 'active'; } if (this.session.incomingVoiceCallPeerId() === peerId) { return 'incoming'; } if (this.session.outgoingVoiceCallPeerId() === peerId) { return 'outgoing'; } return 'active'; }); readonly callModalStatusText = computed(() => { const peer = this.callModalPeer(); if (!peer) { return ''; } switch (this.callModalState()) { case 'incoming': return `${peer.displayName} is calling you${this.callModalMode() === 'audio' ? ' with audio only.' : '.'}`; case 'outgoing': return this.callModalMode() === 'audio' ? 'Calling… your microphone is ready.' : 'Calling… your camera and microphone are ready.'; default: return this.callModalMode() === 'audio' ? 'Connected with live audio.' : 'Connected with live video and audio.'; } }); readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => { const peerId = this.peerId(); if (!peerId) { return 'idle'; } if (this.session.activeVoiceCallPeerId() === peerId) { return 'active'; } if (this.session.outgoingVoiceCallPeerId() === peerId) { return 'outgoing'; } if (this.session.incomingVoiceCallPeerId() === peerId) { return 'incoming'; } return 'idle'; }); readonly canStartSelectedVoiceCall = computed(() => { const selectedPeer = this.peer(); if (!selectedPeer || selectedPeer.channelState !== 'open') { return false; } const activePeerId = this.session.activeVoiceCallPeerId(); const outgoingPeerId = this.session.outgoingVoiceCallPeerId(); const incomingPeerId = this.session.incomingVoiceCallPeerId(); return !activePeerId && !outgoingPeerId && !incomingPeerId; }); readonly canEndSelectedVoiceCall = computed(() => { const peerId = this.peerId(); return !!peerId && ( this.session.activeVoiceCallPeerId() === peerId || this.session.outgoingVoiceCallPeerId() === peerId ); }); readonly webRtcState = computed(() => { const selectedPeer = this.peer(); if (!selectedPeer) { return 'disconnected'; } if (selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected') { return 'connected'; } if (selectedPeer.channelState === 'connecting' || selectedPeer.connectionState === 'connecting') { return 'connecting'; } return 'disconnected'; }); constructor(readonly session: ChatSessionService) { if (!this.session.currentUser()) { void this.router.navigateByUrl('/'); } effect(() => { const peerId = this.peerId(); if (!peerId) { return; } this.session.selectPeer(peerId); void this.session.connectToPeer(peerId); }); effect(() => { this.remoteCallAudioStream(); this.syncCallAudioSource(); }); effect(() => { const peerId = this.peerId(); const entries = this.conversation(); const snapshot = { peerId, length: entries.length, lastEntryId: entries.at(-1)?.id ?? null, }; const previousSnapshot = this.lastConversationSnapshot; this.lastConversationSnapshot = snapshot; if (!peerId || !previousSnapshot || previousSnapshot.peerId !== peerId) { return; } const hasNewTailEntry = snapshot.length > previousSnapshot.length || (snapshot.length > 0 && snapshot.lastEntryId !== previousSnapshot.lastEntryId); if (!hasNewTailEntry) { return; } this.scrollConversationToBottom(); }); } ngOnDestroy(): void { void this.stopDictation(true); this.stopVoiceRecording(true); this.detachCallAudioSource(); } async ensureConnection(): Promise { const peerId = this.peerId(); if (!peerId) { return; } this.session.selectPeer(peerId); await this.session.connectToPeer(peerId); } async sendMessage(): Promise { const peerId = this.peerId(); if (!peerId) { return; } await this.stopDictation(false); await this.session.sendText(peerId, this.messageText); this.messageText = ''; this.emojiPickerOpen.set(false); this.composerSelectionStart = 0; this.composerSelectionEnd = 0; } async requestGeneratedImage(): Promise { const peerId = this.peerId(); if (!peerId) { return; } await this.stopDictation(false); const requested = await this.session.requestGeneratedImage(peerId, this.messageText); if (!requested) { return; } this.messageText = ''; this.handleMessageTextChange(''); this.emojiPickerOpen.set(false); this.composerSelectionStart = 0; this.composerSelectionEnd = 0; } handleComposerEnter(event: Event): void { if (!(event instanceof KeyboardEvent) || event.shiftKey) { return; } event.preventDefault(); void this.sendMessage(); } handleMessageTextChange(text: string): void { const peerId = this.peerId(); if (!peerId) { return; } this.session.notifyTypingActivity(peerId, text); } trackComposerSelection(textarea: HTMLTextAreaElement): void { this.composerSelectionStart = textarea.selectionStart ?? this.messageText.length; this.composerSelectionEnd = textarea.selectionEnd ?? this.composerSelectionStart; } toggleEmojiPicker(event?: Event): void { event?.stopPropagation(); this.emojiPickerOpen.update((open) => !open); } insertEmoji(emoji: string, textarea: HTMLTextAreaElement): void { const selectionStart = textarea.selectionStart ?? this.composerSelectionStart; const selectionEnd = textarea.selectionEnd ?? this.composerSelectionEnd; const before = this.messageText.slice(0, selectionStart); const after = this.messageText.slice(selectionEnd); this.messageText = `${before}${emoji}${after}`; this.emojiPickerOpen.set(false); this.handleMessageTextChange(this.messageText); const nextSelection = selectionStart + emoji.length; this.composerSelectionStart = nextSelection; this.composerSelectionEnd = nextSelection; queueMicrotask(() => { textarea.focus(); textarea.setSelectionRange(nextSelection, nextSelection); this.trackComposerSelection(textarea); }); } openCallChoice(peerId: string): void { if (!peerId) { return; } this.callChoicePeerId.set(peerId); } closeCallChoice(): void { this.callChoicePeerId.set(null); } async startSelectedCall(mode: CallMode): Promise { const peerId = this.callChoicePeerId() ?? this.peerId(); if (!peerId) { return; } this.callChoicePeerId.set(null); await this.session.startVoiceCall(peerId, mode); } async sendFile(peerId: string, input: HTMLInputElement): Promise { const file = input.files?.item(0); if (!file) { return; } await this.session.sendFile(peerId, file); input.value = ''; } async toggleDictation(textarea: HTMLTextAreaElement): Promise { if (this.isDictating()) { await this.stopDictation(false); return; } if (this.isTranscribingDictation()) { return; } const peerId = this.peerId(); if (!peerId) { return; } if (typeof MediaRecorder === 'undefined' || typeof navigator === 'undefined') { this.session.error.set('This browser does not support dictation recording.'); return; } if (typeof navigator.mediaDevices?.getUserMedia !== 'function') { this.session.error.set('This browser cannot access the microphone for dictation.'); return; } this.dictationBaseText = this.messageText; this.discardRecordedDictation = false; this.dictationApplyToken += 1; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const preferredMimeType = this.preferredVoiceMimeType(); const recorder = preferredMimeType ? new MediaRecorder(stream, { mimeType: preferredMimeType }) : new MediaRecorder(stream); const applyToken = this.dictationApplyToken; this.dictationChunks = []; this.dictationStream = stream; this.dictationRecorder = recorder; this.dictationCompletionPromise = new Promise((resolve) => { this.resolveDictationCompletion = resolve; }); recorder.ondataavailable = (event) => { if (event.data.size > 0) { this.dictationChunks.push(event.data); } }; recorder.onerror = () => { this.ngZone.run(() => { this.session.error.set('Could not record dictation audio.'); this.cleanupDictationRecorder(); this.finishDictationCompletion(); }); }; recorder.onstop = () => { const shouldDiscard = this.discardRecordedDictation; const mimeType = recorder.mimeType || preferredMimeType || 'audio/webm'; const blob = new Blob(this.dictationChunks, { type: mimeType }); this.ngZone.run(() => { this.cleanupDictationRecorder(); if (shouldDiscard || blob.size === 0) { this.finishDictationCompletion(); return; } this.isTranscribingDictation.set(true); void this.transcribeDictation(blob, textarea, applyToken); }); }; recorder.start(); this.isDictating.set(true); this.session.error.set(null); } catch { this.session.error.set('Could not start dictation recording.'); this.cleanupDictationRecorder(); this.finishDictationCompletion(); } } async toggleVoiceRecording(): Promise { if (this.isRecordingVoice()) { this.stopVoiceRecording(false); return; } const peerId = this.peerId(); if (!peerId) { return; } if (typeof MediaRecorder === 'undefined' || typeof navigator === 'undefined') { this.session.error.set('This browser does not support voice recording.'); return; } if (typeof navigator.mediaDevices?.getUserMedia !== 'function') { this.session.error.set('This browser cannot access the microphone.'); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const preferredMimeType = this.preferredVoiceMimeType(); const recorder = preferredMimeType ? new MediaRecorder(stream, { mimeType: preferredMimeType }) : new MediaRecorder(stream); this.voiceChunks = []; this.discardRecordedVoice = false; this.recordingPeerId = peerId; this.voiceStream = stream; this.voiceRecorder = recorder; recorder.ondataavailable = (event) => { if (event.data.size > 0) { this.voiceChunks.push(event.data); } }; recorder.onerror = () => { this.session.error.set('Could not record voice message.'); this.cleanupVoiceRecording(); }; recorder.onstop = () => { const shouldDiscard = this.discardRecordedVoice; const targetPeerId = this.recordingPeerId; const mimeType = recorder.mimeType || preferredMimeType || 'audio/webm'; const blob = new Blob(this.voiceChunks, { type: mimeType }); this.cleanupVoiceRecording(); if (shouldDiscard || !targetPeerId || blob.size === 0) { return; } void this.session.sendVoiceMessage(targetPeerId, blob, mimeType); }; recorder.start(); this.isRecordingVoice.set(true); this.session.error.set(null); } catch { this.session.error.set('Could not start microphone recording.'); this.cleanupVoiceRecording(); } } async deleteMessage(entry: ChatEntry): Promise { await this.session.deleteMessage(entry); } isEmojiOnlyEntry(entry: ChatEntry): boolean { if (entry.kind !== 'text' || entry.direction === 'system' || !entry.text?.trim()) { return false; } return entry.text .trim() .split(/\s+/u) .every((token) => this.isEmojiToken(token)); } async deleteConversation(peerId: string, event?: Event): Promise { event?.stopPropagation(); await this.session.deleteConversation(peerId); } toggleForwardMenu(entry: ChatEntry, event?: Event): void { event?.stopPropagation(); if (entry.kind === 'system' || entry.direction === 'system' || this.forwardTargets(entry).length === 0) { this.forwardingEntryId.set(null); return; } this.forwardingEntryId.update((currentEntryId) => (currentEntryId === entry.id ? null : entry.id)); } isForwardMenuOpen(entryId: string): boolean { return this.forwardingEntryId() === entryId; } forwardTargets(entry: ChatEntry): PeerSummary[] { if (entry.kind === 'system' || entry.direction === 'system') { return []; } return this.session.peers().filter((peer) => peer.id !== entry.peerId); } togglePeerDropdown(): void { if (this.session.peers().length === 0) { return; } this.peerDropdownOpen.update((open) => !open); } closePeerDropdown(): void { this.peerDropdownOpen.set(false); } openConversationModal(): void { this.closePeerDropdown(); this.conversationModalOpen.set(true); this.scrollConversationToBottom(); } closeConversationModal(): void { this.conversationModalOpen.set(false); } async selectPeerFromDropdown(peerId: string): Promise { this.closePeerDropdown(); await this.switchPeer(peerId); } async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise { if (!targetPeerId) { return; } await this.session.forwardMessage(targetPeerId, entry); select.value = ''; this.forwardingEntryId.set(null); } async sendGeneratedImage(entry: ChatEntry): Promise { const peerId = this.peerId(); if (!peerId) { return; } await this.session.sendGeneratedImageToPeer(entry, peerId); } async endVoiceCall(peerId: string): Promise { await this.session.endVoiceCall(peerId); } async acceptIncomingVoiceCall(peerId: string): Promise { if (!peerId) { return; } await this.session.acceptVoiceCall(peerId); } rejectIncomingVoiceCall(peerId: string): void { if (!peerId) { return; } this.session.rejectVoiceCall(peerId); } isImageEntry(entry: ChatEntry): boolean { return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false); } isGeneratedImageEntry(entry: ChatEntry): boolean { return this.isImageEntry(entry) && entry.generatedByAi === true; } isVideoEntry(entry: ChatEntry): boolean { if (entry.kind !== 'file' || !entry.downloadUrl) { return false; } if (entry.fileMimeType?.startsWith('video/')) { return true; } return /\.(mp4|webm|ogg|ogv|mov|m4v)$/i.test(entry.fileName ?? ''); } isIncomingJsonFileEntry(entry: ChatEntry): boolean { return ( entry.kind === 'file' && entry.direction === 'incoming' && !!entry.downloadUrl && !!entry.fileName && entry.fileName.toLowerCase().endsWith('.json') ); } hasDocumentPreviewImage(entry: ChatEntry): boolean { return ( entry.kind === 'file' && !!entry.previewDownloadUrl && (entry.previewMimeType?.startsWith('image/') ?? false) ); } documentPreviewImageUrl(entry: ChatEntry): string | null { if (!this.hasDocumentPreviewImage(entry)) { return null; } return entry.previewDownloadUrl ?? null; } isPeerTyping(peerId: string): boolean { return this.session.typingPeerIds().includes(peerId); } isPeerUnread(peerId: string): boolean { return this.session.unreadPeerIds().includes(peerId); } indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' { if (state === 'connected') { return 'ok'; } if (state === 'connecting') { return 'connecting'; } return 'offline'; } canReconnectWebRtc(): boolean { return this.indicatorTone(this.webRtcState()) === 'offline'; } async switchPeer(peerId: string): Promise { if (!peerId || peerId === this.peerId()) { return; } await this.stopDictation(true); this.stopVoiceRecording(true); this.forwardingEntryId.set(null); this.callChoicePeerId.set(null); this.conversationModalOpen.set(false); this.peerDropdownOpen.set(false); this.emojiPickerOpen.set(false); this.session.selectPeer(peerId); await this.router.navigate(['/chat', peerId]); } private stopVoiceRecording(discard: boolean): void { const recorder = this.voiceRecorder; if (!recorder) { this.discardRecordedVoice = discard; this.cleanupVoiceRecording(); return; } this.discardRecordedVoice = discard; if (recorder.state !== 'inactive') { recorder.stop(); return; } this.cleanupVoiceRecording(); } private cleanupVoiceRecording(): void { if (this.voiceStream) { for (const track of this.voiceStream.getTracks()) { track.stop(); } } this.voiceRecorder = null; this.voiceStream = null; this.voiceChunks = []; this.recordingPeerId = null; this.isRecordingVoice.set(false); } private preferredVoiceMimeType(): string { if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') { return ''; } const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']; return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? ''; } private async stopDictation(discard: boolean): Promise { const completion = this.dictationCompletionPromise; if (discard) { this.dictationApplyToken += 1; this.messageText = this.dictationBaseText || this.messageText; this.handleMessageTextChange(this.messageText); this.isTranscribingDictation.set(false); } else { this.dictationBaseText = this.messageText; } if (this.dictationRecorder) { this.discardRecordedDictation = discard; if (this.dictationRecorder.state !== 'inactive') { this.dictationRecorder.stop(); } else { this.cleanupDictationRecorder(); this.finishDictationCompletion(); } } else if (!completion) { this.dictationBaseText = ''; } if (completion) { await completion; } } private cleanupDictationRecorder(): void { if (this.dictationStream) { for (const track of this.dictationStream.getTracks()) { track.stop(); } } this.dictationRecorder = null; this.dictationStream = null; this.dictationChunks = []; this.discardRecordedDictation = false; this.isDictating.set(false); } private finishDictationCompletion(): void { this.resolveDictationCompletion?.(); this.resolveDictationCompletion = null; this.dictationCompletionPromise = null; this.dictationBaseText = ''; } private async transcribeDictation(blob: Blob, textarea: HTMLTextAreaElement, applyToken: number): Promise { try { const transcript = await this.session.requestSpeechTranscription(blob); if (applyToken !== this.dictationApplyToken) { return; } this.applyDictatedText(this.mergeDictatedText(this.dictationBaseText, transcript), textarea); } catch { if (applyToken === this.dictationApplyToken) { this.session.error.set('Dictation transcription failed.'); } } finally { if (applyToken === this.dictationApplyToken) { this.isTranscribingDictation.set(false); } this.finishDictationCompletion(); } } private mergeDictatedText(baseText: string, transcript: string): string { const trimmedTranscript = transcript.trim(); if (!trimmedTranscript) { return baseText; } if (!baseText.trim()) { return trimmedTranscript; } return `${baseText.trimEnd()} ${trimmedTranscript}`; } private applyDictatedText(text: string, textarea: HTMLTextAreaElement): void { this.messageText = text; textarea.value = text; this.composerSelectionStart = text.length; this.composerSelectionEnd = text.length; this.handleMessageTextChange(text); queueMicrotask(() => { textarea.focus(); textarea.setSelectionRange(text.length, text.length); }); } private syncCallAudioSource(): void { const audio = this.callAudioElement?.nativeElement; if (!audio) { return; } const stream = this.remoteCallAudioStream(); audio.srcObject = stream; if (stream) { void audio.play().catch(() => { // Autoplay may wait for a browser gesture. }); return; } audio.pause(); } private detachCallAudioSource(): void { const audio = this.callAudioElement?.nativeElement; if (!audio) { return; } audio.pause(); audio.srcObject = null; } private scrollConversationToBottom(): void { queueMicrotask(() => { requestAnimationFrame(() => { for (const container of [ this.conversationContainer?.nativeElement, this.fullscreenConversationContainer?.nativeElement, ]) { if (!container) { continue; } container.scrollTop = container.scrollHeight; } }); }); } private isEmojiToken(token: string): boolean { if (!token) { return false; } const graphemes = this.graphemeSegmenter ? Array.from(this.graphemeSegmenter.segment(token), ({ segment }) => segment) : [token]; return graphemes.every((grapheme) => /[\p{Emoji}\p{Extended_Pictographic}\u20E3]/u.test(grapheme) && /^[\p{Emoji}\p{Emoji_Component}\p{Extended_Pictographic}\u200D\uFE0F\u20E3]+$/u.test(grapheme), ); } }