1800 lines
54 KiB
TypeScript
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;
|
|
}
|
|
}
|