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
client/dist/*
client/apple-client/WebApp/**
server/data/master.key

View File

@@ -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>
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -7,7 +7,8 @@
"dev:server": "npm run dev --prefix server",
"dev:client": "npm run start --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": {
"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 ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
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 speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
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 ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
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 speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);