import { CommonModule } from '@angular/common'; import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, untracked, 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'; type KnownPeerSummary = { id: string; displayName: string; }; type DropdownPeerSummary = PeerSummary & { knownOnly: boolean }; @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 static readonly knownPeersStoragePrefix = 'privatechat.knownPeers'; 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; private lastAutoConnectedPeerId: string | 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 knownPeers = signal([]); readonly emojiOptions = [ '๐Ÿ˜€', '๐Ÿ˜', '๐Ÿ˜‚', '๐Ÿคฃ', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜…', '๐Ÿ˜†', '๐Ÿ˜‰', '๐Ÿ˜Š', '๐Ÿ˜‹', '๐Ÿ˜Ž', '๐Ÿ˜', '๐Ÿ˜˜', '๐Ÿฅฐ', '๐Ÿ˜—', '๐Ÿ˜™', '๐Ÿ˜š', '๐Ÿ™‚', '๐Ÿค—', '๐Ÿคฉ', '๐Ÿค”', '๐Ÿคจ', '๐Ÿ˜', '๐Ÿ˜‘', '๐Ÿ˜ถ', '๐Ÿ™„', '๐Ÿ˜', '๐Ÿ˜ฃ', '๐Ÿ˜ฅ', '๐Ÿ˜ฎ', '๐Ÿค', '๐Ÿ˜ฏ', '๐Ÿ˜ช', '๐Ÿ˜ซ', '๐Ÿฅฑ', '๐Ÿ˜ด', '๐Ÿ˜Œ', '๐Ÿ˜›', '๐Ÿ˜œ', '๐Ÿ˜', '๐Ÿคค', '๐Ÿ˜’', '๐Ÿ˜“', '๐Ÿ˜”', '๐Ÿ˜•', '๐Ÿ™ƒ', '๐Ÿซ ', '๐Ÿค‘', '๐Ÿ˜ฒ', 'โ˜น๏ธ', '๐Ÿ™', '๐Ÿ˜–', '๐Ÿ˜ž', '๐Ÿ˜Ÿ', '๐Ÿ˜ค', '๐Ÿ˜ข', '๐Ÿ˜ญ', '๐Ÿ˜ฆ', '๐Ÿ˜ง', '๐Ÿ˜จ', '๐Ÿ˜ฉ', '๐Ÿคฏ', '๐Ÿ˜ฌ', '๐Ÿ˜ฐ', '๐Ÿ˜ฑ', '๐Ÿฅต', '๐Ÿฅถ', '๐Ÿ˜ณ', '๐Ÿคช', '๐Ÿ˜ต', '๐Ÿฅด', '๐Ÿ˜ ', '๐Ÿ˜ก', '๐Ÿคฌ', '๐Ÿ˜ท', '๐Ÿค’', '๐Ÿค•', '๐Ÿคข', '๐Ÿคฎ', '๐Ÿคง', '๐Ÿ˜‡', '๐Ÿฅณ', '๐Ÿฅบ', '๐Ÿค ', '๐Ÿคก', '๐Ÿคฅ', '๐Ÿคซ', '๐Ÿคญ', '๐Ÿง', '๐Ÿค“', '๐Ÿ˜ˆ', '๐Ÿ‘ฟ', '๐Ÿ‘น', '๐Ÿ‘บ', '๐Ÿ’€', 'โ˜ ๏ธ', '๐Ÿ‘ป', '๐Ÿ‘ฝ', '๐Ÿค–', '๐Ÿ’ฉ', '๐Ÿ˜บ', '๐Ÿ˜ธ', '๐Ÿ˜น', '๐Ÿ˜ป', '๐Ÿ˜ผ', '๐Ÿ˜ฝ', '๐Ÿ™€', '๐Ÿ˜ฟ', '๐Ÿ˜พ', '๐Ÿ™ˆ', '๐Ÿ™‰', '๐Ÿ™Š', '๐Ÿ’‹', '๐Ÿ’Œ', '๐Ÿ’˜', '๐Ÿ’', '๐Ÿ’–', '๐Ÿ’—', '๐Ÿ’“', '๐Ÿ’ž', '๐Ÿ’•', 'โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿค', '๐ŸคŽ', '๐Ÿ’”', 'โค๏ธโ€๐Ÿ”ฅ', 'โค๏ธโ€๐Ÿฉน', 'โฃ๏ธ', '๐Ÿ’ฏ', '๐Ÿ’ข', '๐Ÿ’ฅ', '๐Ÿ’ซ', '๐Ÿ’ฆ', '๐Ÿ’จ', '๐Ÿ•ณ๏ธ', '๐Ÿ’ฌ', '๐Ÿ—จ๏ธ', '๐Ÿ—ฏ๏ธ', '๐Ÿ’ญ', '๐Ÿ’ค', '๐Ÿ‘‹', '๐Ÿคš', '๐Ÿ–๏ธ', 'โœ‹', '๐Ÿ––', '๐Ÿซฑ', '๐Ÿซฒ', '๐Ÿซณ', '๐Ÿซด', '๐Ÿ‘Œ', '๐ŸคŒ', '๐Ÿค', 'โœŒ๏ธ', '๐Ÿคž', '๐Ÿซฐ', '๐ŸคŸ', '๐Ÿค˜', '๐Ÿค™', '๐Ÿ‘ˆ', '๐Ÿ‘‰', '๐Ÿ‘†', '๐Ÿ‘‡', 'โ˜๏ธ', '๐Ÿ‘', '๐Ÿ‘Ž', 'โœŠ', '๐Ÿ‘Š', '๐Ÿค›', '๐Ÿคœ', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿซถ', '๐Ÿ‘', '๐Ÿคฒ', '๐Ÿ™', 'โœ๏ธ', '๐Ÿ’…', '๐Ÿคณ', '๐Ÿ’ช', '๐Ÿฆพ', '๐Ÿฆฟ', '๐Ÿฆต', '๐Ÿฆถ', '๐Ÿ‘‚', '๐Ÿฆป', '๐Ÿ‘ƒ', '๐Ÿง ', '๐Ÿซ€', '๐Ÿซ', '๐Ÿฆท', '๐Ÿฆด', '๐Ÿ‘€', '๐Ÿ‘๏ธ', '๐Ÿ‘…', '๐Ÿ‘„', '๐Ÿซฆ', '๐ŸŒ', '๐ŸŒŽ', '๐ŸŒ', '๐ŸŒ•', 'โญ', '๐ŸŒŸ', 'โœจ', 'โšก', '๐Ÿ”ฅ', '๐Ÿ’ง', '๐ŸŒˆ', 'โ˜€๏ธ', '๐ŸŒค๏ธ', 'โ›…', '๐ŸŒง๏ธ', 'โ›ˆ๏ธ', '๐ŸŒฉ๏ธ', 'โ„๏ธ', 'โ˜ƒ๏ธ', 'โ˜”', '๐ŸŽ', '๐ŸŠ', '๐Ÿ‹', '๐Ÿ‰', '๐Ÿ‡', '๐Ÿ“', '๐Ÿ’', '๐Ÿ‘', '๐Ÿ', '๐Ÿฅฅ', '๐Ÿฅ‘', '๐Ÿ”', '๐Ÿ•', '๐ŸŒฎ', '๐Ÿฃ', '๐Ÿช', '๐ŸŽ‚', 'โ˜•', '๐Ÿต', '๐Ÿน', '๐ŸŽ‰', '๐ŸŽˆ', '๐ŸŽ', '๐Ÿ†', '๐Ÿš€', '๐Ÿ“ท', '๐ŸŽต', ]; readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); readonly dropdownPeers = computed(() => { const connectedPeers = this.session.peers(); const connectedPeerIds = new Set(connectedPeers.map((peer) => peer.id)); const knownOnlyPeers = this.knownPeers() .filter((peer) => !connectedPeerIds.has(peer.id)) .sort((left, right) => left.displayName.localeCompare(right.displayName)) .map((peer) => ({ id: peer.id, username: peer.id, displayName: peer.displayName, connectionState: 'disconnected', channelState: 'closed', knownOnly: true, })); return [ ...connectedPeers.map((peer) => ({ ...peer, knownOnly: false })), ...knownOnlyPeers, ]; }); readonly displayedPeer = computed(() => { const selectedPeerId = this.peerId(); const peers = this.dropdownPeers(); return (selectedPeerId ? peers.find((peer) => peer.id === selectedPeerId) ?? null : null) ?? 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.dropdownPeers().find((peer) => peer.id === peerId) ?? null : null; }); readonly conversation = computed(() => this.session .messages() .filter((entry) => entry.peerId === this.peerId()), ); readonly lastIncomingReceiveMetric = computed(() => { const metric = this.session.lastIncomingReceiveMetric(); return metric?.peerId === this.peerId() ? metric : null; }); 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 currentUserId = this.currentUser()?.id ?? null; this.knownPeers.set(this.readKnownPeers(currentUserId)); }); effect(() => { const connectedPeers = this.session.peers(); if (connectedPeers.length === 0) { return; } this.mergeKnownPeers( connectedPeers.map((peer) => ({ id: peer.id, displayName: peer.displayName })), ); }); effect(() => { const currentUserId = this.currentUser()?.id ?? null; const knownPeersFromMessages = this.session.messages() .filter((entry) => entry.direction !== 'system' && entry.kind !== 'system') .map((entry) => ({ id: entry.peerId, displayName: entry.direction === 'incoming' && entry.authorLabel !== 'You' ? entry.authorLabel : this.findKnownPeerDisplayName(entry.peerId) ?? entry.peerId, })); if (!currentUserId || knownPeersFromMessages.length === 0) { return; } this.mergeKnownPeers(knownPeersFromMessages); }); effect(() => { const peerId = this.peerId(); const hasLivePeer = !!this.peer(); if (!peerId) { this.lastAutoConnectedPeerId = null; return; } this.session.selectPeer(peerId); if (!hasLivePeer) { if (this.lastAutoConnectedPeerId === peerId) { this.lastAutoConnectedPeerId = null; } return; } if (this.lastAutoConnectedPeerId === peerId) { return; } this.lastAutoConnectedPeerId = 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 || !this.peer()) { return; } this.session.selectPeer(peerId); await this.session.reconnectToPeer(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); this.removeKnownPeer(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.dropdownPeers().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' && !!this.imageDisplayUrl(entry); } isGeneratedImageEntry(entry: ChatEntry): boolean { return this.isImageEntry(entry) && entry.generatedByAi === true; } imageDisplayUrl(entry: ChatEntry): string | null { if (entry.kind !== 'file' || !(entry.fileMimeType?.startsWith('image/') ?? false)) { return null; } return entry.previewDownloadUrl ?? entry.downloadUrl ?? null; } 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.fileMimeType?.startsWith('image/') ?? false) && !!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); } isPendingOutgoingEntry(entry: ChatEntry): boolean { return entry.direction === 'outgoing' && entry.deliveryState === 'pending'; } indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' { if (state === 'connected') { return 'ok'; } if (state === 'connecting') { return 'connecting'; } return 'offline'; } canReconnectWebRtc(): boolean { return !!this.peerId() && !!this.peer() && this.indicatorTone(this.webRtcState()) !== 'ok'; } 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); await this.router.navigate(['/chat', peerId]); } private mergeKnownPeers(peers: Array<{ id: string; displayName?: string }>): void { const currentUserId = this.currentUser()?.id; if (!currentUserId) { return; } const currentKnownPeers = untracked(this.knownPeers); const nextPeers = this.normalizeKnownPeers([ ...currentKnownPeers, ...peers, ], currentUserId); if (this.areKnownPeersEqual(nextPeers, currentKnownPeers)) { return; } this.knownPeers.set(nextPeers); this.writeKnownPeers(currentUserId, nextPeers); } private removeKnownPeer(peerId: string): void { const currentUserId = this.currentUser()?.id; if (!currentUserId) { return; } const currentKnownPeers = untracked(this.knownPeers); const nextPeers = currentKnownPeers.filter((knownPeer) => knownPeer.id !== peerId); if (nextPeers.length === currentKnownPeers.length) { return; } this.knownPeers.set(nextPeers); this.writeKnownPeers(currentUserId, nextPeers); } private readKnownPeers(currentUserId: string | null): KnownPeerSummary[] { if (!currentUserId) { return []; } const storedValue = this.readStorage(this.knownPeersStorageKey(currentUserId)); if (!storedValue) { return []; } try { const parsedValue = JSON.parse(storedValue); if (!Array.isArray(parsedValue)) { return []; } return this.normalizeKnownPeers(parsedValue, currentUserId); } catch { return []; } } private writeKnownPeers(currentUserId: string, peers: KnownPeerSummary[]): void { const storageKey = this.knownPeersStorageKey(currentUserId); if (peers.length === 0) { this.removeStorage(storageKey); return; } this.writeStorage(storageKey, JSON.stringify(peers)); } private normalizeKnownPeers(peers: unknown[], currentUserId: string): KnownPeerSummary[] { const peerMap = new Map(); for (const peer of peers) { if (typeof peer === 'string') { const id = peer.trim(); if (!id || id === currentUserId) { continue; } peerMap.set(id, { id, displayName: id }); continue; } if (!peer || typeof peer !== 'object') { continue; } const candidate = peer as Partial; const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; if (!id || id === currentUserId) { continue; } const displayName = typeof candidate.displayName === 'string' && candidate.displayName.trim() ? candidate.displayName.trim() : peerMap.get(id)?.displayName ?? id; peerMap.set(id, { id, displayName }); } return Array.from(peerMap.values()) .sort((left, right) => left.displayName.localeCompare(right.displayName)); } private areKnownPeersEqual(left: KnownPeerSummary[], right: KnownPeerSummary[]): boolean { return left.length === right.length && left.every((peer, index) => peer.id === right[index]?.id && peer.displayName === right[index]?.displayName, ); } private findKnownPeerDisplayName(peerId: string): string | null { return untracked(this.knownPeers).find((peer) => peer.id === peerId)?.displayName ?? null; } private knownPeersStorageKey(currentUserId: string): string { return `${ChatPageComponent.knownPeersStoragePrefix}.${currentUserId}`; } private readStorage(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } private writeStorage(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { // Ignore storage errors in private browsing modes. } } private removeStorage(key: string): void { try { localStorage.removeItem(key); } catch { // Ignore storage errors in private browsing modes. } } 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), ); } }