Files
PrivateChat/client/src/app/chat-page.component.ts

768 lines
22 KiB
TypeScript
Raw Normal View History

2026-03-09 19:35:08 +01:00
import { CommonModule } from '@angular/common';
2026-03-11 00:26:49 +01:00
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
2026-03-09 19:35:08 +01:00
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
2026-03-10 02:49:27 +01:00
import { PeerVideoModalComponent } from './peer-video-modal.component';
2026-03-09 19:35:08 +01:00
import { ChatSessionService } from './chat-session.service';
2026-03-09 20:40:21 +01:00
import { JsonFileViewerComponent } from './json-file-viewer.component';
2026-03-10 02:49:27 +01:00
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
2026-03-09 19:35:08 +01:00
@Component({
selector: 'app-chat-page',
2026-03-10 02:49:27 +01:00
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
2026-03-09 19:35:08 +01:00
templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss',
})
2026-03-10 22:36:21 +01:00
export class ChatPageComponent implements OnDestroy {
2026-03-09 19:35:08 +01:00
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
2026-03-11 00:26:49 +01:00
private readonly ngZone = inject(NgZone);
2026-03-09 19:35:08 +01:00
private readonly routeParamMap = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
2026-03-10 02:49:27 +01:00
private composerSelectionStart = 0;
private composerSelectionEnd = 0;
2026-03-10 22:36:21 +01:00
private voiceRecorder: MediaRecorder | null = null;
private voiceStream: MediaStream | null = null;
private voiceChunks: Blob[] = [];
private discardRecordedVoice = false;
private recordingPeerId: string | null = null;
2026-03-11 00:26:49 +01:00
private dictationRecorder: MediaRecorder | null = null;
private dictationStream: MediaStream | null = null;
private dictationChunks: Blob[] = [];
private dictationBaseText = '';
private discardRecordedDictation = false;
private dictationCompletionPromise: Promise<void> | null = null;
private resolveDictationCompletion: (() => void) | null = null;
private dictationApplyToken = 0;
2026-03-10 22:36:21 +01:00
@ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
this.syncCallAudioSource();
}
private callAudioElement?: ElementRef<HTMLAudioElement>;
2026-03-09 19:35:08 +01:00
messageText = '';
2026-03-10 02:49:27 +01:00
readonly forwardingEntryId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false);
2026-03-10 22:36:21 +01:00
readonly isRecordingVoice = signal(false);
2026-03-11 00:26:49 +01:00
readonly isDictating = signal(false);
readonly isTranscribingDictation = signal(false);
2026-03-10 02:49:27 +01:00
readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😊',
'😉', '😍', '😘', '😎', '🤔',
'😅', '😭', '😡', '😴', '🙃',
'👍', '👎', '👏', '🙏', '🤝',
'🎉', '🔥', '❤️', '💡', '✅',
'🚀', '👀', '📹', '📎', '💬',
'🌍', '⚡', '⭐', '🎵', '📷',
'🗑️', '⏩', '🛑', '🙌', '👌',
];
2026-03-09 19:35:08 +01:00
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());
2026-03-10 22:36:21 +01:00
readonly incomingVoiceCallPeer = computed(() => {
const peerId = this.session.incomingVoiceCallPeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
});
2026-03-09 19:35:08 +01:00
readonly conversation = computed(() =>
this.session
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
2026-03-10 02:49:27 +01:00
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
2026-03-10 22:36:21 +01:00
readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
);
2026-03-10 02:49:27 +01:00
readonly remoteVideoModalVisible = computed(
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
);
2026-03-10 22:36:21 +01:00
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
);
});
2026-03-09 19:35:08 +01:00
readonly webRtcState = computed<ConnectionState>(() => {
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);
});
2026-03-10 22:36:21 +01:00
effect(() => {
this.remoteCallAudioStream();
this.syncCallAudioSource();
});
}
ngOnDestroy(): void {
2026-03-11 00:26:49 +01:00
void this.stopDictation(true);
2026-03-10 22:36:21 +01:00
this.stopVoiceRecording(true);
this.detachCallAudioSource();
2026-03-09 19:35:08 +01:00
}
async ensureConnection(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.selectPeer(peerId);
await this.session.connectToPeer(peerId);
}
async sendMessage(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
2026-03-11 00:26:49 +01:00
await this.stopDictation(false);
2026-03-09 19:35:08 +01:00
await this.session.sendText(peerId, this.messageText);
this.messageText = '';
2026-03-10 02:49:27 +01:00
this.emojiPickerOpen.set(false);
this.composerSelectionStart = 0;
this.composerSelectionEnd = 0;
}
async requestGeneratedImage(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
2026-03-11 00:26:49 +01:00
await this.stopDictation(false);
2026-03-10 03:27:11 +01:00
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;
2026-03-09 19:35:08 +01:00
}
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);
}
2026-03-10 02:49:27 +01:00
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);
});
}
2026-03-09 19:35:08 +01:00
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0);
if (!file) {
return;
}
await this.session.sendFile(peerId, file);
input.value = '';
}
2026-03-11 00:26:49 +01:00
async toggleDictation(textarea: HTMLTextAreaElement): Promise<void> {
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<void>((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();
}
}
2026-03-10 22:36:21 +01:00
async toggleVoiceRecording(): Promise<void> {
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();
}
}
2026-03-09 19:35:08 +01:00
async deleteMessage(entry: ChatEntry): Promise<void> {
await this.session.deleteMessage(entry);
}
2026-03-09 20:40:21 +01:00
async deleteConversation(peerId: string, event?: Event): Promise<void> {
event?.stopPropagation();
await this.session.deleteConversation(peerId);
}
2026-03-10 02:49:27 +01:00
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<void> {
if (!targetPeerId) {
return;
}
await this.session.forwardMessage(targetPeerId, entry);
select.value = '';
this.forwardingEntryId.set(null);
}
async toggleCameraStream(peerId: string): Promise<void> {
if (this.session.isStreamingCameraToPeer(peerId)) {
await this.session.stopCameraStream(peerId);
return;
}
await this.session.startCameraStream(peerId);
}
2026-03-10 22:36:21 +01:00
async startVoiceCall(peerId: string): Promise<void> {
await this.session.startVoiceCall(peerId);
}
async endVoiceCall(peerId: string): Promise<void> {
await this.session.endVoiceCall(peerId);
}
async acceptIncomingVoiceCall(peerId: string): Promise<void> {
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);
}
2026-03-09 19:35:08 +01:00
isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
}
2026-03-10 02:49:27 +01:00
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 ?? '');
}
2026-03-09 20:40:21 +01:00
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
return (
entry.kind === 'file' &&
entry.direction === 'incoming' &&
!!entry.downloadUrl &&
!!entry.fileName &&
entry.fileName.toLowerCase().endsWith('.json')
);
}
2026-03-09 19:35:08 +01:00
isPeerTyping(peerId: string): boolean {
return this.session.typingPeerIds().includes(peerId);
}
2026-03-10 22:36:21 +01:00
isPeerUnread(peerId: string): boolean {
return this.session.unreadPeerIds().includes(peerId);
}
2026-03-09 19:35:08 +01:00
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') {
return 'ok';
}
if (state === 'connecting') {
return 'connecting';
}
return 'offline';
}
2026-03-09 20:09:46 +01:00
canReconnectWebRtc(): boolean {
return this.indicatorTone(this.webRtcState()) === 'offline';
}
2026-03-10 02:49:27 +01:00
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);
}
2026-03-09 19:35:08 +01:00
async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) {
return;
}
2026-03-11 00:26:49 +01:00
await this.stopDictation(true);
2026-03-10 22:36:21 +01:00
this.stopVoiceRecording(true);
2026-03-10 02:49:27 +01:00
this.forwardingEntryId.set(null);
this.emojiPickerOpen.set(false);
2026-03-09 19:35:08 +01:00
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
2026-03-10 22:36:21 +01:00
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)) ?? '';
}
2026-03-11 00:26:49 +01:00
private async stopDictation(discard: boolean): Promise<void> {
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<void> {
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);
});
}
2026-03-10 22:36:21 +01:00
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;
}
2026-03-09 19:35:08 +01:00
}