Files
PrivateChat/client/src/app/chat-session.service.ts
2026-03-09 19:35:08 +01:00

1800 lines
54 KiB
TypeScript

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<ChatEntry['kind'], 'system'>;
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<ChatEntry['kind'], 'system'>;
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<UserProfile | null>(this.readUserStorage());
readonly accessKeys = signal<AccessKeySummary[]>([]);
readonly peers = signal<PeerSummary[]>([]);
readonly activePeerId = signal<string | null>(null);
readonly messages = signal<ChatEntry[]>([]);
readonly unreadPeerIds = signal<string[]>([]);
readonly typingPeerIds = signal<string[]>([]);
readonly signalingState = signal<ConnectionState>('disconnected');
readonly status = signal('Disconnected from signaling server.');
readonly error = signal<string | null>(null);
readonly notice = signal<string | null>(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<string, PeerBundle>();
private readonly incomingFiles = new Map<string, IncomingFileTransfer>();
private readonly systemMessageTimeouts = new Map<string, number>();
private readonly typingIndicatorTimeouts = new Map<string, number>();
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 messageEncryptionKey: CryptoKey | null = null;
private messageDatabasePromise: Promise<IDBDatabase | null> | 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<boolean> {
this.error.set(null);
this.notice.set(null);
try {
const response = await firstValueFrom(
this.http.post<AuthResponse | PendingApprovalResponse>(`${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<void> {
await this.authenticate('/api/auth/login', { username, password });
}
async loginWithAccessKey(username: string): Promise<void> {
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<AuthenticationOptionsResponse>(
`${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<AuthResponse>(
`${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<void> {
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<void> {
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<void> {
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<RegistrationOptionsResponse>(
`${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<void> {
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<void> {
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<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,
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<string, unknown>): Promise<void> {
this.error.set(null);
this.notice.set(null);
try {
const response = await firstValueFrom(
this.http.post<AuthResponse>(`${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<void> {
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<PendingApprovalUser[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<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();
await this.connectWebSocket();
} catch {
this.clearLocalAuth('Saved session expired. Sign in again.');
}
}
private mergePresence(peers: Array<UserProfile | PeerSummary>): 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<void> {
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<void> {
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<void> {
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<void> {
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<Pick<PeerSummary, 'connectionState' | 'channelState'>>,
): 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<void> {
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<void> {
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<void> {
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<ChatEntry | null> {
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<void> {
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<void> {
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<void>): Promise<void> {
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<CryptoKey> {
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<PersistedChatEntryContent> {
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<ArrayBuffer> {
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<IDBDatabase | null> {
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<T>(request: IDBRequest<T>): Promise<T> {
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<void> {
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;
}
}