import { CommonModule } from '@angular/common'; import { Component, computed, effect, inject, signal } 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 { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly routeParamMap = toSignal(this.route.paramMap, { initialValue: this.route.snapshot.paramMap, }); private composerSelectionStart = 0; private composerSelectionEnd = 0; messageText = ''; readonly forwardingEntryId = signal(null); readonly emojiPickerOpen = 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 conversation = computed(() => this.session .messages() .filter((entry) => entry.peerId === this.peerId()), ); readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId())); readonly remoteVideoModalVisible = computed( () => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(), ); 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); }); } 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.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.session.requestGeneratedImage(peerId, this.messageText); } 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 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); } 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); } 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; } this.forwardingEntryId.set(null); this.emojiPickerOpen.set(false); this.session.selectPeer(peerId); await this.router.navigate(['/chat', peerId]); } }