offline users and messages

This commit is contained in:
2026-03-25 20:09:36 +01:00
parent fd888c9ed1
commit f13c04e809
10 changed files with 792 additions and 154 deletions

View File

@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../Speech2Text"
},
{
"path": "."
}
],
"settings": {}
}

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ server/server/data/privatechat.sqlite-wal
server/server/data/master.key server/server/data/master.key
client/dist/* client/dist/*
client/apple-client/WebApp/** client/apple-client/WebApp/**
server/data/master.key

View File

@@ -46,7 +46,7 @@
<header class="conversation-modal-header"> <header class="conversation-modal-header">
<div> <div>
<p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p> <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> </div>
<button <button
class="conversation-modal-close" class="conversation-modal-close"
@@ -113,21 +113,21 @@
@if (peerDropdownOpen()) { @if (peerDropdownOpen()) {
<div class="peer-dropdown-menu" role="listbox"> <div class="peer-dropdown-menu" role="listbox">
@for (connectedPeer of session.peers(); track connectedPeer.id) { @for (dropdownPeer of dropdownPeers(); track dropdownPeer.id) {
<article <article
class="peer-tile" class="peer-tile"
[class.peer-tile-active]="connectedPeer.id === peerId()" [class.peer-tile-active]="dropdownPeer.id === peerId()"
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)" [class.peer-tile-unread]="isPeerUnread(dropdownPeer.id)"
> >
<button <button
class="peer-tile-main text-start" class="peer-tile-main text-start"
type="button" type="button"
(click)="selectPeerFromDropdown(connectedPeer.id)" (click)="selectPeerFromDropdown(dropdownPeer.id)"
> >
<div class="peer-tile-row"> <div class="peer-tile-row">
<span class="peer-tile-title"> <span class="peer-tile-title">
<span class="fw-semibold">{{ connectedPeer.displayName }}</span> <span class="fw-semibold">{{ dropdownPeer.displayName }}</span>
@if (isPeerTyping(connectedPeer.id)) { @if (isPeerTyping(dropdownPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing"> <span class="peer-typing-dots" aria-label="Typing">
<span></span> <span></span>
<span></span> <span></span>
@@ -137,10 +137,10 @@
</span> </span>
<span <span
class="status-led peer-tile-status" class="status-led peer-tile-status"
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'" [class.status-led-ok]="dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'"
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'" [class.status-led-offline]="dropdownPeer.channelState !== 'open' && dropdownPeer.connectionState !== 'connected'"
[attr.aria-label]=" [attr.aria-label]="
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected' dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'
? 'Connected' ? 'Connected'
: 'Disconnected' : 'Disconnected'
" "
@@ -152,7 +152,7 @@
type="button" type="button"
title="Delete conversation" title="Delete conversation"
aria-label="Delete conversation" aria-label="Delete conversation"
(click)="deleteConversation(connectedPeer.id, $event)" (click)="deleteConversation(dropdownPeer.id, $event)"
> >
🗑️ 🗑️
</button> </button>
@@ -215,18 +215,19 @@
(click)="trackComposerSelection(composerTextarea)" (click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)" (keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)" (select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()" [disabled]="!peerId()"
placeholder="Write a text message to your peer" placeholder="Write a text message to your peer, even if they are offline"
></textarea> ></textarea>
<div class="composer-toolbar"> <div class="composer-toolbar">
<div class="composer-actions"> <div class="composer-actions">
@if (peer(); as selectedPeer) { @if (peerId(); as selectedPeerId) {
@if (peer(); as livePeer) {
<button <button
class="composer-call" class="composer-call"
type="button" type="button"
[disabled]="!canStartSelectedVoiceCall()" [disabled]="!canStartSelectedVoiceCall()"
(click)="openCallChoice(selectedPeer.id)" (click)="openCallChoice(livePeer.id)"
title="Start call" title="Start call"
aria-label="Start call" aria-label="Start call"
> >
@@ -237,7 +238,7 @@
<button <button
class="composer-hangup" class="composer-hangup"
type="button" type="button"
(click)="endVoiceCall(selectedPeer.id)" (click)="endVoiceCall(livePeer.id)"
title="End call" title="End call"
aria-label="End call" aria-label="End call"
> >
@@ -248,7 +249,7 @@
<button <button
class="composer-voice" class="composer-voice"
type="button" type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()" [disabled]="livePeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()" (click)="toggleVoiceRecording()"
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'" [title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'" [attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
@@ -280,18 +281,19 @@
> >
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }} {{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
</button> </button>
}
<input <input
#fileInput #fileInput
class="composer-file-input" class="composer-file-input"
type="file" type="file"
[disabled]="selectedPeer.channelState !== 'open'" [disabled]="!selectedPeerId"
(change)="sendFile(selectedPeer.id, fileInput)" (change)="sendFile(selectedPeerId, fileInput)"
/> />
<button <button
class="composer-plus" class="composer-plus"
type="button" type="button"
[disabled]="selectedPeer.channelState !== 'open'" [disabled]="!selectedPeerId"
(click)="fileInput.click()" (click)="fileInput.click()"
title="Send file" title="Send file"
aria-label="Send file" aria-label="Send file"
@@ -330,7 +332,7 @@
<button <button
class="composer-emoji-trigger" class="composer-emoji-trigger"
type="button" type="button"
[disabled]="!session.isSelectedPeerReady()" [disabled]="!peerId()"
(click)="toggleEmojiPicker($event)" (click)="toggleEmojiPicker($event)"
title="Insert emoji" title="Insert emoji"
aria-label="Insert emoji" aria-label="Insert emoji"
@@ -342,7 +344,7 @@
<button <button
class="send-emoji" class="send-emoji"
type="button" type="button"
[disabled]="!session.isSelectedPeerReady()" [disabled]="!peerId()"
(click)="sendMessage()" (click)="sendMessage()"
title="Send message" title="Send message"
aria-label="Send message" aria-label="Send message"
@@ -368,7 +370,7 @@
<ng-template #conversationBubbles> <ng-template #conversationBubbles>
@if (conversation().length === 0) { @if (conversation().length === 0) {
<div class="empty-chat"> <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> </div>
} }
@@ -377,6 +379,7 @@
class="bubble" class="bubble"
[class.bubble-incoming]="entry.direction === 'incoming'" [class.bubble-incoming]="entry.direction === 'incoming'"
[class.bubble-outgoing]="entry.direction === 'outgoing'" [class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-pending]="isPendingOutgoingEntry(entry)"
[class.bubble-system]="entry.direction === 'system'" [class.bubble-system]="entry.direction === 'system'"
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)" [class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
> >
@@ -427,6 +430,9 @@
<div class="bubble-meta"> <div class="bubble-meta">
<span class="bubble-author">{{ entry.authorLabel }}</span> <span class="bubble-author">{{ entry.authorLabel }}</span>
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time> <time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
@if (isPendingOutgoingEntry(entry)) {
<span class="bubble-delivery-state">Queued</span>
}
</div> </div>
} }

View File

@@ -467,6 +467,11 @@
background: var(--outgoing-bubble-background); background: var(--outgoing-bubble-background);
} }
.bubble-pending {
opacity: 0.58;
filter: grayscale(0.28);
}
.bubble-system { .bubble-system {
justify-self: center; justify-self: center;
max-width: 90%; max-width: 90%;
@@ -494,6 +499,14 @@
display: block; 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 { .emoji-only-text {
font-size: clamp(2.1rem, 5vw, 3.4rem); font-size: clamp(2.1rem, 5vw, 3.4rem);
line-height: 1.15; line-height: 1.15;

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; 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 { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; 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 { JsonFileViewerComponent } from './json-file-viewer.component';
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models'; import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
type KnownPeerSummary = {
id: string;
displayName: string;
};
type DropdownPeerSummary = PeerSummary & { knownOnly: boolean };
@Component({ @Component({
selector: 'app-chat-page', selector: 'app-chat-page',
imports: [ imports: [
@@ -22,6 +29,7 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
styleUrl: './chat-page.component.scss', styleUrl: './chat-page.component.scss',
}) })
export class ChatPageComponent implements OnDestroy { export class ChatPageComponent implements OnDestroy {
private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers';
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null; : null;
@@ -47,6 +55,7 @@ export class ChatPageComponent implements OnDestroy {
private resolveDictationCompletion: (() => void) | null = null; private resolveDictationCompletion: (() => void) | null = null;
private dictationApplyToken = 0; private dictationApplyToken = 0;
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null; private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
private lastAutoConnectSnapshot: { peerId: string; hasLivePeer: boolean } | null = null;
@ViewChild('callAudioElement') @ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) { set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value; this.callAudioElement = value;
@@ -73,6 +82,7 @@ export class ChatPageComponent implements OnDestroy {
readonly isRecordingVoice = signal(false); readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false); readonly isDictating = signal(false);
readonly isTranscribingDictation = signal(false); readonly isTranscribingDictation = signal(false);
readonly knownPeers = signal<KnownPeerSummary[]>([]);
readonly emojiOptions = [ readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗', '😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
@@ -102,7 +112,32 @@ export class ChatPageComponent implements OnDestroy {
]; ];
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); 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 currentUser = computed(() => this.session.currentUser());
readonly callModalPeerId = computed(() => readonly callModalPeerId = computed(() =>
this.session.activeVoiceCallPeerId() this.session.activeVoiceCallPeerId()
@@ -118,7 +153,7 @@ export class ChatPageComponent implements OnDestroy {
readonly callChoicePeer = computed(() => { readonly callChoicePeer = computed(() => {
const peerId = this.callChoicePeerId(); 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(() => readonly conversation = computed(() =>
this.session this.session
@@ -240,13 +275,61 @@ export class ChatPageComponent implements OnDestroy {
} }
effect(() => { 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; 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); this.session.selectPeer(peerId);
if (!hasLivePeer) {
return;
}
if (previousSnapshot?.peerId === peerId && previousSnapshot.hasLivePeer) {
return;
}
void this.session.connectToPeer(peerId); void this.session.connectToPeer(peerId);
}); });
@@ -291,7 +374,7 @@ export class ChatPageComponent implements OnDestroy {
async ensureConnection(): Promise<void> { async ensureConnection(): Promise<void> {
const peerId = this.peerId(); const peerId = this.peerId();
if (!peerId) { if (!peerId || !this.peer()) {
return; return;
} }
@@ -593,6 +676,7 @@ export class ChatPageComponent implements OnDestroy {
async deleteConversation(peerId: string, event?: Event): Promise<void> { async deleteConversation(peerId: string, event?: Event): Promise<void> {
event?.stopPropagation(); event?.stopPropagation();
await this.session.deleteConversation(peerId); await this.session.deleteConversation(peerId);
this.removeKnownPeer(peerId);
} }
toggleForwardMenu(entry: ChatEntry, event?: Event): void { toggleForwardMenu(entry: ChatEntry, event?: Event): void {
@@ -619,7 +703,7 @@ export class ChatPageComponent implements OnDestroy {
} }
togglePeerDropdown(): void { togglePeerDropdown(): void {
if (this.session.peers().length === 0) { if (this.dropdownPeers().length === 0) {
return; return;
} }
@@ -739,6 +823,10 @@ export class ChatPageComponent implements OnDestroy {
return this.session.unreadPeerIds().includes(peerId); return this.session.unreadPeerIds().includes(peerId);
} }
isPendingOutgoingEntry(entry: ChatEntry): boolean {
return entry.direction === 'outgoing' && entry.deliveryState === 'pending';
}
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' { indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') { if (state === 'connected') {
return 'ok'; return 'ok';
@@ -752,7 +840,7 @@ export class ChatPageComponent implements OnDestroy {
} }
canReconnectWebRtc(): boolean { 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> { async switchPeer(peerId: string): Promise<void> {
@@ -767,10 +855,159 @@ export class ChatPageComponent implements OnDestroy {
this.conversationModalOpen.set(false); this.conversationModalOpen.set(false);
this.peerDropdownOpen.set(false); this.peerDropdownOpen.set(false);
this.emojiPickerOpen.set(false); this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', 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 { private stopVoiceRecording(discard: boolean): void {
const recorder = this.voiceRecorder; const recorder = this.voiceRecorder;

View File

@@ -11,6 +11,7 @@ import {
ChatEntry, ChatEntry,
ConnectionState, ConnectionState,
DataEnvelope, DataEnvelope,
DeliveryState,
PendingApprovalResponse, PendingApprovalResponse,
PendingApprovalUser, PendingApprovalUser,
PeerSummary, PeerSummary,
@@ -59,6 +60,7 @@ type LegacyPersistedChatEntry = {
kind: Exclude<ChatEntry['kind'], 'system'>; kind: Exclude<ChatEntry['kind'], 'system'>;
createdAt: number; createdAt: number;
authorLabel: string; authorLabel: string;
deliveryState?: DeliveryState;
generatedByAi?: boolean; generatedByAi?: boolean;
text?: string; text?: string;
payload?: unknown; payload?: unknown;
@@ -91,6 +93,7 @@ type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry
type PersistedChatEntryContent = { type PersistedChatEntryContent = {
authorLabel: string; authorLabel: string;
deliveryState?: DeliveryState;
generatedByAi?: boolean; generatedByAi?: boolean;
text?: string; text?: string;
payload?: unknown; payload?: unknown;
@@ -125,6 +128,7 @@ export class ChatSessionService {
private static readonly messageRetentionLimit = 256; private static readonly messageRetentionLimit = 256;
private static readonly sessionKeepaliveMs = 5 * 60 * 1000; private static readonly sessionKeepaliveMs = 5 * 60 * 1000;
private static readonly signalingHeartbeatMs = 25 * 1000; private static readonly signalingHeartbeatMs = 25 * 1000;
private static readonly signalingHeartbeatTimeoutMs = 60 * 1000;
private static readonly signalingReconnectBaseMs = 1000; private static readonly signalingReconnectBaseMs = 1000;
private static readonly signalingReconnectMaxMs = 10 * 1000; private static readonly signalingReconnectMaxMs = 10 * 1000;
private static readonly systemMessageLifetimeMs = 5000; private static readonly systemMessageLifetimeMs = 5000;
@@ -179,6 +183,7 @@ export class ChatSessionService {
private readonly outgoingTypingIdleTimeouts = new Map<string, number>(); private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>(); private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
private readonly messageStoreOperations = new Map<string, Promise<void>>(); private readonly messageStoreOperations = new Map<string, Promise<void>>();
private readonly pendingOutgoingFlushes = new Map<string, Promise<void>>();
private readonly pendingImageGenerationRequests = new Map< private readonly pendingImageGenerationRequests = new Map<
string, string,
{ peerId: string; prompt: string; waitMessageId: string } { peerId: string; prompt: string; waitMessageId: string }
@@ -199,6 +204,8 @@ export class ChatSessionService {
private websocketReconnectTimeoutId: number | null = null; private websocketReconnectTimeoutId: number | null = null;
private websocketReconnectAttempt = 0; private websocketReconnectAttempt = 0;
private suppressSocketReconnect = false; private suppressSocketReconnect = false;
private signalingRecoveryPromise: Promise<void> | null = null;
private lastWebSocketPongAt = 0;
private ringtoneAudio: HTMLAudioElement | null = null; private ringtoneAudio: HTMLAudioElement | null = null;
private ringtoneAudioUrl: string = this.resolveIncomingCallRingtoneUrl(); private ringtoneAudioUrl: string = this.resolveIncomingCallRingtoneUrl();
private ringtonePreloadPromise: Promise<void> | null = null; private ringtonePreloadPromise: Promise<void> | null = null;
@@ -207,6 +214,8 @@ export class ChatSessionService {
private websocket: WebSocket | null = null; private websocket: WebSocket | null = null;
constructor(private readonly http: HttpClient) { constructor(private readonly http: HttpClient) {
this.installConnectionRecoveryListeners();
if (this.token() && this.currentUser()) { if (this.token() && this.currentUser()) {
queueMicrotask(() => { queueMicrotask(() => {
void this.restoreSession(); void this.restoreSession();
@@ -334,6 +343,11 @@ export class ChatSessionService {
return; return;
} }
if (!this.isPeerOnline(peerId)) {
this.clearOutgoingTyping(peerId);
return;
}
const trimmed = rawText.trim(); const trimmed = rawText.trim();
if (!trimmed) { if (!trimmed) {
@@ -556,13 +570,24 @@ export class ChatSessionService {
return; 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; return;
} }
this.sendTextEnvelope(peerId, channel, trimmed); this.sendTypingState(peerId, false);
void this.flushPendingOutgoingMessages(peerId);
} }
async sendJson(peerId: string, rawPayload: string): Promise<void> { 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> { 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({ this.pushMessage({
id: transferId, id: crypto.randomUUID(),
peerId, peerId,
direction: 'outgoing', direction: 'outgoing',
kind: attachmentKind, kind: attachmentKind,
createdAt: sentAt, createdAt: Date.now(),
authorLabel: 'You', authorLabel: 'You',
deliveryState: 'pending',
fileName: file.name, fileName: file.name,
fileSize: file.size, fileSize: file.size,
fileMimeType: file.type || 'application/octet-stream', fileMimeType: file.type || 'application/octet-stream',
downloadUrl: URL.createObjectURL(file), downloadUrl: URL.createObjectURL(file),
}, 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> { async sendVoiceMessage(peerId: string, blob: Blob, mimeType?: string): Promise<void> {
@@ -749,6 +751,126 @@ export class ChatSessionService {
await this.connectWebSocket(); 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 { private sendTextEnvelope(peerId: string, channel: RTCDataChannel, text: string): void {
const trimmed = text.trim(); const trimmed = text.trim();
@@ -774,6 +896,7 @@ export class ChatSessionService {
kind: 'text', kind: 'text',
createdAt: envelope.sentAt, createdAt: envelope.sentAt,
authorLabel: 'You', authorLabel: 'You',
deliveryState: 'sent',
text: trimmed, text: trimmed,
}); });
} }
@@ -796,10 +919,158 @@ export class ChatSessionService {
kind: 'json', kind: 'json',
createdAt: envelope.sentAt, createdAt: envelope.sentAt,
authorLabel: 'You', authorLabel: 'You',
deliveryState: 'sent',
payload, 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[]> { async loadPendingApprovalUsers(): Promise<PendingApprovalUser[]> {
const token = this.token(); const token = this.token();
@@ -974,6 +1245,7 @@ export class ChatSessionService {
} }
this.websocketReconnectAttempt = 0; this.websocketReconnectAttempt = 0;
this.lastWebSocketPongAt = Date.now();
this.startWebSocketHeartbeat(websocket); this.startWebSocketHeartbeat(websocket);
this.signalingState.set('connected'); this.signalingState.set('connected');
this.status.set('Connected to signaling server.'); this.status.set('Connected to signaling server.');
@@ -984,6 +1256,7 @@ export class ChatSessionService {
return; return;
} }
this.lastWebSocketPongAt = Date.now();
const message = JSON.parse(event.data) as ServerEvent; const message = JSON.parse(event.data) as ServerEvent;
void this.handleServerEvent(message); void this.handleServerEvent(message);
}; };
@@ -1019,11 +1292,29 @@ export class ChatSessionService {
} }
if (shouldReconnect) { 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 { private disconnectWebSocket(): void {
this.stopWebSocketHeartbeat(); this.stopWebSocketHeartbeat();
this.rejectPendingSpeechTranscriptions('Signaling connection closed during dictation.'); this.rejectPendingSpeechTranscriptions('Signaling connection closed during dictation.');
@@ -1078,13 +1369,17 @@ export class ChatSessionService {
break; break;
case 'error': case 'error':
this.error.set(event.message); this.error.set(event.message);
if (/auth|session/i.test(event.message)) { if (this.isSessionTerminationError(event.message)) {
this.clearLocalAuth('Session expired. Sign in again.'); this.clearLocalAuth('Session expired. Sign in again.');
} }
break; break;
} }
} }
private isSessionTerminationError(message: string): boolean {
return /^(Authentication required\.|Session expired\.)$/i.test(message.trim());
}
private handleGeneratedImage(event: Extract<ServerEvent, { type: 'image-generated' }>): void { private handleGeneratedImage(event: Extract<ServerEvent, { type: 'image-generated' }>): void {
const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId); const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId);
@@ -1163,21 +1458,17 @@ export class ChatSessionService {
this.notice.set(null); this.notice.set(null);
try { try {
const response = await firstValueFrom( const response = await this.fetchCurrentSession(token);
this.http.get<SessionResponse>(`${this.serverUrl()}/api/auth/session`, { await this.hydrateSessionState(response, { loadPersistedMessages: true, loadAccessKeys: true });
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();
await this.connectWebSocket(); await this.connectWebSocket();
} catch { } catch (error) {
if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) {
this.clearLocalAuth('Saved session expired. Sign in again.'); 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 { try {
await firstValueFrom( const response = await this.fetchCurrentSession(token);
this.http.get<SessionResponse>(`${this.serverUrl()}/api/auth/session`, { await this.hydrateSessionState(response);
headers: { Authorization: `Bearer ${token}` },
}),
);
} catch (error) { } catch (error) {
if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) { if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) {
this.clearLocalAuth('Session expired. Sign in again.'); this.clearLocalAuth('Session expired. Sign in again.');
return;
} }
this.triggerSignalingRecovery(false);
} }
} }
@@ -1236,7 +1527,16 @@ export class ChatSessionService {
return; return;
} }
if (Date.now() - this.lastWebSocketPongAt > ChatSessionService.signalingHeartbeatTimeoutMs) {
this.restartHungWebSocket(websocket);
return;
}
try {
websocket.send(JSON.stringify({ type: 'ping' })); websocket.send(JSON.stringify({ type: 'ping' }));
} catch {
this.restartHungWebSocket(websocket);
}
}, ChatSessionService.signalingHeartbeatMs); }, ChatSessionService.signalingHeartbeatMs);
} }
@@ -1493,6 +1793,7 @@ export class ChatSessionService {
if (bundle.announceConnectionEvents) { if (bundle.announceConnectionEvents) {
this.addSystemMessage(peerId, 'Secure data channel is open.'); this.addSystemMessage(peerId, 'Secure data channel is open.');
} }
void this.flushPendingOutgoingMessages(peerId);
}; };
channel.onclose = () => { channel.onclose = () => {
@@ -1703,19 +2004,27 @@ export class ChatSessionService {
this.websocket.send(JSON.stringify({ type: 'signal', to: peerId, signal })); 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; const channel = this.peerBundles.get(peerId)?.channel;
if (!channel || channel.readyState !== 'open') { 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.'); this.error.set('Open a peer connection before sending data.');
}
return null; return null;
} }
return channel; return channel;
} }
private async ensureOpenChannel(peerId: string): Promise<RTCDataChannel | null> { private async ensureOpenChannel(peerId: string, suppressError = false): Promise<RTCDataChannel | null> {
const openChannel = this.requireOpenChannel(peerId); const openChannel = this.requireOpenChannel(peerId, suppressError);
if (openChannel) { if (openChannel) {
return openChannel; return openChannel;
@@ -1723,7 +2032,7 @@ export class ChatSessionService {
await this.connectToPeer(peerId); await this.connectToPeer(peerId);
return this.waitForOpenChannel(peerId); return this.waitForOpenChannel(peerId, 8000, suppressError);
} }
private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise<void> { 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; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const channel = this.peerBundles.get(peerId)?.channel; const channel = this.peekOpenChannel(peerId);
if (channel?.readyState === 'open') { if (channel) {
return channel; return channel;
} }
await new Promise((resolve) => window.setTimeout(resolve, 100)); await new Promise((resolve) => window.setTimeout(resolve, 100));
} }
if (!suppressError) {
this.error.set('Could not open a peer channel for forwarding.'); this.error.set('Could not open a peer channel for forwarding.');
}
return null; return null;
} }
@@ -1764,6 +2076,19 @@ export class ChatSessionService {
.some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId); .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> { private async ensureLocalCallStream(peerId: string, mode: CallMode): Promise<PeerBundle | null> {
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') { if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.error.set(mode === 'video' 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> { async deleteMessage(entry: ChatEntry): Promise<void> {
this.removeMessageById(entry.id); this.removeMessageById(entry.id);
@@ -2104,6 +2447,8 @@ export class ChatSessionService {
private clearLocalAuth(statusMessage: string): void { private clearLocalAuth(statusMessage: string): void {
this.clearWebSocketReconnect(); this.clearWebSocketReconnect();
this.signalingRecoveryPromise = null;
this.lastWebSocketPongAt = 0;
this.disconnectWebSocket(); this.disconnectWebSocket();
this.resetPeerConnections(); this.resetPeerConnections();
this.stopSessionKeepalive(); this.stopSessionKeepalive();
@@ -2112,6 +2457,7 @@ export class ChatSessionService {
this.stopRingtone(); this.stopRingtone();
this.releasePreloadedRingtone(); this.releasePreloadedRingtone();
this.pendingImageGenerationRequests.clear(); this.pendingImageGenerationRequests.clear();
this.pendingOutgoingFlushes.clear();
this.rejectPendingSpeechTranscriptions('Session ended during dictation.'); this.rejectPendingSpeechTranscriptions('Session ended during dictation.');
this.incomingCallModes.set([]); this.incomingCallModes.set([]);
this.outgoingCallModes.set([]); this.outgoingCallModes.set([]);
@@ -2204,6 +2550,16 @@ export class ChatSessionService {
if (encryptedRowsToMigrate.length > 0) { if (encryptedRowsToMigrate.length > 0) {
await Promise.all(encryptedRowsToMigrate.map((row) => this.migrateEncryptedPersistedMessage(row))); 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) { } catch (error) {
console.warn('Could not restore persisted chat messages.', 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 storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, { const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
authorLabel: entry.authorLabel, authorLabel: entry.authorLabel,
deliveryState: entry.deliveryState,
generatedByAi: entry.generatedByAi, generatedByAi: entry.generatedByAi,
text: entry.text, text: entry.text,
payload: entry.payload, 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( private async hydratePersistedMessage(
entry: PersistedChatEntry, entry: PersistedChatEntry,
messageEncryptionKey: CryptoKey, messageEncryptionKey: CryptoKey,
@@ -2334,6 +2698,7 @@ export class ChatSessionService {
kind: entry.kind, kind: entry.kind,
createdAt: entry.createdAt, createdAt: entry.createdAt,
authorLabel: content.authorLabel, authorLabel: content.authorLabel,
deliveryState: content.deliveryState,
generatedByAi: content.generatedByAi, generatedByAi: content.generatedByAi,
text: content.text, text: content.text,
payload: content.payload, payload: content.payload,
@@ -2358,6 +2723,7 @@ export class ChatSessionService {
kind: entry.kind, kind: entry.kind,
createdAt: entry.createdAt, createdAt: entry.createdAt,
authorLabel: entry.authorLabel, authorLabel: entry.authorLabel,
deliveryState: entry.deliveryState,
generatedByAi: entry.generatedByAi, generatedByAi: entry.generatedByAi,
text: entry.text, text: entry.text,
payload: entry.payload, payload: entry.payload,

View File

@@ -55,6 +55,8 @@ export interface AccessKeySummary {
createdAt: string; createdAt: string;
} }
export type DeliveryState = 'pending' | 'sent';
export interface RegistrationOptionsResponse { export interface RegistrationOptionsResponse {
rp: PublicKeyCredentialRpEntity; rp: PublicKeyCredentialRpEntity;
user: { user: {
@@ -97,6 +99,7 @@ export interface ChatEntry {
kind: 'text' | 'json' | 'file' | 'voice' | 'system'; kind: 'text' | 'json' | 'file' | 'voice' | 'system';
createdAt: number; createdAt: number;
authorLabel: string; authorLabel: string;
deliveryState?: DeliveryState;
generatedByAi?: boolean; generatedByAi?: boolean;
showSpinner?: boolean; showSpinner?: boolean;
text?: string; text?: string;

View File

@@ -7,7 +7,8 @@
"dev:server": "npm run dev --prefix server", "dev:server": "npm run dev --prefix server",
"dev:client": "npm run start --prefix client", "dev:client": "npm run start --prefix client",
"build": "npm run build --prefix server && npm run build --prefix client", "build": "npm run build --prefix server && npm run build --prefix client",
"start": "npm run build && npm run start --prefix server" "start": "npm run build && npm run start --prefix server",
"restart": "npm run build && sudo systemctl restart privatechat.service"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1" "concurrently": "^9.2.1"

View File

@@ -106,7 +106,7 @@ const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, ''); const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest'; const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024'; const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080'; const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'wss://whisper.dubertrand.fr';
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto'; const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000); const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12); const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);

View File

@@ -340,7 +340,7 @@ const frontendDistPath = resolveProjectPath(
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, ''); const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest'; const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024'; const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080'; const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'wss://whisper.dubertrand.fr';
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto'; const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000); const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12); const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);