offline users and messages
This commit is contained in:
@@ -46,7 +46,7 @@
|
||||
<header class="conversation-modal-header">
|
||||
<div>
|
||||
<p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p>
|
||||
<h2 class="h5 mb-0">{{ peer()?.displayName ?? 'Conversation' }}</h2>
|
||||
<h2 class="h5 mb-0">{{ displayedPeer()?.displayName ?? 'Conversation' }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="conversation-modal-close"
|
||||
@@ -113,21 +113,21 @@
|
||||
|
||||
@if (peerDropdownOpen()) {
|
||||
<div class="peer-dropdown-menu" role="listbox">
|
||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||
@for (dropdownPeer of dropdownPeers(); track dropdownPeer.id) {
|
||||
<article
|
||||
class="peer-tile"
|
||||
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
||||
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
|
||||
[class.peer-tile-active]="dropdownPeer.id === peerId()"
|
||||
[class.peer-tile-unread]="isPeerUnread(dropdownPeer.id)"
|
||||
>
|
||||
<button
|
||||
class="peer-tile-main text-start"
|
||||
type="button"
|
||||
(click)="selectPeerFromDropdown(connectedPeer.id)"
|
||||
(click)="selectPeerFromDropdown(dropdownPeer.id)"
|
||||
>
|
||||
<div class="peer-tile-row">
|
||||
<span class="peer-tile-title">
|
||||
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
|
||||
@if (isPeerTyping(connectedPeer.id)) {
|
||||
<span class="fw-semibold">{{ dropdownPeer.displayName }}</span>
|
||||
@if (isPeerTyping(dropdownPeer.id)) {
|
||||
<span class="peer-typing-dots" aria-label="Typing">
|
||||
<span></span>
|
||||
<span></span>
|
||||
@@ -137,10 +137,10 @@
|
||||
</span>
|
||||
<span
|
||||
class="status-led peer-tile-status"
|
||||
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
|
||||
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
|
||||
[class.status-led-ok]="dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'"
|
||||
[class.status-led-offline]="dropdownPeer.channelState !== 'open' && dropdownPeer.connectionState !== 'connected'"
|
||||
[attr.aria-label]="
|
||||
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
|
||||
dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'
|
||||
? 'Connected'
|
||||
: 'Disconnected'
|
||||
"
|
||||
@@ -152,7 +152,7 @@
|
||||
type="button"
|
||||
title="Delete conversation"
|
||||
aria-label="Delete conversation"
|
||||
(click)="deleteConversation(connectedPeer.id, $event)"
|
||||
(click)="deleteConversation(dropdownPeer.id, $event)"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
@@ -215,83 +215,85 @@
|
||||
(click)="trackComposerSelection(composerTextarea)"
|
||||
(keyup)="trackComposerSelection(composerTextarea)"
|
||||
(select)="trackComposerSelection(composerTextarea)"
|
||||
[disabled]="!session.isSelectedPeerReady()"
|
||||
placeholder="Write a text message to your peer"
|
||||
[disabled]="!peerId()"
|
||||
placeholder="Write a text message to your peer, even if they are offline"
|
||||
></textarea>
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<div class="composer-actions">
|
||||
@if (peer(); as selectedPeer) {
|
||||
<button
|
||||
class="composer-call"
|
||||
type="button"
|
||||
[disabled]="!canStartSelectedVoiceCall()"
|
||||
(click)="openCallChoice(selectedPeer.id)"
|
||||
title="Start call"
|
||||
aria-label="Start call"
|
||||
>
|
||||
📞
|
||||
</button>
|
||||
|
||||
@if (canEndSelectedVoiceCall()) {
|
||||
@if (peerId(); as selectedPeerId) {
|
||||
@if (peer(); as livePeer) {
|
||||
<button
|
||||
class="composer-hangup"
|
||||
class="composer-call"
|
||||
type="button"
|
||||
(click)="endVoiceCall(selectedPeer.id)"
|
||||
title="End call"
|
||||
aria-label="End call"
|
||||
[disabled]="!canStartSelectedVoiceCall()"
|
||||
(click)="openCallChoice(livePeer.id)"
|
||||
title="Start call"
|
||||
aria-label="Start call"
|
||||
>
|
||||
🛑
|
||||
📞
|
||||
</button>
|
||||
|
||||
@if (canEndSelectedVoiceCall()) {
|
||||
<button
|
||||
class="composer-hangup"
|
||||
type="button"
|
||||
(click)="endVoiceCall(livePeer.id)"
|
||||
title="End call"
|
||||
aria-label="End call"
|
||||
>
|
||||
🛑
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
class="composer-voice"
|
||||
type="button"
|
||||
[disabled]="livePeer.channelState !== 'open' && !isRecordingVoice()"
|
||||
(click)="toggleVoiceRecording()"
|
||||
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||
[class.composer-voice-recording]="isRecordingVoice()"
|
||||
>
|
||||
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="composer-dictation"
|
||||
type="button"
|
||||
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
||||
(click)="toggleDictation(composerTextarea)"
|
||||
[title]="
|
||||
isDictating()
|
||||
? 'Stop dictation and transcribe'
|
||||
: isTranscribingDictation()
|
||||
? 'Transcribing dictated audio'
|
||||
: 'Start dictation'
|
||||
"
|
||||
[attr.aria-label]="
|
||||
isDictating()
|
||||
? 'Stop dictation and transcribe'
|
||||
: isTranscribingDictation()
|
||||
? 'Transcribing dictated audio'
|
||||
: 'Start dictation'
|
||||
"
|
||||
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
||||
>
|
||||
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
class="composer-voice"
|
||||
type="button"
|
||||
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
|
||||
(click)="toggleVoiceRecording()"
|
||||
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||
[class.composer-voice-recording]="isRecordingVoice()"
|
||||
>
|
||||
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="composer-dictation"
|
||||
type="button"
|
||||
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
||||
(click)="toggleDictation(composerTextarea)"
|
||||
[title]="
|
||||
isDictating()
|
||||
? 'Stop dictation and transcribe'
|
||||
: isTranscribingDictation()
|
||||
? 'Transcribing dictated audio'
|
||||
: 'Start dictation'
|
||||
"
|
||||
[attr.aria-label]="
|
||||
isDictating()
|
||||
? 'Stop dictation and transcribe'
|
||||
: isTranscribingDictation()
|
||||
? 'Transcribing dictated audio'
|
||||
: 'Start dictation'
|
||||
"
|
||||
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
||||
>
|
||||
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
||||
</button>
|
||||
|
||||
<input
|
||||
#fileInput
|
||||
class="composer-file-input"
|
||||
type="file"
|
||||
[disabled]="selectedPeer.channelState !== 'open'"
|
||||
(change)="sendFile(selectedPeer.id, fileInput)"
|
||||
[disabled]="!selectedPeerId"
|
||||
(change)="sendFile(selectedPeerId, fileInput)"
|
||||
/>
|
||||
<button
|
||||
class="composer-plus"
|
||||
type="button"
|
||||
[disabled]="selectedPeer.channelState !== 'open'"
|
||||
[disabled]="!selectedPeerId"
|
||||
(click)="fileInput.click()"
|
||||
title="Send file"
|
||||
aria-label="Send file"
|
||||
@@ -330,7 +332,7 @@
|
||||
<button
|
||||
class="composer-emoji-trigger"
|
||||
type="button"
|
||||
[disabled]="!session.isSelectedPeerReady()"
|
||||
[disabled]="!peerId()"
|
||||
(click)="toggleEmojiPicker($event)"
|
||||
title="Insert emoji"
|
||||
aria-label="Insert emoji"
|
||||
@@ -342,7 +344,7 @@
|
||||
<button
|
||||
class="send-emoji"
|
||||
type="button"
|
||||
[disabled]="!session.isSelectedPeerReady()"
|
||||
[disabled]="!peerId()"
|
||||
(click)="sendMessage()"
|
||||
title="Send message"
|
||||
aria-label="Send message"
|
||||
@@ -368,7 +370,7 @@
|
||||
<ng-template #conversationBubbles>
|
||||
@if (conversation().length === 0) {
|
||||
<div class="empty-chat">
|
||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||
No text messages yet. Messages and files can be queued here and will send when the peer reconnects.
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -377,6 +379,7 @@
|
||||
class="bubble"
|
||||
[class.bubble-incoming]="entry.direction === 'incoming'"
|
||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||
[class.bubble-pending]="isPendingOutgoingEntry(entry)"
|
||||
[class.bubble-system]="entry.direction === 'system'"
|
||||
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
|
||||
>
|
||||
@@ -427,6 +430,9 @@
|
||||
<div class="bubble-meta">
|
||||
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
@if (isPendingOutgoingEntry(entry)) {
|
||||
<span class="bubble-delivery-state">Queued</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -467,6 +467,11 @@
|
||||
background: var(--outgoing-bubble-background);
|
||||
}
|
||||
|
||||
.bubble-pending {
|
||||
opacity: 0.58;
|
||||
filter: grayscale(0.28);
|
||||
}
|
||||
|
||||
.bubble-system {
|
||||
justify-self: center;
|
||||
max-width: 90%;
|
||||
@@ -494,6 +499,14 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bubble-delivery-state {
|
||||
display: inline-block;
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.emoji-only-text {
|
||||
font-size: clamp(2.1rem, 5vw, 3.4rem);
|
||||
line-height: 1.15;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
|
||||
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';
|
||||
@@ -9,6 +9,13 @@ 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: [
|
||||
@@ -22,6 +29,7 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
|
||||
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;
|
||||
@@ -47,6 +55,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
private resolveDictationCompletion: (() => void) | null = null;
|
||||
private dictationApplyToken = 0;
|
||||
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
||||
private lastAutoConnectSnapshot: { peerId: string; hasLivePeer: boolean } | null = null;
|
||||
@ViewChild('callAudioElement')
|
||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||
this.callAudioElement = value;
|
||||
@@ -73,6 +82,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
readonly isRecordingVoice = signal(false);
|
||||
readonly isDictating = signal(false);
|
||||
readonly isTranscribingDictation = signal(false);
|
||||
readonly knownPeers = signal<KnownPeerSummary[]>([]);
|
||||
readonly emojiOptions = [
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
|
||||
@@ -102,7 +112,32 @@ export class ChatPageComponent implements OnDestroy {
|
||||
];
|
||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||
readonly displayedPeer = computed(() => this.peer() ?? this.session.peers()[0] ?? 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()
|
||||
@@ -118,7 +153,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
readonly callChoicePeer = computed(() => {
|
||||
const peerId = this.callChoicePeerId();
|
||||
|
||||
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||
return peerId ? this.dropdownPeers().find((peer) => peer.id === peerId) ?? null : null;
|
||||
});
|
||||
readonly conversation = computed(() =>
|
||||
this.session
|
||||
@@ -240,13 +275,61 @@ export class ChatPageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const peerId = this.peerId();
|
||||
const currentUserId = this.currentUser()?.id ?? null;
|
||||
this.knownPeers.set(this.readKnownPeers(currentUserId));
|
||||
});
|
||||
|
||||
if (!peerId) {
|
||||
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();
|
||||
const previousSnapshot = this.lastAutoConnectSnapshot;
|
||||
|
||||
if (!peerId) {
|
||||
this.lastAutoConnectSnapshot = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastAutoConnectSnapshot = { peerId, hasLivePeer };
|
||||
this.session.selectPeer(peerId);
|
||||
|
||||
if (!hasLivePeer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousSnapshot?.peerId === peerId && previousSnapshot.hasLivePeer) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.session.connectToPeer(peerId);
|
||||
});
|
||||
|
||||
@@ -291,7 +374,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
async ensureConnection(): Promise<void> {
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
if (!peerId || !this.peer()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -593,6 +676,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
||||
event?.stopPropagation();
|
||||
await this.session.deleteConversation(peerId);
|
||||
this.removeKnownPeer(peerId);
|
||||
}
|
||||
|
||||
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
|
||||
@@ -619,7 +703,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
togglePeerDropdown(): void {
|
||||
if (this.session.peers().length === 0) {
|
||||
if (this.dropdownPeers().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -739,6 +823,10 @@ export class ChatPageComponent implements OnDestroy {
|
||||
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';
|
||||
@@ -752,7 +840,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
canReconnectWebRtc(): boolean {
|
||||
return !!this.peerId() && this.indicatorTone(this.webRtcState()) !== 'ok';
|
||||
return !!this.peerId() && !!this.peer() && this.indicatorTone(this.webRtcState()) !== 'ok';
|
||||
}
|
||||
|
||||
async switchPeer(peerId: string): Promise<void> {
|
||||
@@ -767,10 +855,159 @@ export class ChatPageComponent implements OnDestroy {
|
||||
this.conversationModalOpen.set(false);
|
||||
this.peerDropdownOpen.set(false);
|
||||
this.emojiPickerOpen.set(false);
|
||||
this.session.selectPeer(peerId);
|
||||
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;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChatEntry,
|
||||
ConnectionState,
|
||||
DataEnvelope,
|
||||
DeliveryState,
|
||||
PendingApprovalResponse,
|
||||
PendingApprovalUser,
|
||||
PeerSummary,
|
||||
@@ -59,6 +60,7 @@ type LegacyPersistedChatEntry = {
|
||||
kind: Exclude<ChatEntry['kind'], 'system'>;
|
||||
createdAt: number;
|
||||
authorLabel: string;
|
||||
deliveryState?: DeliveryState;
|
||||
generatedByAi?: boolean;
|
||||
text?: string;
|
||||
payload?: unknown;
|
||||
@@ -91,6 +93,7 @@ type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry
|
||||
|
||||
type PersistedChatEntryContent = {
|
||||
authorLabel: string;
|
||||
deliveryState?: DeliveryState;
|
||||
generatedByAi?: boolean;
|
||||
text?: string;
|
||||
payload?: unknown;
|
||||
@@ -125,6 +128,7 @@ export class ChatSessionService {
|
||||
private static readonly messageRetentionLimit = 256;
|
||||
private static readonly sessionKeepaliveMs = 5 * 60 * 1000;
|
||||
private static readonly signalingHeartbeatMs = 25 * 1000;
|
||||
private static readonly signalingHeartbeatTimeoutMs = 60 * 1000;
|
||||
private static readonly signalingReconnectBaseMs = 1000;
|
||||
private static readonly signalingReconnectMaxMs = 10 * 1000;
|
||||
private static readonly systemMessageLifetimeMs = 5000;
|
||||
@@ -179,6 +183,7 @@ export class ChatSessionService {
|
||||
private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
|
||||
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
|
||||
private readonly messageStoreOperations = new Map<string, Promise<void>>();
|
||||
private readonly pendingOutgoingFlushes = new Map<string, Promise<void>>();
|
||||
private readonly pendingImageGenerationRequests = new Map<
|
||||
string,
|
||||
{ peerId: string; prompt: string; waitMessageId: string }
|
||||
@@ -199,6 +204,8 @@ export class ChatSessionService {
|
||||
private websocketReconnectTimeoutId: number | null = null;
|
||||
private websocketReconnectAttempt = 0;
|
||||
private suppressSocketReconnect = false;
|
||||
private signalingRecoveryPromise: Promise<void> | null = null;
|
||||
private lastWebSocketPongAt = 0;
|
||||
private ringtoneAudio: HTMLAudioElement | null = null;
|
||||
private ringtoneAudioUrl: string = this.resolveIncomingCallRingtoneUrl();
|
||||
private ringtonePreloadPromise: Promise<void> | null = null;
|
||||
@@ -207,6 +214,8 @@ export class ChatSessionService {
|
||||
private websocket: WebSocket | null = null;
|
||||
|
||||
constructor(private readonly http: HttpClient) {
|
||||
this.installConnectionRecoveryListeners();
|
||||
|
||||
if (this.token() && this.currentUser()) {
|
||||
queueMicrotask(() => {
|
||||
void this.restoreSession();
|
||||
@@ -334,6 +343,11 @@ export class ChatSessionService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isPeerOnline(peerId)) {
|
||||
this.clearOutgoingTyping(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = rawText.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
@@ -556,13 +570,24 @@ export class ChatSessionService {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = this.requireOpenChannel(peerId);
|
||||
this.pushMessage({
|
||||
id: crypto.randomUUID(),
|
||||
peerId,
|
||||
direction: 'outgoing',
|
||||
kind: 'text',
|
||||
createdAt: Date.now(),
|
||||
authorLabel: 'You',
|
||||
deliveryState: 'pending',
|
||||
text: trimmed,
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
if (!this.canAttemptImmediatePeerDelivery(peerId)) {
|
||||
this.clearOutgoingTyping(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendTextEnvelope(peerId, channel, trimmed);
|
||||
this.sendTypingState(peerId, false);
|
||||
void this.flushPendingOutgoingMessages(peerId);
|
||||
}
|
||||
|
||||
async sendJson(peerId: string, rawPayload: string): Promise<void> {
|
||||
@@ -589,50 +614,27 @@ export class ChatSessionService {
|
||||
}
|
||||
|
||||
async sendFile(peerId: string, file: File, attachmentKind: 'file' | 'voice' = 'file'): Promise<void> {
|
||||
const channel = this.requireOpenChannel(peerId);
|
||||
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendTypingState(peerId, false);
|
||||
|
||||
const transferId = crypto.randomUUID();
|
||||
const sentAt = Date.now();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const chunkSize = 16 * 1024;
|
||||
|
||||
channel.send(JSON.stringify({
|
||||
type: 'file-meta',
|
||||
id: transferId,
|
||||
name: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
attachmentKind,
|
||||
authorId: this.currentUser()!.id,
|
||||
authorName: this.currentUser()!.displayName,
|
||||
sentAt,
|
||||
} satisfies DataEnvelope));
|
||||
|
||||
for (let offset = 0; offset < arrayBuffer.byteLength; offset += chunkSize) {
|
||||
await this.waitForBufferedAmount(channel, chunkSize * 2);
|
||||
channel.send(arrayBuffer.slice(offset, Math.min(offset + chunkSize, arrayBuffer.byteLength)));
|
||||
}
|
||||
|
||||
channel.send(JSON.stringify({ type: 'file-complete', id: transferId } satisfies DataEnvelope));
|
||||
|
||||
this.pushMessage({
|
||||
id: transferId,
|
||||
id: crypto.randomUUID(),
|
||||
peerId,
|
||||
direction: 'outgoing',
|
||||
kind: attachmentKind,
|
||||
createdAt: sentAt,
|
||||
createdAt: Date.now(),
|
||||
authorLabel: 'You',
|
||||
deliveryState: 'pending',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileMimeType: file.type || 'application/octet-stream',
|
||||
downloadUrl: URL.createObjectURL(file),
|
||||
}, file);
|
||||
|
||||
if (!this.canAttemptImmediatePeerDelivery(peerId)) {
|
||||
this.clearOutgoingTyping(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendTypingState(peerId, false);
|
||||
void this.flushPendingOutgoingMessages(peerId);
|
||||
}
|
||||
|
||||
async sendVoiceMessage(peerId: string, blob: Blob, mimeType?: string): Promise<void> {
|
||||
@@ -749,6 +751,126 @@ export class ChatSessionService {
|
||||
await this.connectWebSocket();
|
||||
}
|
||||
|
||||
private installConnectionRecoveryListeners(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
this.triggerSignalingRecovery(true);
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
this.triggerSignalingRecovery(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchCurrentSession(token: string): Promise<SessionResponse> {
|
||||
return await firstValueFrom(
|
||||
this.http.get<SessionResponse>(`${this.serverUrl()}/api/auth/session`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async hydrateSessionState(
|
||||
response: SessionResponse,
|
||||
options?: { loadPersistedMessages?: boolean; loadAccessKeys?: boolean },
|
||||
): Promise<void> {
|
||||
const currentUser = this.currentUser();
|
||||
const shouldLoadPersistedMessages = options?.loadPersistedMessages ?? false;
|
||||
const shouldLoadAccessKeys = options?.loadAccessKeys ?? false;
|
||||
const userChanged = currentUser?.id !== response.user.id;
|
||||
|
||||
this.currentUser.set(response.user);
|
||||
this.messageEncryptionKey = await this.importMessageEncryptionKey(response.messageEncryptionKey);
|
||||
this.writeStorage('privatechat.user', JSON.stringify(response.user));
|
||||
|
||||
if (shouldLoadPersistedMessages || userChanged) {
|
||||
await this.loadPersistedMessages(response.user.id);
|
||||
}
|
||||
|
||||
if (shouldLoadAccessKeys || userChanged) {
|
||||
await this.loadAccessKeys();
|
||||
}
|
||||
|
||||
this.startSessionKeepalive();
|
||||
}
|
||||
|
||||
private triggerSignalingRecovery(immediate = false): void {
|
||||
if (!this.token()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
this.clearWebSocketReconnect();
|
||||
void this.recoverSignalingConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleWebSocketReconnect();
|
||||
}
|
||||
|
||||
private isPeerOnline(peerId: string): boolean {
|
||||
return this.peers().some((peer) => peer.id === peerId);
|
||||
}
|
||||
|
||||
private canAttemptImmediatePeerDelivery(peerId: string): boolean {
|
||||
return !!peerId
|
||||
&& this.isPeerOnline(peerId)
|
||||
&& !!this.websocket
|
||||
&& this.websocket.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
private async recoverSignalingConnection(): Promise<void> {
|
||||
if (this.signalingRecoveryPromise) {
|
||||
return this.signalingRecoveryPromise;
|
||||
}
|
||||
|
||||
const recovery = this.recoverSignalingConnectionNow().finally(() => {
|
||||
if (this.signalingRecoveryPromise === recovery) {
|
||||
this.signalingRecoveryPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.signalingRecoveryPromise = recovery;
|
||||
return recovery;
|
||||
}
|
||||
|
||||
private async recoverSignalingConnectionNow(): Promise<void> {
|
||||
const token = this.token();
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const websocket = this.websocket;
|
||||
|
||||
if (websocket && (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set('Reconnecting to signaling server with your saved session.');
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const response = await this.fetchCurrentSession(token);
|
||||
await this.hydrateSessionState(response);
|
||||
await this.connectWebSocket();
|
||||
} catch (error) {
|
||||
if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) {
|
||||
this.clearLocalAuth('Session expired. Sign in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.signalingState.set('disconnected');
|
||||
this.status.set('Saved session found, retrying signaling server connection.');
|
||||
this.triggerSignalingRecovery(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sendTextEnvelope(peerId: string, channel: RTCDataChannel, text: string): void {
|
||||
const trimmed = text.trim();
|
||||
|
||||
@@ -774,6 +896,7 @@ export class ChatSessionService {
|
||||
kind: 'text',
|
||||
createdAt: envelope.sentAt,
|
||||
authorLabel: 'You',
|
||||
deliveryState: 'sent',
|
||||
text: trimmed,
|
||||
});
|
||||
}
|
||||
@@ -796,10 +919,158 @@ export class ChatSessionService {
|
||||
kind: 'json',
|
||||
createdAt: envelope.sentAt,
|
||||
authorLabel: 'You',
|
||||
deliveryState: 'sent',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async flushPendingOutgoingMessages(peerId: string): Promise<void> {
|
||||
const previous = this.pendingOutgoingFlushes.get(peerId) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => {
|
||||
// Keep the per-peer queue moving after a failed send attempt.
|
||||
})
|
||||
.then(() => this.flushPendingOutgoingMessagesNow(peerId));
|
||||
|
||||
this.pendingOutgoingFlushes.set(peerId, next);
|
||||
|
||||
await next.finally(() => {
|
||||
if (this.pendingOutgoingFlushes.get(peerId) === next) {
|
||||
this.pendingOutgoingFlushes.delete(peerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async flushPendingOutgoingMessagesNow(peerId: string): Promise<void> {
|
||||
if (!peerId || !this.currentUser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingEntries = this.messages()
|
||||
.filter((entry) =>
|
||||
entry.peerId === peerId
|
||||
&& entry.direction === 'outgoing'
|
||||
&& entry.kind !== 'system'
|
||||
&& entry.deliveryState === 'pending',
|
||||
)
|
||||
.sort((left, right) => left.createdAt - right.createdAt);
|
||||
|
||||
if (pendingEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.peers().some((peer) => peer.id === peerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel = this.peekOpenChannel(peerId);
|
||||
|
||||
if (!channel) {
|
||||
await this.connectToPeer(peerId, { announce: false });
|
||||
channel = await this.waitForOpenChannel(peerId, 8000, true);
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of pendingEntries) {
|
||||
const currentEntry = this.messages().find((message) => message.id === entry.id);
|
||||
|
||||
if (!currentEntry || currentEntry.deliveryState !== 'pending') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sent = await this.sendPendingOutgoingEntry(peerId, currentEntry, channel);
|
||||
|
||||
if (!sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.patchMessage(currentEntry.id, { deliveryState: 'sent' });
|
||||
channel = this.peekOpenChannel(peerId);
|
||||
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sendPendingOutgoingEntry(
|
||||
peerId: string,
|
||||
entry: ChatEntry,
|
||||
channel: RTCDataChannel,
|
||||
): Promise<boolean> {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUser || channel.readyState !== 'open') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (entry.kind) {
|
||||
case 'text':
|
||||
if (!entry.text?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
channel.send(JSON.stringify({
|
||||
type: 'text',
|
||||
id: entry.id,
|
||||
body: entry.text.trim(),
|
||||
authorId: currentUser.id,
|
||||
authorName: currentUser.displayName,
|
||||
sentAt: entry.createdAt,
|
||||
} satisfies DataEnvelope));
|
||||
return true;
|
||||
case 'file':
|
||||
case 'voice': {
|
||||
const fileBlob = await this.readBlobFromUrl(entry.downloadUrl);
|
||||
|
||||
if (!fileBlob) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arrayBuffer = await fileBlob.arrayBuffer();
|
||||
const chunkSize = 16 * 1024;
|
||||
|
||||
channel.send(JSON.stringify({
|
||||
type: 'file-meta',
|
||||
id: entry.id,
|
||||
name: entry.fileName || 'attachment',
|
||||
mimeType: entry.fileMimeType || fileBlob.type || 'application/octet-stream',
|
||||
size: entry.fileSize ?? fileBlob.size,
|
||||
attachmentKind: entry.kind === 'voice' ? 'voice' : 'file',
|
||||
authorId: currentUser.id,
|
||||
authorName: currentUser.displayName,
|
||||
sentAt: entry.createdAt,
|
||||
} satisfies DataEnvelope));
|
||||
|
||||
for (let offset = 0; offset < arrayBuffer.byteLength; offset += chunkSize) {
|
||||
await this.waitForBufferedAmount(channel, chunkSize * 2);
|
||||
|
||||
if (channel.readyState !== 'open') {
|
||||
return false;
|
||||
}
|
||||
|
||||
channel.send(arrayBuffer.slice(offset, Math.min(offset + chunkSize, arrayBuffer.byteLength)));
|
||||
}
|
||||
|
||||
channel.send(JSON.stringify({ type: 'file-complete', id: entry.id } satisfies DataEnvelope));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadPendingApprovalUsers(): Promise<PendingApprovalUser[]> {
|
||||
const token = this.token();
|
||||
|
||||
@@ -974,6 +1245,7 @@ export class ChatSessionService {
|
||||
}
|
||||
|
||||
this.websocketReconnectAttempt = 0;
|
||||
this.lastWebSocketPongAt = Date.now();
|
||||
this.startWebSocketHeartbeat(websocket);
|
||||
this.signalingState.set('connected');
|
||||
this.status.set('Connected to signaling server.');
|
||||
@@ -984,6 +1256,7 @@ export class ChatSessionService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastWebSocketPongAt = Date.now();
|
||||
const message = JSON.parse(event.data) as ServerEvent;
|
||||
void this.handleServerEvent(message);
|
||||
};
|
||||
@@ -1019,11 +1292,29 @@ export class ChatSessionService {
|
||||
}
|
||||
|
||||
if (shouldReconnect) {
|
||||
this.scheduleWebSocketReconnect();
|
||||
this.triggerSignalingRecovery(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private restartHungWebSocket(websocket: WebSocket): void {
|
||||
if (this.websocket !== websocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopWebSocketHeartbeat();
|
||||
this.status.set('Signaling connection stalled. Reconnecting with your saved session.');
|
||||
|
||||
try {
|
||||
websocket.close();
|
||||
} catch {
|
||||
if (this.websocket === websocket) {
|
||||
this.websocket = null;
|
||||
}
|
||||
this.triggerSignalingRecovery(false);
|
||||
}
|
||||
}
|
||||
|
||||
private disconnectWebSocket(): void {
|
||||
this.stopWebSocketHeartbeat();
|
||||
this.rejectPendingSpeechTranscriptions('Signaling connection closed during dictation.');
|
||||
@@ -1078,13 +1369,17 @@ export class ChatSessionService {
|
||||
break;
|
||||
case 'error':
|
||||
this.error.set(event.message);
|
||||
if (/auth|session/i.test(event.message)) {
|
||||
if (this.isSessionTerminationError(event.message)) {
|
||||
this.clearLocalAuth('Session expired. Sign in again.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private isSessionTerminationError(message: string): boolean {
|
||||
return /^(Authentication required\.|Session expired\.)$/i.test(message.trim());
|
||||
}
|
||||
|
||||
private handleGeneratedImage(event: Extract<ServerEvent, { type: 'image-generated' }>): void {
|
||||
const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId);
|
||||
|
||||
@@ -1163,21 +1458,17 @@ export class ChatSessionService {
|
||||
this.notice.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<SessionResponse>(`${this.serverUrl()}/api/auth/session`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
this.currentUser.set(response.user);
|
||||
this.messageEncryptionKey = await this.importMessageEncryptionKey(response.messageEncryptionKey);
|
||||
this.writeStorage('privatechat.user', JSON.stringify(response.user));
|
||||
await this.loadPersistedMessages(response.user.id);
|
||||
await this.loadAccessKeys();
|
||||
this.startSessionKeepalive();
|
||||
const response = await this.fetchCurrentSession(token);
|
||||
await this.hydrateSessionState(response, { loadPersistedMessages: true, loadAccessKeys: true });
|
||||
await this.connectWebSocket();
|
||||
} catch {
|
||||
this.clearLocalAuth('Saved session expired. Sign in again.');
|
||||
} catch (error) {
|
||||
if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) {
|
||||
this.clearLocalAuth('Saved session expired. Sign in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set('Saved session found, retrying signaling server connection.');
|
||||
this.triggerSignalingRecovery(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,15 +1502,15 @@ export class ChatSessionService {
|
||||
}
|
||||
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.http.get<SessionResponse>(`${this.serverUrl()}/api/auth/session`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
);
|
||||
const response = await this.fetchCurrentSession(token);
|
||||
await this.hydrateSessionState(response);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) {
|
||||
this.clearLocalAuth('Session expired. Sign in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerSignalingRecovery(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1236,7 +1527,16 @@ export class ChatSessionService {
|
||||
return;
|
||||
}
|
||||
|
||||
websocket.send(JSON.stringify({ type: 'ping' }));
|
||||
if (Date.now() - this.lastWebSocketPongAt > ChatSessionService.signalingHeartbeatTimeoutMs) {
|
||||
this.restartHungWebSocket(websocket);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
websocket.send(JSON.stringify({ type: 'ping' }));
|
||||
} catch {
|
||||
this.restartHungWebSocket(websocket);
|
||||
}
|
||||
}, ChatSessionService.signalingHeartbeatMs);
|
||||
}
|
||||
|
||||
@@ -1493,6 +1793,7 @@ export class ChatSessionService {
|
||||
if (bundle.announceConnectionEvents) {
|
||||
this.addSystemMessage(peerId, 'Secure data channel is open.');
|
||||
}
|
||||
void this.flushPendingOutgoingMessages(peerId);
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
@@ -1703,19 +2004,27 @@ export class ChatSessionService {
|
||||
this.websocket.send(JSON.stringify({ type: 'signal', to: peerId, signal }));
|
||||
}
|
||||
|
||||
private requireOpenChannel(peerId: string): RTCDataChannel | null {
|
||||
private peekOpenChannel(peerId: string): RTCDataChannel | null {
|
||||
const channel = this.peerBundles.get(peerId)?.channel;
|
||||
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
this.error.set('Open a peer connection before sending data.');
|
||||
return channel?.readyState === 'open' ? channel : null;
|
||||
}
|
||||
|
||||
private requireOpenChannel(peerId: string, suppressError = false): RTCDataChannel | null {
|
||||
const channel = this.peekOpenChannel(peerId);
|
||||
|
||||
if (!channel) {
|
||||
if (!suppressError) {
|
||||
this.error.set('Open a peer connection before sending data.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
private async ensureOpenChannel(peerId: string): Promise<RTCDataChannel | null> {
|
||||
const openChannel = this.requireOpenChannel(peerId);
|
||||
private async ensureOpenChannel(peerId: string, suppressError = false): Promise<RTCDataChannel | null> {
|
||||
const openChannel = this.requireOpenChannel(peerId, suppressError);
|
||||
|
||||
if (openChannel) {
|
||||
return openChannel;
|
||||
@@ -1723,7 +2032,7 @@ export class ChatSessionService {
|
||||
|
||||
await this.connectToPeer(peerId);
|
||||
|
||||
return this.waitForOpenChannel(peerId);
|
||||
return this.waitForOpenChannel(peerId, 8000, suppressError);
|
||||
}
|
||||
|
||||
private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise<void> {
|
||||
@@ -1732,20 +2041,23 @@ export class ChatSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForOpenChannel(peerId: string, timeoutMs = 8000): Promise<RTCDataChannel | null> {
|
||||
private async waitForOpenChannel(peerId: string, timeoutMs = 8000, suppressError = false): Promise<RTCDataChannel | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const channel = this.peerBundles.get(peerId)?.channel;
|
||||
const channel = this.peekOpenChannel(peerId);
|
||||
|
||||
if (channel?.readyState === 'open') {
|
||||
if (channel) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
this.error.set('Could not open a peer channel for forwarding.');
|
||||
if (!suppressError) {
|
||||
this.error.set('Could not open a peer channel for forwarding.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1764,6 +2076,19 @@ export class ChatSessionService {
|
||||
.some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId);
|
||||
}
|
||||
|
||||
private async readBlobFromUrl(url?: string): Promise<Blob | null> {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureLocalCallStream(peerId: string, mode: CallMode): Promise<PeerBundle | null> {
|
||||
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
|
||||
this.error.set(mode === 'video'
|
||||
@@ -1967,6 +2292,24 @@ export class ChatSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private patchMessage(messageId: string, patch: Partial<ChatEntry>): void {
|
||||
this.messages.update((messages) =>
|
||||
messages.map((entry) => {
|
||||
if (entry.id !== messageId) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return { ...entry, ...patch };
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedEntry = this.messages().find((entry) => entry.id === messageId);
|
||||
|
||||
if (updatedEntry && updatedEntry.kind !== 'system') {
|
||||
void this.persistUpdatedMessage(updatedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(entry: ChatEntry): Promise<void> {
|
||||
this.removeMessageById(entry.id);
|
||||
|
||||
@@ -2104,6 +2447,8 @@ export class ChatSessionService {
|
||||
|
||||
private clearLocalAuth(statusMessage: string): void {
|
||||
this.clearWebSocketReconnect();
|
||||
this.signalingRecoveryPromise = null;
|
||||
this.lastWebSocketPongAt = 0;
|
||||
this.disconnectWebSocket();
|
||||
this.resetPeerConnections();
|
||||
this.stopSessionKeepalive();
|
||||
@@ -2112,6 +2457,7 @@ export class ChatSessionService {
|
||||
this.stopRingtone();
|
||||
this.releasePreloadedRingtone();
|
||||
this.pendingImageGenerationRequests.clear();
|
||||
this.pendingOutgoingFlushes.clear();
|
||||
this.rejectPendingSpeechTranscriptions('Session ended during dictation.');
|
||||
this.incomingCallModes.set([]);
|
||||
this.outgoingCallModes.set([]);
|
||||
@@ -2204,6 +2550,16 @@ export class ChatSessionService {
|
||||
if (encryptedRowsToMigrate.length > 0) {
|
||||
await Promise.all(encryptedRowsToMigrate.map((row) => this.migrateEncryptedPersistedMessage(row)));
|
||||
}
|
||||
|
||||
const pendingPeerIds = Array.from(new Set(
|
||||
nextMessages
|
||||
.filter((entry) => entry.direction === 'outgoing' && entry.deliveryState === 'pending')
|
||||
.map((entry) => entry.peerId),
|
||||
));
|
||||
|
||||
for (const peerId of pendingPeerIds) {
|
||||
void this.flushPendingOutgoingMessages(peerId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not restore persisted chat messages.', error);
|
||||
}
|
||||
@@ -2222,6 +2578,7 @@ export class ChatSessionService {
|
||||
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
|
||||
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
|
||||
authorLabel: entry.authorLabel,
|
||||
deliveryState: entry.deliveryState,
|
||||
generatedByAi: entry.generatedByAi,
|
||||
text: entry.text,
|
||||
payload: entry.payload,
|
||||
@@ -2290,6 +2647,13 @@ export class ChatSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async persistUpdatedMessage(entry: ChatEntry): Promise<void> {
|
||||
const fileBlob = await this.readBlobFromUrl(entry.downloadUrl);
|
||||
const previewBlob = await this.readBlobFromUrl(entry.previewDownloadUrl);
|
||||
|
||||
await this.persistMessage(entry, fileBlob ?? undefined, previewBlob ?? undefined);
|
||||
}
|
||||
|
||||
private async hydratePersistedMessage(
|
||||
entry: PersistedChatEntry,
|
||||
messageEncryptionKey: CryptoKey,
|
||||
@@ -2334,6 +2698,7 @@ export class ChatSessionService {
|
||||
kind: entry.kind,
|
||||
createdAt: entry.createdAt,
|
||||
authorLabel: content.authorLabel,
|
||||
deliveryState: content.deliveryState,
|
||||
generatedByAi: content.generatedByAi,
|
||||
text: content.text,
|
||||
payload: content.payload,
|
||||
@@ -2358,6 +2723,7 @@ export class ChatSessionService {
|
||||
kind: entry.kind,
|
||||
createdAt: entry.createdAt,
|
||||
authorLabel: entry.authorLabel,
|
||||
deliveryState: entry.deliveryState,
|
||||
generatedByAi: entry.generatedByAi,
|
||||
text: entry.text,
|
||||
payload: entry.payload,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface AccessKeySummary {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type DeliveryState = 'pending' | 'sent';
|
||||
|
||||
export interface RegistrationOptionsResponse {
|
||||
rp: PublicKeyCredentialRpEntity;
|
||||
user: {
|
||||
@@ -97,6 +99,7 @@ export interface ChatEntry {
|
||||
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||
createdAt: number;
|
||||
authorLabel: string;
|
||||
deliveryState?: DeliveryState;
|
||||
generatedByAi?: boolean;
|
||||
showSpinner?: boolean;
|
||||
text?: string;
|
||||
|
||||
Reference in New Issue
Block a user