2026-03-09 19:35:08 +01:00
|
|
|
import { CommonModule } from '@angular/common';
|
2026-03-25 20:09:36 +01:00
|
|
|
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, untracked, 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-11 08:05:54 +01:00
|
|
|
import { PeerCallModalComponent } from './peer-call-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-11 08:05:54 +01:00
|
|
|
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
|
2026-03-09 19:35:08 +01:00
|
|
|
|
2026-03-25 20:09:36 +01:00
|
|
|
type KnownPeerSummary = {
|
|
|
|
|
id: string;
|
|
|
|
|
displayName: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type DropdownPeerSummary = PeerSummary & { knownOnly: boolean };
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
@Component({
|
|
|
|
|
selector: 'app-chat-page',
|
2026-03-11 09:09:15 +01:00
|
|
|
imports: [
|
|
|
|
|
CommonModule,
|
|
|
|
|
FormsModule,
|
|
|
|
|
RouterLink,
|
|
|
|
|
JsonFileViewerComponent,
|
|
|
|
|
PeerCallModalComponent,
|
|
|
|
|
],
|
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-25 20:09:36 +01:00
|
|
|
private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers';
|
2026-03-11 17:17:54 +01:00
|
|
|
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
|
|
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
|
|
|
: null;
|
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-11 09:09:15 +01:00
|
|
|
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
2026-03-25 20:22:28 +01:00
|
|
|
private lastAutoConnectedPeerId: string | null = null;
|
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-11 09:09:15 +01:00
|
|
|
@ViewChild('conversationContainer')
|
|
|
|
|
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
|
|
|
|
this.conversationContainer = value;
|
|
|
|
|
}
|
|
|
|
|
private conversationContainer?: ElementRef<HTMLDivElement>;
|
2026-03-11 16:48:39 +01:00
|
|
|
@ViewChild('fullscreenConversationContainer')
|
|
|
|
|
set fullscreenConversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
|
|
|
|
this.fullscreenConversationContainer = value;
|
|
|
|
|
}
|
|
|
|
|
private fullscreenConversationContainer?: ElementRef<HTMLDivElement>;
|
2026-03-09 19:35:08 +01:00
|
|
|
|
|
|
|
|
messageText = '';
|
2026-03-10 02:49:27 +01:00
|
|
|
readonly forwardingEntryId = signal<string | null>(null);
|
2026-03-11 08:05:54 +01:00
|
|
|
readonly callChoicePeerId = signal<string | null>(null);
|
2026-03-11 16:48:39 +01:00
|
|
|
readonly conversationModalOpen = signal(false);
|
|
|
|
|
readonly peerDropdownOpen = signal(false);
|
2026-03-10 02:49:27 +01:00
|
|
|
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-25 20:09:36 +01:00
|
|
|
readonly knownPeers = signal<KnownPeerSummary[]>([]);
|
2026-03-10 02:49:27 +01:00
|
|
|
readonly emojiOptions = [
|
2026-03-11 17:59:14 +01:00
|
|
|
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
|
|
|
|
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
|
|
|
|
|
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
|
|
|
|
|
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
|
|
|
|
|
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🫠', '🤑', '😲',
|
|
|
|
|
'☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧',
|
|
|
|
|
'😨', '😩', '🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪',
|
|
|
|
|
'😵', '🥴', '😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮',
|
|
|
|
|
'🤧', '😇', '🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐',
|
|
|
|
|
'🤓', '😈', '👿', '👹', '👺', '💀', '☠️', '👻', '👽', '🤖',
|
|
|
|
|
'💩', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾',
|
|
|
|
|
'🙈', '🙉', '🙊', '💋', '💌', '💘', '💝', '💖', '💗', '💓',
|
|
|
|
|
'💞', '💕', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
|
|
|
|
'🤎', '💔', '❤️🔥', '❤️🩹', '❣️', '💯', '💢', '💥', '💫', '💦',
|
|
|
|
|
'💨', '🕳️', '💬', '🗨️', '🗯️', '💭', '💤', '👋', '🤚', '🖐️',
|
|
|
|
|
'✋', '🖖', '🫱', '🫲', '🫳', '🫴', '👌', '🤌', '🤏', '✌️',
|
|
|
|
|
'🤞', '🫰', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝️',
|
|
|
|
|
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '🫶', '👐',
|
|
|
|
|
'🤲', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶',
|
|
|
|
|
'👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️',
|
|
|
|
|
'👅', '👄', '🫦', '🌍', '🌎', '🌏', '🌕', '⭐', '🌟', '✨',
|
|
|
|
|
'⚡', '🔥', '💧', '🌈', '☀️', '🌤️', '⛅', '🌧️', '⛈️', '🌩️',
|
|
|
|
|
'❄️', '☃️', '☔', '🍎', '🍊', '🍋', '🍉', '🍇', '🍓', '🍒',
|
|
|
|
|
'🍑', '🍍', '🥥', '🥑', '🍔', '🍕', '🌮', '🍣', '🍪', '🎂',
|
|
|
|
|
'☕', '🍵', '🍹', '🎉', '🎈', '🎁', '🏆', '🚀', '📷', '🎵',
|
2026-03-10 02:49:27 +01:00
|
|
|
];
|
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);
|
2026-03-25 20:09:36 +01:00
|
|
|
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;
|
|
|
|
|
});
|
2026-03-09 19:35:08 +01:00
|
|
|
readonly currentUser = computed(() => this.session.currentUser());
|
2026-03-11 08:05:54 +01:00
|
|
|
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();
|
2026-03-10 22:36:21 +01:00
|
|
|
|
2026-03-25 20:09:36 +01:00
|
|
|
return peerId ? this.dropdownPeers().find((peer) => peer.id === peerId) ?? null : null;
|
2026-03-10 22:36:21 +01:00
|
|
|
});
|
2026-03-09 19:35:08 +01:00
|
|
|
readonly conversation = computed(() =>
|
|
|
|
|
this.session
|
|
|
|
|
.messages()
|
|
|
|
|
.filter((entry) => entry.peerId === this.peerId()),
|
|
|
|
|
);
|
2026-03-11 18:12:08 +01:00
|
|
|
readonly lastIncomingReceiveMetric = computed(() => {
|
|
|
|
|
const metric = this.session.lastIncomingReceiveMetric();
|
|
|
|
|
|
|
|
|
|
return metric?.peerId === this.peerId() ? metric : null;
|
|
|
|
|
});
|
2026-03-10 22:36:21 +01:00
|
|
|
readonly remoteCallAudioStream = computed(() =>
|
2026-03-11 08:05:54 +01:00
|
|
|
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
2026-03-10 02:49:27 +01:00
|
|
|
);
|
2026-03-11 08:05:54 +01:00
|
|
|
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.';
|
|
|
|
|
}
|
|
|
|
|
});
|
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(() => {
|
2026-03-25 20:09:36 +01:00
|
|
|
const currentUserId = this.currentUser()?.id ?? null;
|
|
|
|
|
this.knownPeers.set(this.readKnownPeers(currentUserId));
|
|
|
|
|
});
|
2026-03-09 19:35:08 +01:00
|
|
|
|
2026-03-25 20:09:36 +01:00
|
|
|
effect(() => {
|
|
|
|
|
const connectedPeers = this.session.peers();
|
|
|
|
|
|
|
|
|
|
if (connectedPeers.length === 0) {
|
2026-03-09 19:35:08 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 20:09:36 +01:00
|
|
|
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) {
|
2026-03-25 20:22:28 +01:00
|
|
|
this.lastAutoConnectedPeerId = null;
|
2026-03-25 20:09:36 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
this.session.selectPeer(peerId);
|
2026-03-25 20:09:36 +01:00
|
|
|
|
|
|
|
|
if (!hasLivePeer) {
|
2026-03-25 20:22:28 +01:00
|
|
|
if (this.lastAutoConnectedPeerId === peerId) {
|
|
|
|
|
this.lastAutoConnectedPeerId = null;
|
|
|
|
|
}
|
2026-03-25 20:09:36 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 20:22:28 +01:00
|
|
|
if (this.lastAutoConnectedPeerId === peerId) {
|
2026-03-25 20:09:36 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 20:22:28 +01:00
|
|
|
this.lastAutoConnectedPeerId = peerId;
|
2026-03-09 19:35:08 +01:00
|
|
|
void this.session.connectToPeer(peerId);
|
|
|
|
|
});
|
2026-03-10 22:36:21 +01:00
|
|
|
|
|
|
|
|
effect(() => {
|
|
|
|
|
this.remoteCallAudioStream();
|
|
|
|
|
this.syncCallAudioSource();
|
|
|
|
|
});
|
2026-03-11 09:09:15 +01:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
2026-03-10 22:36:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-03-25 20:09:36 +01:00
|
|
|
if (!peerId || !this.peer()) {
|
2026-03-09 19:35:08 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.session.selectPeer(peerId);
|
2026-03-11 17:59:14 +01:00
|
|
|
await this.session.reconnectToPeer(peerId);
|
2026-03-09 19:35:08 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-11 08:05:54 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
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-11 17:17:54 +01:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
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-25 20:09:36 +01:00
|
|
|
this.removeKnownPeer(peerId);
|
2026-03-09 20:40:21 +01:00
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 16:48:39 +01:00
|
|
|
togglePeerDropdown(): void {
|
2026-03-25 20:09:36 +01:00
|
|
|
if (this.dropdownPeers().length === 0) {
|
2026-03-11 16:48:39 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 09:09:15 +01:00
|
|
|
async sendGeneratedImage(entry: ChatEntry): Promise<void> {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
if (!peerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.session.sendGeneratedImageToPeer(entry, peerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 22:36:21 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
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-11 09:09:15 +01:00
|
|
|
isGeneratedImageEntry(entry: ChatEntry): boolean {
|
|
|
|
|
return this.isImageEntry(entry) && entry.generatedByAi === true;
|
|
|
|
|
}
|
|
|
|
|
|
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-11 09:40:03 +01:00
|
|
|
hasDocumentPreviewImage(entry: ChatEntry): boolean {
|
2026-03-11 09:09:15 +01:00
|
|
|
return (
|
|
|
|
|
entry.kind === 'file' &&
|
2026-03-11 09:40:03 +01:00
|
|
|
!!entry.previewDownloadUrl &&
|
|
|
|
|
(entry.previewMimeType?.startsWith('image/') ?? false)
|
2026-03-11 09:09:15 +01:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 09:40:03 +01:00
|
|
|
documentPreviewImageUrl(entry: ChatEntry): string | null {
|
|
|
|
|
if (!this.hasDocumentPreviewImage(entry)) {
|
2026-03-11 09:09:15 +01:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 09:40:03 +01:00
|
|
|
return entry.previewDownloadUrl ?? null;
|
2026-03-11 09:09:15 +01:00
|
|
|
}
|
|
|
|
|
|
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-25 20:09:36 +01:00
|
|
|
isPendingOutgoingEntry(entry: ChatEntry): boolean {
|
|
|
|
|
return entry.direction === 'outgoing' && entry.deliveryState === 'pending';
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-03-25 20:09:36 +01:00
|
|
|
return !!this.peerId() && !!this.peer() && this.indicatorTone(this.webRtcState()) !== 'ok';
|
2026-03-09 20:09:46 +01:00
|
|
|
}
|
|
|
|
|
|
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);
|
2026-03-11 08:05:54 +01:00
|
|
|
this.callChoicePeerId.set(null);
|
2026-03-11 16:48:39 +01:00
|
|
|
this.conversationModalOpen.set(false);
|
|
|
|
|
this.peerDropdownOpen.set(false);
|
2026-03-10 02:49:27 +01:00
|
|
|
this.emojiPickerOpen.set(false);
|
2026-03-09 19:35:08 +01:00
|
|
|
await this.router.navigate(['/chat', peerId]);
|
|
|
|
|
}
|
2026-03-10 22:36:21 +01:00
|
|
|
|
2026-03-25 20:09:36 +01:00
|
|
|
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.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-11 09:09:15 +01:00
|
|
|
|
|
|
|
|
private scrollConversationToBottom(): void {
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
requestAnimationFrame(() => {
|
2026-03-11 16:48:39 +01:00
|
|
|
for (const container of [
|
|
|
|
|
this.conversationContainer?.nativeElement,
|
|
|
|
|
this.fullscreenConversationContainer?.nativeElement,
|
|
|
|
|
]) {
|
|
|
|
|
if (!container) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
|
}
|
2026-03-11 09:09:15 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-11 17:17:54 +01:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-09 19:35:08 +01:00
|
|
|
}
|