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 { PeerVideoModalComponent } from './peer-video-modal.component'; import { ChatSessionService } from './chat-session.service'; import { JsonFileViewerComponent } from './json-file-viewer.component'; import type { ChatEntry, ConnectionState, PeerSummary } from './models'; @Component({ selector: 'app-chat-page', imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent], templateUrl: './chat-page.component.html', styleUrl: './chat-page.component.scss', }) export class ChatPageComponent implements OnDestroy { 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; @ViewChild('callAudioElement') set callAudioElementRef(value: ElementRef | undefined) { this.callAudioElement = value; this.syncCallAudioSource(); } private callAudioElement?: ElementRef; messageText = ''; readonly forwardingEntryId = signal(null); 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 currentUser = computed(() => this.session.currentUser()); readonly incomingVoiceCallPeer = computed(() => { const peerId = this.session.incomingVoiceCallPeerId(); 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 remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId())); readonly remoteCallAudioStream = computed(() => this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''), ); readonly remoteVideoModalVisible = computed( () => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(), ); 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(); }); } 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); }); } 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); } 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); } 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 toggleCameraStream(peerId: string): Promise { if (this.session.isStreamingCameraToPeer(peerId)) { await this.session.stopCameraStream(peerId); return; } await this.session.startCameraStream(peerId); } async startVoiceCall(peerId: string): Promise { await this.session.startVoiceCall(peerId); } async endVoiceCall(peerId: string): Promise { await this.session.endVoiceCall(peerId); } async acceptIncomingVoiceCall(peerId: string): Promise { if (!peerId) { return; } if (peerId !== this.peerId()) { await this.router.navigate(['/chat', peerId]); } 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); } 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') ); } 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'; } isStreamingCameraToSelectedPeer(): boolean { const peerId = this.peerId(); return !!peerId && this.session.isStreamingCameraToPeer(peerId); } closeRemoteVideoModal(): void { const peerId = this.peerId(); if (!peerId) { return; } this.session.dismissRemoteVideoModal(peerId); } async switchPeer(peerId: string): Promise { if (!peerId || peerId === this.peerId()) { return; } await this.stopDictation(true); this.stopVoiceRecording(true); this.forwardingEntryId.set(null); 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; } }