Files
PrivateChat/client/src/app/chat-page.component.ts
2026-03-25 23:41:38 +01:00

1229 lines
36 KiB
TypeScript

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<void> | 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<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
this.syncCallAudioSource();
}
private callAudioElement?: ElementRef<HTMLAudioElement>;
@ViewChild('conversationContainer')
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
this.conversationContainer = value;
}
private conversationContainer?: ElementRef<HTMLDivElement>;
@ViewChild('fullscreenConversationContainer')
set fullscreenConversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
this.fullscreenConversationContainer = value;
}
private fullscreenConversationContainer?: ElementRef<HTMLDivElement>;
messageText = '';
readonly forwardingEntryId = signal<string | null>(null);
readonly callChoicePeerId = signal<string | null>(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<KnownPeerSummary[]>([]);
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<DropdownPeerSummary[]>(() => {
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<DropdownPeerSummary>((peer) => ({
id: peer.id,
username: peer.id,
displayName: peer.displayName,
connectionState: 'disconnected',
channelState: 'closed',
knownOnly: true,
}));
return [
...connectedPeers.map<DropdownPeerSummary>((peer) => ({ ...peer, knownOnly: false })),
...knownOnlyPeers,
];
});
readonly displayedPeer = computed<DropdownPeerSummary | null>(() => {
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<CallMode>(() => 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<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 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<void> {
const peerId = this.peerId();
if (!peerId || !this.peer()) {
return;
}
this.session.selectPeer(peerId);
await this.session.reconnectToPeer(peerId);
}
async sendMessage(): Promise<void> {
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<void> {
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<void> {
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<void> {
const file = input.files?.item(0);
if (!file) {
return;
}
await this.session.sendFile(peerId, file);
input.value = '';
}
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();
}
}
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();
}
}
async deleteMessage(entry: ChatEntry): Promise<void> {
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<void> {
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<void> {
this.closePeerDropdown();
await this.switchPeer(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 sendGeneratedImage(entry: ChatEntry): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.sendGeneratedImageToPeer(entry, peerId);
}
async endVoiceCall(peerId: string): Promise<void> {
await this.session.endVoiceCall(peerId);
}
async acceptIncomingVoiceCall(peerId: string): Promise<void> {
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<void> {
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<string, KnownPeerSummary>();
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<KnownPeerSummary>;
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<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);
});
}
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),
);
}
}