import { HttpClient } from '@angular/common/http'; import { computed, Injectable, signal } from '@angular/core'; import type { HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { AccessKeySummary, AuthenticationOptionsResponse, AuthResponse, ChatEntry, ConnectionState, DataEnvelope, PendingApprovalResponse, PendingApprovalUser, PeerSummary, RegistrationOptionsResponse, SessionResponse, ServerEvent, SignalPayload, UserProfile, } from './models'; type PeerBundle = { pc: RTCPeerConnection; channel?: RTCDataChannel; pendingCandidates: RTCIceCandidateInit[]; }; type IncomingFileTransfer = { id: string; name: string; mimeType: string; size: number; sentAt: number; authorName: string; chunks: ArrayBuffer[]; receivedBytes: number; }; type PersistedBinary = string | ArrayBuffer; type LegacyPersistedChatEntry = { storageKey: string; ownerUserId: string; conversationKey: string; id: string; peerId: string; direction: ChatEntry['direction']; kind: Exclude; createdAt: number; authorLabel: string; text?: string; payload?: unknown; fileName?: string; fileSize?: number; fileMimeType?: string; fileBlob?: Blob; }; type EncryptedPersistedChatEntry = { storageKey: string; ownerUserId: string; conversationKey: string; id: string; peerId: string; direction: ChatEntry['direction']; kind: Exclude; createdAt: number; encryptedPayload: PersistedBinary; payloadIv: number[]; encryptedFileBlob?: PersistedBinary; fileIv?: number[]; }; type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry; type PersistedChatEntryContent = { authorLabel: string; text?: string; payload?: unknown; fileName?: string; fileSize?: number; fileMimeType?: string; }; type RuntimeEnv = { PRIVATECHAT_CLIENT_SERVER_URL?: string; }; function readDefaultServerUrl(): string { const runtimeWindow = typeof window === 'undefined' ? undefined : (window as Window & typeof globalThis & { __PRIVATECHAT_ENV__?: RuntimeEnv }); const configuredUrl = runtimeWindow?.__PRIVATECHAT_ENV__?.PRIVATECHAT_CLIENT_SERVER_URL?.trim(); return configuredUrl?.replace(/\/+$/, '') || 'http://localhost:3000'; } @Injectable({ providedIn: 'root' }) export class ChatSessionService { private static readonly messageDatabaseName = 'privatechat'; private static readonly messageStoreName = 'conversation_messages'; private static readonly messageRetentionLimit = 256; private static readonly systemMessageLifetimeMs = 5000; private static readonly typingIndicatorLifetimeMs = 1800; private static readonly typingIdleMs = 1200; private static readonly typingHeartbeatMs = 900; readonly serverUrl = signal(this.readStorage('privatechat.serverUrl') ?? readDefaultServerUrl()); readonly currentUser = signal(this.readUserStorage()); readonly accessKeys = signal([]); readonly peers = signal([]); readonly activePeerId = signal(null); readonly messages = signal([]); readonly unreadPeerIds = signal([]); readonly typingPeerIds = signal([]); readonly signalingState = signal('disconnected'); readonly status = signal('Disconnected from signaling server.'); readonly error = signal(null); readonly notice = signal(null); readonly webAuthnSupported = signal( typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined' && typeof navigator !== 'undefined' && typeof navigator.credentials?.create === 'function' && typeof navigator.credentials?.get === 'function', ); readonly selectedPeer = computed(() => this.peers().find((peer) => peer.id === this.activePeerId()) ?? null); readonly conversation = computed(() => { const peerId = this.activePeerId(); if (!peerId) { return []; } return this.messages().filter((entry) => entry.peerId === peerId); }); readonly isSelectedPeerReady = computed(() => this.selectedPeer()?.channelState === 'open'); readonly isApprovalAdmin = computed(() => this.currentUser()?.username === 'ladparis'); private readonly token = signal(this.readStorage('privatechat.token')); private readonly peerBundles = new Map(); private readonly incomingFiles = new Map(); private readonly systemMessageTimeouts = new Map(); private readonly typingIndicatorTimeouts = new Map(); private readonly outgoingTypingIdleTimeouts = new Map(); private readonly outgoingTypingStates = new Map(); private readonly messageStoreOperations = new Map>(); private messageEncryptionKey: CryptoKey | null = null; private messageDatabasePromise: Promise | null = null; private websocket: WebSocket | null = null; constructor(private readonly http: HttpClient) { if (this.token() && this.currentUser()) { queueMicrotask(() => { void this.restoreSession(); }); } } async register(username: string, password: string, displayName: string): Promise { this.error.set(null); this.notice.set(null); try { const response = await firstValueFrom( this.http.post(`${this.serverUrl()}/api/auth/register`, { username, password, displayName: displayName.trim() || undefined, }), ); if ('token' in response) { await this.applyAuthenticatedSession(response); return true; } this.notice.set(response.message); this.status.set(response.message); return false; } catch (error) { this.error.set( this.extractErrorMessage(error, 'Registration failed. Check the backend URL and your credentials.'), ); return false; } } async login(username: string, password: string): Promise { await this.authenticate('/api/auth/login', { username, password }); } async loginWithAccessKey(username: string): Promise { if (!this.webAuthnSupported()) { this.error.set('This browser does not support WebAuthn access keys.'); return; } this.error.set(null); this.notice.set(null); try { const options = await firstValueFrom( this.http.post( `${this.serverUrl()}/api/webauthn/authenticate/options`, { username: username.trim() || undefined }, ), ); const credential = await navigator.credentials.get({ publicKey: this.toPublicKeyRequestOptions(options), }); if (!(credential instanceof PublicKeyCredential)) { this.error.set('The browser did not return a valid access key credential.'); return; } const response = await firstValueFrom( this.http.post( `${this.serverUrl()}/api/webauthn/authenticate/verify`, { attemptId: options.attemptId, credential: this.serializeAuthenticationCredential(credential), }, ), ); await this.applyAuthenticatedSession(response); } catch (error) { this.error.set(this.extractErrorMessage(error, 'Access key login failed or was cancelled.')); } } async logout(): Promise { const token = this.token(); try { if (token) { await firstValueFrom( this.http.post( `${this.serverUrl()}/api/auth/logout`, {}, { headers: { Authorization: `Bearer ${token}` } }, ), ); } } catch { // Local cleanup still needs to happen even if the backend is unavailable. } finally { this.clearLocalAuth('Logged out.'); } } setServerUrl(url: string): void { const normalized = url.trim().replace(/\/+$/, ''); if (!normalized) { return; } this.serverUrl.set(normalized); this.writeStorage('privatechat.serverUrl', normalized); if (this.currentUser()) { void this.connectWebSocket(); } } selectPeer(peerId: string): void { this.activePeerId.set(peerId); this.clearUnreadPeer(peerId); } notifyTypingActivity(peerId: string, rawText: string): void { if (!peerId) { return; } const trimmed = rawText.trim(); if (!trimmed) { this.sendTypingState(peerId, false); return; } const now = Date.now(); const currentState = this.outgoingTypingStates.get(peerId); if (!currentState?.active || now - currentState.lastSentAt >= ChatSessionService.typingHeartbeatMs) { this.sendTypingState(peerId, true, true); } const existingTimeoutId = this.outgoingTypingIdleTimeouts.get(peerId); if (typeof existingTimeoutId !== 'undefined') { window.clearTimeout(existingTimeoutId); } const timeoutId = window.setTimeout(() => { this.sendTypingState(peerId, false); }, ChatSessionService.typingIdleMs); this.outgoingTypingIdleTimeouts.set(peerId, timeoutId); } async connectToPeer(peerId: string): Promise { if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { this.error.set('You must be connected to signaling before opening a peer session.'); return; } const bundle = this.ensurePeerBundle(peerId, true); if (bundle.channel?.readyState === 'open') { return; } if (bundle.pc.signalingState !== 'stable') { return; } this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' }); this.addSystemMessage(peerId, 'Opening WebRTC data channel.'); const offer = await bundle.pc.createOffer(); await bundle.pc.setLocalDescription(offer); this.sendSignal(peerId, { type: 'sdp', description: bundle.pc.localDescription!.toJSON(), }); } async registerAccessKey(label: string): Promise { if (!this.webAuthnSupported()) { this.error.set('This browser does not support WebAuthn access keys.'); return; } const token = this.token(); if (!token) { this.error.set('Sign in before registering an access key.'); return; } this.error.set(null); this.notice.set(null); try { const options = await firstValueFrom( this.http.post( `${this.serverUrl()}/api/webauthn/register/options`, { label: label.trim() || undefined }, { headers: { Authorization: `Bearer ${token}` } }, ), ); const credential = await navigator.credentials.create({ publicKey: this.toPublicKeyCreationOptions(options), }); if (!(credential instanceof PublicKeyCredential)) { this.error.set('The browser did not return a valid access key credential.'); return; } await firstValueFrom( this.http.post( `${this.serverUrl()}/api/webauthn/register/verify`, { credential: this.serializeRegistrationCredential(credential) }, { headers: { Authorization: `Bearer ${token}` } }, ), ); await this.loadAccessKeys(); this.status.set('Access key registered.'); } catch (error) { this.error.set(this.extractErrorMessage(error, 'Access key registration failed or was cancelled.')); } } async sendText(peerId: string, text: string): Promise { const trimmed = text.trim(); if (!trimmed) { return; } const channel = this.requireOpenChannel(peerId); if (!channel) { return; } const envelope: DataEnvelope = { type: 'text', id: crypto.randomUUID(), body: trimmed, authorId: this.currentUser()!.id, authorName: this.currentUser()!.displayName, sentAt: Date.now(), }; channel.send(JSON.stringify(envelope)); this.sendTypingState(peerId, false); this.pushMessage({ id: envelope.id, peerId, direction: 'outgoing', kind: 'text', createdAt: envelope.sentAt, authorLabel: 'You', text: trimmed, }); } async sendJson(peerId: string, rawPayload: string): Promise { if (!rawPayload.trim()) { return; } const channel = this.requireOpenChannel(peerId); if (!channel) { return; } let parsedPayload: unknown; try { parsedPayload = JSON.parse(rawPayload); } catch { this.error.set('JSON payload is not valid.'); return; } const envelope: DataEnvelope = { type: 'json', id: crypto.randomUUID(), body: parsedPayload, authorId: this.currentUser()!.id, authorName: this.currentUser()!.displayName, sentAt: Date.now(), }; channel.send(JSON.stringify(envelope)); this.pushMessage({ id: envelope.id, peerId, direction: 'outgoing', kind: 'json', createdAt: envelope.sentAt, authorLabel: 'You', payload: parsedPayload, }); } async sendFile(peerId: string, file: File): Promise { 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, 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, peerId, direction: 'outgoing', kind: 'file', createdAt: sentAt, authorLabel: 'You', fileName: file.name, fileSize: file.size, fileMimeType: file.type || 'application/octet-stream', downloadUrl: URL.createObjectURL(file), }, file); } private async authenticate(path: string, payload: Record): Promise { this.error.set(null); this.notice.set(null); try { const response = await firstValueFrom( this.http.post(`${this.serverUrl()}${path}`, payload), ); await this.applyAuthenticatedSession(response); } catch (error) { this.error.set( this.extractErrorMessage(error, 'Authentication failed. Check the backend URL and your credentials.'), ); } } private async applyAuthenticatedSession(response: AuthResponse): Promise { this.token.set(response.token); this.currentUser.set(response.user); this.messageEncryptionKey = await this.importMessageEncryptionKey(response.messageEncryptionKey); this.writeStorage('privatechat.token', response.token); this.writeStorage('privatechat.user', JSON.stringify(response.user)); this.notice.set(null); this.status.set(`Authenticated as ${response.user.displayName}.`); await this.loadPersistedMessages(response.user.id); await this.loadAccessKeys(); await this.connectWebSocket(); } async loadPendingApprovalUsers(): Promise { const token = this.token(); if (!token) { throw new Error('Authentication required.'); } const response = await firstValueFrom( this.http.get<{ users: PendingApprovalUser[] }>(`${this.serverUrl()}/api/admin/pending-users`, { headers: { Authorization: `Bearer ${token}` }, }), ); return response.users; } async approvePendingUser(userId: string): Promise { const token = this.token(); if (!token) { throw new Error('Authentication required.'); } await firstValueFrom( this.http.post( `${this.serverUrl()}/api/admin/pending-users/${encodeURIComponent(userId)}/approve`, {}, { headers: { Authorization: `Bearer ${token}` } }, ), ); } private async loadAccessKeys(): Promise { const token = this.token(); if (!token) { this.accessKeys.set([]); return; } try { const response = await firstValueFrom( this.http.get<{ credentials: AccessKeySummary[] }>(`${this.serverUrl()}/api/webauthn/credentials`, { headers: { Authorization: `Bearer ${token}` }, }), ); this.accessKeys.set(response.credentials); } catch { this.error.set('Could not load registered access keys.'); } } private async connectWebSocket(): Promise { const token = this.token(); if (!token) { return; } this.disconnectWebSocket(); this.resetPeerConnections(); this.error.set(null); this.signalingState.set('connecting'); this.status.set('Connecting to signaling server.'); const websocket = new WebSocket(this.toWebSocketUrl(this.serverUrl(), token)); this.websocket = websocket; websocket.onopen = () => { this.signalingState.set('connected'); this.status.set('Connected to signaling server.'); }; websocket.onmessage = (event) => { const message = JSON.parse(event.data) as ServerEvent; void this.handleServerEvent(message); }; websocket.onerror = () => { this.signalingState.set('failed'); this.error.set('The signaling socket encountered an error.'); }; websocket.onclose = () => { this.signalingState.set('disconnected'); this.status.set('Signaling connection closed.'); this.websocket = null; this.peers.update((peers) => peers.map((peer) => ({ ...peer, connectionState: 'disconnected', channelState: 'closed' })), ); }; } private disconnectWebSocket(): void { if (this.websocket) { this.websocket.close(); this.websocket = null; } } private async handleServerEvent(event: ServerEvent): Promise { switch (event.type) { case 'presence': this.mergePresence(event.peers); break; case 'peer-joined': this.mergePresence([ ...this.peers().map((peer) => peer), { ...event.peer, connectionState: 'disconnected', channelState: 'closed' }, ]); break; case 'peer-left': this.releasePeerBundle(event.peerId, false); this.peers.update((peers) => peers.filter((peer) => peer.id !== event.peerId)); this.clearUnreadPeer(event.peerId); this.clearPeerTyping(event.peerId); if (this.activePeerId() === event.peerId) { this.activePeerId.set(this.peers()[0]?.id ?? null); } this.addSystemMessage(event.peerId, 'Peer disconnected from signaling.'); break; case 'signal': await this.handleSignal(event.from, event.signal); break; case 'error': this.error.set(event.message); if (/auth|session/i.test(event.message)) { this.clearLocalAuth('Session expired. Sign in again.'); } break; } } private async restoreSession(): Promise { const token = this.token(); if (!token) { return; } this.status.set('Restoring saved session.'); this.error.set(null); this.notice.set(null); try { const response = await firstValueFrom( this.http.get(`${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(); await this.connectWebSocket(); } catch { this.clearLocalAuth('Saved session expired. Sign in again.'); } } private mergePresence(peers: Array): void { const previous = new Map(this.peers().map((peer) => [peer.id, peer])); const nextPeers = peers .map((peer) => { const existing = previous.get(peer.id); return { id: peer.id, username: peer.username, displayName: peer.displayName, connectionState: existing?.connectionState ?? 'disconnected', channelState: existing?.channelState ?? 'closed', } satisfies PeerSummary; }) .filter((peer, index, values) => values.findIndex((candidate) => candidate.id === peer.id) === index) .sort((left, right) => left.displayName.localeCompare(right.displayName)); this.peers.set(nextPeers); this.unreadPeerIds.update((peerIds) => peerIds.filter((peerId) => nextPeers.some((peer) => peer.id === peerId))); this.typingPeerIds.update((peerIds) => peerIds.filter((peerId) => nextPeers.some((peer) => peer.id === peerId))); if (!this.activePeerId() && nextPeers.length > 0) { this.activePeerId.set(nextPeers[0].id); } } private async handleSignal(peerId: string, signal: SignalPayload): Promise { if (signal.type === 'ice-candidate') { const bundle = this.ensurePeerBundle(peerId, false); if (bundle.pc.remoteDescription) { await bundle.pc.addIceCandidate(signal.candidate); } else { bundle.pendingCandidates.push(signal.candidate); } return; } const bundle = this.ensurePeerBundle(peerId, false); const description = signal.description; if (description.type === 'offer') { const offerCollision = bundle.pc.signalingState !== 'stable'; const polite = this.isPolitePeer(peerId); if (offerCollision) { if (!polite) { return; } await bundle.pc.setLocalDescription({ type: 'rollback' }); } await bundle.pc.setRemoteDescription(description); await this.flushPendingCandidates(bundle); const answer = await bundle.pc.createAnswer(); await bundle.pc.setLocalDescription(answer); this.sendSignal(peerId, { type: 'sdp', description: bundle.pc.localDescription!.toJSON(), }); return; } await bundle.pc.setRemoteDescription(description); await this.flushPendingCandidates(bundle); } private ensurePeerBundle(peerId: string, initiator: boolean): PeerBundle { const existing = this.peerBundles.get(peerId); if (existing && existing.pc.connectionState !== 'closed' && existing.pc.connectionState !== 'failed') { if (initiator && !existing.channel) { const channel = existing.pc.createDataChannel('privatechat'); this.attachDataChannel(peerId, channel, existing); } return existing; } this.releasePeerBundle(peerId, false); const bundle: PeerBundle = { pc: new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }), pendingCandidates: [], }; bundle.pc.onicecandidate = (event) => { if (event.candidate) { this.sendSignal(peerId, { type: 'ice-candidate', candidate: event.candidate.toJSON(), }); } }; bundle.pc.onconnectionstatechange = () => { const state = this.mapConnectionState(bundle.pc.connectionState); this.patchPeer(peerId, { connectionState: state }); if (state === 'connected') { this.addSystemMessage(peerId, 'Peer connection established.'); } if (bundle.pc.connectionState === 'closed' || bundle.pc.connectionState === 'failed') { this.releasePeerBundle(peerId, true); } }; bundle.pc.ondatachannel = (event) => { this.attachDataChannel(peerId, event.channel, bundle); }; if (initiator) { const channel = bundle.pc.createDataChannel('privatechat'); this.attachDataChannel(peerId, channel, bundle); } this.peerBundles.set(peerId, bundle); this.patchPeer(peerId, { connectionState: 'connecting' }); return bundle; } private attachDataChannel(peerId: string, channel: RTCDataChannel, bundle: PeerBundle): void { channel.binaryType = 'arraybuffer'; bundle.channel = channel; this.patchPeer(peerId, { channelState: channel.readyState === 'open' ? 'open' : 'connecting' }); channel.onopen = () => { this.patchPeer(peerId, { connectionState: 'connected', channelState: 'open' }); this.addSystemMessage(peerId, 'Secure data channel is open.'); }; channel.onclose = () => { this.patchPeer(peerId, { channelState: 'closed' }); }; channel.onerror = () => { this.patchPeer(peerId, { channelState: 'closed', connectionState: 'failed' }); this.error.set('A peer data channel failed.'); }; channel.onmessage = (event) => { if (typeof event.data === 'string') { this.handleChannelEnvelope(peerId, JSON.parse(event.data) as DataEnvelope); return; } void this.handleBinaryChunk(peerId, event.data); }; } private handleChannelEnvelope(peerId: string, envelope: DataEnvelope): void { switch (envelope.type) { case 'text': this.pushMessage({ id: envelope.id, peerId, direction: 'incoming', kind: 'text', createdAt: envelope.sentAt, authorLabel: envelope.authorName, text: envelope.body, }); break; case 'json': this.pushMessage({ id: envelope.id, peerId, direction: 'incoming', kind: 'json', createdAt: envelope.sentAt, authorLabel: envelope.authorName, payload: envelope.body, }); break; case 'file-meta': this.incomingFiles.set(peerId, { id: envelope.id, name: envelope.name, mimeType: envelope.mimeType, size: envelope.size, sentAt: envelope.sentAt, authorName: envelope.authorName, chunks: [], receivedBytes: 0, }); this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`); break; case 'file-complete': this.finalizeIncomingFile(peerId, envelope.id); break; case 'typing': this.setPeerTyping(peerId, envelope.active); break; } } private async handleBinaryChunk(peerId: string, chunk: Blob | ArrayBuffer): Promise { const transfer = this.incomingFiles.get(peerId); if (!transfer) { return; } const arrayBuffer = chunk instanceof Blob ? await chunk.arrayBuffer() : chunk; transfer.chunks.push(arrayBuffer); transfer.receivedBytes += arrayBuffer.byteLength; } private finalizeIncomingFile(peerId: string, transferId: string): void { const transfer = this.incomingFiles.get(peerId); if (!transfer || transfer.id !== transferId) { return; } const blob = new Blob(transfer.chunks, { type: transfer.mimeType }); const downloadUrl = URL.createObjectURL(blob); this.pushMessage({ id: transfer.id, peerId, direction: 'incoming', kind: 'file', createdAt: transfer.sentAt, authorLabel: transfer.authorName, fileName: transfer.name, fileSize: transfer.size, fileMimeType: transfer.mimeType, downloadUrl, }, blob); this.incomingFiles.delete(peerId); } private async flushPendingCandidates(bundle: PeerBundle): Promise { while (bundle.pendingCandidates.length > 0) { const candidate = bundle.pendingCandidates.shift(); if (candidate) { await bundle.pc.addIceCandidate(candidate); } } } private sendSignal(peerId: string, signal: SignalPayload): void { if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { return; } this.websocket.send(JSON.stringify({ type: 'signal', to: peerId, signal })); } private requireOpenChannel(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 null; } return channel; } private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise { while (channel.bufferedAmount > threshold) { await new Promise((resolve) => window.setTimeout(resolve, 25)); } } private releasePeerBundle(peerId: string, preservePeerState: boolean): void { const bundle = this.peerBundles.get(peerId); this.clearPeerTyping(peerId); this.clearOutgoingTyping(peerId); if (!bundle) { return; } bundle.channel?.close(); bundle.pc.close(); this.peerBundles.delete(peerId); this.incomingFiles.delete(peerId); if (preservePeerState) { this.patchPeer(peerId, { connectionState: 'disconnected', channelState: 'closed' }); } } private resetPeerConnections(): void { for (const peerId of this.peerBundles.keys()) { this.releasePeerBundle(peerId, true); } } private patchPeer( peerId: string, patch: Partial>, ): void { this.peers.update((peers) => peers.map((peer) => (peer.id === peerId ? { ...peer, ...patch } : peer)), ); } private pushMessage(entry: ChatEntry, fileBlob?: Blob): void { this.messages.update((messages) => [...messages, entry].sort((left, right) => left.createdAt - right.createdAt)); if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) { this.markPeerUnread(entry.peerId); } if (entry.kind !== 'system') { void this.persistMessage(entry, fileBlob); } } async deleteMessage(entry: ChatEntry): Promise { this.removeMessageById(entry.id); if (entry.kind === 'system') { return; } const currentUserId = this.currentUser()?.id; if (!currentUserId) { return; } try { const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id); await this.queueMessageStoreOperation(storageKey, async () => { const database = await this.openMessageDatabase(); if (!database) { return; } const transaction = database.transaction(ChatSessionService.messageStoreName, 'readwrite'); const store = transaction.objectStore(ChatSessionService.messageStoreName); store.delete(storageKey); await this.waitForTransaction(transaction); }); } catch (error) { console.warn('Could not delete chat message.', error); } } private addSystemMessage(peerId: string, text: string): void { const id = crypto.randomUUID(); this.pushMessage({ id, peerId, direction: 'system', kind: 'system', createdAt: Date.now(), authorLabel: 'System', text, }); const timeoutId = window.setTimeout(() => { this.removeMessageById(id); }, ChatSessionService.systemMessageLifetimeMs); this.systemMessageTimeouts.set(id, timeoutId); } private isPolitePeer(peerId: string): boolean { return (this.currentUser()?.id ?? '') > peerId; } private mapConnectionState(state: RTCPeerConnectionState): ConnectionState { switch (state) { case 'connected': return 'connected'; case 'connecting': case 'new': return 'connecting'; case 'failed': return 'failed'; default: return 'disconnected'; } } private clearLocalAuth(statusMessage: string): void { this.disconnectWebSocket(); this.resetPeerConnections(); this.clearSystemMessageTimeouts(); this.clearTypingTimeouts(); this.messageEncryptionKey = null; this.revokeMessageDownloads(this.messages()); this.currentUser.set(null); this.token.set(null); this.peers.set([]); this.accessKeys.set([]); this.activePeerId.set(null); this.unreadPeerIds.set([]); this.typingPeerIds.set([]); this.messages.set([]); this.signalingState.set('disconnected'); this.error.set(null); this.notice.set(null); this.status.set(statusMessage); this.removeStorage('privatechat.token'); this.removeStorage('privatechat.user'); } private async loadPersistedMessages(userId: string): Promise { const messageEncryptionKey = this.messageEncryptionKey; if (!messageEncryptionKey) { this.revokeMessageDownloads(this.messages()); this.messages.set([]); return; } try { const database = await this.openMessageDatabase(); if (!database) { this.revokeMessageDownloads(this.messages()); this.messages.set([]); return; } const transaction = database.transaction(ChatSessionService.messageStoreName, 'readonly'); const store = transaction.objectStore(ChatSessionService.messageStoreName); const ownerIndex = store.index('ownerUserId'); const rows = await this.waitForRequest( ownerIndex.getAll(IDBKeyRange.only(userId)), ) as PersistedChatEntry[]; await this.waitForTransaction(transaction); const nextMessages = ( await Promise.all(rows.map((row) => this.hydratePersistedMessage(row, messageEncryptionKey))) ) .filter((entry): entry is ChatEntry => entry !== null) .sort((left, right) => left.createdAt - right.createdAt); this.revokeMessageDownloads(this.messages()); this.messages.set(nextMessages); const legacyRows = rows.filter((row): row is LegacyPersistedChatEntry => this.isLegacyPersistedChatEntry(row)); const encryptedRows = rows.filter( (row): row is EncryptedPersistedChatEntry => !this.isLegacyPersistedChatEntry(row), ); if (legacyRows.length > 0) { await Promise.all(legacyRows.map((row) => this.migrateLegacyPersistedMessage(row))); } const encryptedRowsToMigrate = encryptedRows.filter((row) => this.hasLegacyEncryptedBinaryStorage(row)); if (encryptedRowsToMigrate.length > 0) { await Promise.all(encryptedRowsToMigrate.map((row) => this.migrateEncryptedPersistedMessage(row))); } } catch (error) { console.warn('Could not restore persisted chat messages.', error); } } private async persistMessage(entry: ChatEntry, fileBlob?: Blob): Promise { const currentUserId = this.currentUser()?.id; const messageEncryptionKey = this.messageEncryptionKey; if (!currentUserId || !messageEncryptionKey || entry.kind === 'system') { return; } try { const conversationKey = this.conversationStorageKey(currentUserId, entry.peerId); const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id); const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, { authorLabel: entry.authorLabel, text: entry.text, payload: entry.payload, fileName: entry.fileName, fileSize: entry.fileSize, fileMimeType: entry.fileMimeType, }); const encryptedFileBlob = fileBlob ? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer()) : null; const persistedEntry: EncryptedPersistedChatEntry = { storageKey, ownerUserId: currentUserId, conversationKey, id: entry.id, peerId: entry.peerId, direction: entry.direction, kind: entry.kind, createdAt: entry.createdAt, encryptedPayload: this.serializePersistedBinary(encryptedPayload.ciphertext), payloadIv: Array.from(encryptedPayload.iv), encryptedFileBlob: encryptedFileBlob ? this.serializePersistedBinary(encryptedFileBlob.ciphertext) : undefined, fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : undefined, }; await this.queueMessageStoreOperation(storageKey, async () => { const database = await this.openMessageDatabase(); if (!database) { return; } const transaction = database.transaction(ChatSessionService.messageStoreName, 'readwrite'); const store = transaction.objectStore(ChatSessionService.messageStoreName); store.put(persistedEntry); const conversationIndex = store.index('conversationKeyCreatedAt'); const rows = await this.waitForRequest( conversationIndex.getAll( IDBKeyRange.bound([conversationKey, 0], [conversationKey, Number.MAX_SAFE_INTEGER]), ), ) as PersistedChatEntry[]; const overflow = rows.length - ChatSessionService.messageRetentionLimit; if (overflow > 0) { for (const staleEntry of rows.slice(0, overflow)) { store.delete(staleEntry.storageKey); } } await this.waitForTransaction(transaction); }); } catch (error) { console.warn('Could not persist chat message.', error); } } private async hydratePersistedMessage( entry: PersistedChatEntry, messageEncryptionKey: CryptoKey, ): Promise { if (this.isLegacyPersistedChatEntry(entry)) { return this.hydrateLegacyPersistedMessage(entry); } try { const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry); let downloadUrl: string | undefined; if (entry.encryptedFileBlob && entry.fileIv) { const decryptedFile = await this.decryptBinary( messageEncryptionKey, this.deserializePersistedBinary(entry.encryptedFileBlob), Uint8Array.from(entry.fileIv).buffer, ); const fileBlob = new Blob([decryptedFile], { type: content.fileMimeType || 'application/octet-stream', }); downloadUrl = URL.createObjectURL(fileBlob); } return { id: entry.id, peerId: entry.peerId, direction: entry.direction, kind: entry.kind, createdAt: entry.createdAt, authorLabel: content.authorLabel, text: content.text, payload: content.payload, fileName: content.fileName, fileSize: content.fileSize, fileMimeType: content.fileMimeType, downloadUrl, }; } catch (error) { console.warn('Could not decrypt persisted chat message.', error); return null; } } private hydrateLegacyPersistedMessage(entry: LegacyPersistedChatEntry): ChatEntry { return { id: entry.id, peerId: entry.peerId, direction: entry.direction, kind: entry.kind, createdAt: entry.createdAt, authorLabel: entry.authorLabel, text: entry.text, payload: entry.payload, fileName: entry.fileName, fileSize: entry.fileSize, fileMimeType: entry.fileMimeType, downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined, }; } private async migrateLegacyPersistedMessage(entry: LegacyPersistedChatEntry): Promise { const hydratedEntry = this.hydrateLegacyPersistedMessage(entry); const fileBlob = entry.fileBlob ? new Blob([await entry.fileBlob.arrayBuffer()], { type: entry.fileMimeType || 'application/octet-stream', }) : undefined; await this.persistMessage(hydratedEntry, fileBlob); } private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise { const migratedEntry: EncryptedPersistedChatEntry = { ...entry, encryptedPayload: this.serializePersistedBinary(this.deserializePersistedBinary(entry.encryptedPayload)), encryptedFileBlob: entry.encryptedFileBlob ? this.serializePersistedBinary(this.deserializePersistedBinary(entry.encryptedFileBlob)) : undefined, }; await this.queueMessageStoreOperation(entry.storageKey, async () => { const database = await this.openMessageDatabase(); if (!database) { return; } const transaction = database.transaction(ChatSessionService.messageStoreName, 'readwrite'); const store = transaction.objectStore(ChatSessionService.messageStoreName); store.put(migratedEntry); await this.waitForTransaction(transaction); }); } private revokeMessageDownloads(entries: ChatEntry[]): void { for (const entry of entries) { if (entry.downloadUrl?.startsWith('blob:')) { URL.revokeObjectURL(entry.downloadUrl); } } } private conversationStorageKey(currentUserId: string, peerId: string): string { return `${currentUserId}:${peerId}`; } private messageStorageKey(currentUserId: string, peerId: string, messageId: string): string { return `${this.conversationStorageKey(currentUserId, peerId)}:${messageId}`; } private queueMessageStoreOperation(storageKey: string, operation: () => Promise): Promise { const previous = this.messageStoreOperations.get(storageKey) ?? Promise.resolve(); const next = previous .catch(() => { // Keep the queue moving after a failed operation for the same message. }) .then(operation); this.messageStoreOperations.set(storageKey, next); return next.finally(() => { if (this.messageStoreOperations.get(storageKey) === next) { this.messageStoreOperations.delete(storageKey); } }); } private async importMessageEncryptionKey(rawKey: string): Promise { if (!globalThis.crypto?.subtle) { throw new Error('This browser does not support message encryption.'); } return crypto.subtle.importKey( 'raw', this.base64UrlToBuffer(rawKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'], ); } private isLegacyPersistedChatEntry(entry: PersistedChatEntry): entry is LegacyPersistedChatEntry { return !('encryptedPayload' in entry); } private async encryptPersistedMessageContent( messageEncryptionKey: CryptoKey, content: PersistedChatEntryContent, ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> { const encoded = new TextEncoder().encode(JSON.stringify(content)); return this.encryptBinary(messageEncryptionKey, encoded.buffer); } private async decryptPersistedMessageContent( messageEncryptionKey: CryptoKey, entry: EncryptedPersistedChatEntry, ): Promise { const decrypted = await this.decryptBinary( messageEncryptionKey, this.deserializePersistedBinary(entry.encryptedPayload), Uint8Array.from(entry.payloadIv).buffer, ); return JSON.parse(new TextDecoder().decode(decrypted)) as PersistedChatEntryContent; } private async encryptBinary( messageEncryptionKey: CryptoKey, value: ArrayBuffer, ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> { const ivBuffer = new ArrayBuffer(12); const iv = new Uint8Array(ivBuffer); crypto.getRandomValues(iv); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: ivBuffer }, messageEncryptionKey, value, ); return { ciphertext, iv }; } private async decryptBinary( messageEncryptionKey: CryptoKey, value: ArrayBuffer, iv: ArrayBuffer, ): Promise { return crypto.subtle.decrypt( { name: 'AES-GCM', iv }, messageEncryptionKey, value, ); } private hasLegacyEncryptedBinaryStorage(entry: EncryptedPersistedChatEntry): boolean { return typeof entry.encryptedPayload !== 'string' || (!!entry.encryptedFileBlob && typeof entry.encryptedFileBlob !== 'string'); } private serializePersistedBinary(value: ArrayBuffer): string { return this.bufferToBase64Url(value); } private deserializePersistedBinary(value: PersistedBinary): ArrayBuffer { return typeof value === 'string' ? this.base64UrlToBuffer(value) : value; } private sendTypingState(peerId: string, active: boolean, force = false): void { const existingIdleTimeoutId = this.outgoingTypingIdleTimeouts.get(peerId); if (typeof existingIdleTimeoutId !== 'undefined') { window.clearTimeout(existingIdleTimeoutId); this.outgoingTypingIdleTimeouts.delete(peerId); } const currentState = this.outgoingTypingStates.get(peerId); if (!force && currentState?.active === active) { if (!active) { this.outgoingTypingStates.delete(peerId); } return; } const channel = this.peerBundles.get(peerId)?.channel; if (!channel || channel.readyState !== 'open') { if (active) { this.outgoingTypingStates.set(peerId, { active: true, lastSentAt: Date.now() }); } else { this.outgoingTypingStates.delete(peerId); } return; } channel.send(JSON.stringify({ type: 'typing', active } satisfies DataEnvelope)); if (active) { this.outgoingTypingStates.set(peerId, { active: true, lastSentAt: Date.now() }); return; } this.outgoingTypingStates.delete(peerId); } private removeMessageById(messageId: string): void { const message = this.messages().find((entry) => entry.id === messageId); if (!message) { return; } if (message.downloadUrl?.startsWith('blob:')) { URL.revokeObjectURL(message.downloadUrl); } const timeoutId = this.systemMessageTimeouts.get(messageId); if (typeof timeoutId !== 'undefined') { window.clearTimeout(timeoutId); this.systemMessageTimeouts.delete(messageId); } this.messages.update((messages) => messages.filter((entry) => entry.id !== messageId)); } private clearSystemMessageTimeouts(): void { for (const timeoutId of this.systemMessageTimeouts.values()) { window.clearTimeout(timeoutId); } this.systemMessageTimeouts.clear(); } private markPeerUnread(peerId: string): void { this.unreadPeerIds.update((peerIds) => (peerIds.includes(peerId) ? peerIds : [...peerIds, peerId])); } private clearUnreadPeer(peerId: string): void { this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId)); } private setPeerTyping(peerId: string, active: boolean): void { const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId); if (typeof existingTimeoutId !== 'undefined') { window.clearTimeout(existingTimeoutId); this.typingIndicatorTimeouts.delete(peerId); } if (!active) { this.clearPeerTyping(peerId); return; } this.typingPeerIds.update((peerIds) => (peerIds.includes(peerId) ? peerIds : [...peerIds, peerId])); const timeoutId = window.setTimeout(() => { this.clearPeerTyping(peerId); }, ChatSessionService.typingIndicatorLifetimeMs); this.typingIndicatorTimeouts.set(peerId, timeoutId); } private clearPeerTyping(peerId: string): void { const timeoutId = this.typingIndicatorTimeouts.get(peerId); if (typeof timeoutId !== 'undefined') { window.clearTimeout(timeoutId); this.typingIndicatorTimeouts.delete(peerId); } this.typingPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId)); } private clearOutgoingTyping(peerId: string): void { const idleTimeoutId = this.outgoingTypingIdleTimeouts.get(peerId); if (typeof idleTimeoutId !== 'undefined') { window.clearTimeout(idleTimeoutId); this.outgoingTypingIdleTimeouts.delete(peerId); } this.outgoingTypingStates.delete(peerId); } private clearTypingTimeouts(): void { for (const timeoutId of this.typingIndicatorTimeouts.values()) { window.clearTimeout(timeoutId); } for (const timeoutId of this.outgoingTypingIdleTimeouts.values()) { window.clearTimeout(timeoutId); } this.typingIndicatorTimeouts.clear(); this.outgoingTypingIdleTimeouts.clear(); this.outgoingTypingStates.clear(); } private async openMessageDatabase(): Promise { if (this.messageDatabasePromise) { return this.messageDatabasePromise; } if (typeof indexedDB === 'undefined') { this.messageDatabasePromise = Promise.resolve(null); return this.messageDatabasePromise; } this.messageDatabasePromise = new Promise((resolve, reject) => { const request = indexedDB.open(ChatSessionService.messageDatabaseName, 2); request.onupgradeneeded = () => { const database = request.result; const store = database.objectStoreNames.contains(ChatSessionService.messageStoreName) ? request.transaction!.objectStore(ChatSessionService.messageStoreName) : database.createObjectStore(ChatSessionService.messageStoreName, { keyPath: 'storageKey' }); if (!store.indexNames.contains('ownerUserId')) { store.createIndex('ownerUserId', 'ownerUserId', { unique: false }); } if (!store.indexNames.contains('conversationKeyCreatedAt')) { store.createIndex('conversationKeyCreatedAt', ['conversationKey', 'createdAt'], { unique: false }); } }; request.onsuccess = () => { const database = request.result; database.onversionchange = () => { database.close(); this.messageDatabasePromise = null; }; resolve(database); }; request.onerror = () => { reject(request.error ?? new Error('Could not open IndexedDB.')); }; }); return this.messageDatabasePromise; } private waitForRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.')); }); } private waitForTransaction(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.')); transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.')); }); } private toWebSocketUrl(httpUrl: string, token: string): string { const normalized = new URL(httpUrl); normalized.protocol = normalized.protocol === 'https:' ? 'wss:' : 'ws:'; normalized.pathname = '/ws'; normalized.search = `token=${encodeURIComponent(token)}`; return normalized.toString(); } private toPublicKeyCreationOptions(options: RegistrationOptionsResponse): PublicKeyCredentialCreationOptions { return { ...options, challenge: this.base64UrlToBuffer(options.challenge), user: { ...options.user, id: this.base64UrlToBuffer(options.user.id), }, excludeCredentials: options.excludeCredentials?.map((credential) => ({ ...credential, id: this.base64UrlToBuffer(credential.id), transports: credential.transports as AuthenticatorTransport[] | undefined, })), }; } private toPublicKeyRequestOptions(options: AuthenticationOptionsResponse): PublicKeyCredentialRequestOptions { return { challenge: this.base64UrlToBuffer(options.challenge), timeout: options.timeout, rpId: options.rpId, userVerification: options.userVerification, allowCredentials: options.allowCredentials?.map((credential) => ({ ...credential, id: this.base64UrlToBuffer(credential.id), transports: credential.transports as AuthenticatorTransport[] | undefined, })), extensions: options.extensions, }; } private serializeRegistrationCredential(credential: PublicKeyCredential) { const response = credential.response as AuthenticatorAttestationResponse & { getTransports?: () => string[]; }; return { id: credential.id, rawId: this.bufferToBase64Url(credential.rawId), response: { clientDataJSON: this.bufferToBase64Url(response.clientDataJSON), attestationObject: this.bufferToBase64Url(response.attestationObject), transports: response.getTransports?.(), }, clientExtensionResults: credential.getClientExtensionResults(), type: credential.type, authenticatorAttachment: credential.authenticatorAttachment ?? undefined, }; } private serializeAuthenticationCredential(credential: PublicKeyCredential) { const response = credential.response as AuthenticatorAssertionResponse; return { id: credential.id, rawId: this.bufferToBase64Url(credential.rawId), response: { clientDataJSON: this.bufferToBase64Url(response.clientDataJSON), authenticatorData: this.bufferToBase64Url(response.authenticatorData), signature: this.bufferToBase64Url(response.signature), userHandle: response.userHandle ? this.bufferToBase64Url(response.userHandle) : undefined, }, clientExtensionResults: credential.getClientExtensionResults(), type: credential.type, authenticatorAttachment: credential.authenticatorAttachment ?? undefined, }; } private base64UrlToBuffer(value: string): ArrayBuffer { const padding = '='.repeat((4 - (value.length % 4)) % 4); const normalized = (value + padding).replace(/-/g, '+').replace(/_/g, '/'); const binary = atob(normalized); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes.buffer.slice(0); } private bufferToBase64Url(value: ArrayBuffer): string { let binary = ''; const bytes = new Uint8Array(value); for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } private readUserStorage(): UserProfile | null { const value = this.readStorage('privatechat.user'); if (!value) { return null; } try { return JSON.parse(value) as UserProfile; } catch { return null; } } 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 extractErrorMessage(error: unknown, fallback: string): string { const httpError = error as HttpErrorResponse | undefined; const responseMessage = typeof httpError?.error?.message === 'string' ? httpError.error.message : undefined; const thrownMessage = error instanceof Error ? error.message : undefined; return responseMessage ?? thrownMessage ?? fallback; } }