Create sdks/web/ with a vanilla TypeScript web client: - IndexedDB local store for conversations, messages, and identity - WebSocket transport connecting to the server bridge - Service Worker with cache-first strategy for offline support - PWA manifest for installable web app - Dark-themed responsive UI with sidebar, messages, and input bar - Connection status badge and MLS epoch indicator in header - Unread message count badges on conversations
171 lines
4.8 KiB
TypeScript
171 lines
4.8 KiB
TypeScript
/**
|
|
* IndexedDB local state persistence for the web client.
|
|
*
|
|
* Stores conversations, messages, and identity state in the browser.
|
|
*/
|
|
|
|
const DB_NAME = "quicproquo";
|
|
const DB_VERSION = 1;
|
|
|
|
export interface StoredConversation {
|
|
id: string;
|
|
name: string;
|
|
unread: number;
|
|
lastActivity: number;
|
|
}
|
|
|
|
export interface StoredMessage {
|
|
id: string;
|
|
conversationId: string;
|
|
sender: string;
|
|
body: string;
|
|
timestamp: number;
|
|
isOutgoing: boolean;
|
|
}
|
|
|
|
export interface StoredIdentity {
|
|
username: string;
|
|
seed: Uint8Array;
|
|
publicKey: Uint8Array;
|
|
}
|
|
|
|
function openDB(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains("conversations")) {
|
|
const convStore = db.createObjectStore("conversations", { keyPath: "id" });
|
|
convStore.createIndex("lastActivity", "lastActivity");
|
|
}
|
|
if (!db.objectStoreNames.contains("messages")) {
|
|
const msgStore = db.createObjectStore("messages", { keyPath: "id" });
|
|
msgStore.createIndex("conversationId", "conversationId");
|
|
msgStore.createIndex("timestamp", "timestamp");
|
|
}
|
|
if (!db.objectStoreNames.contains("identity")) {
|
|
db.createObjectStore("identity", { keyPath: "username" });
|
|
}
|
|
};
|
|
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
export class LocalStore {
|
|
private db: IDBDatabase | null = null;
|
|
|
|
async open(): Promise<void> {
|
|
this.db = await openDB();
|
|
}
|
|
|
|
close(): void {
|
|
if (this.db) {
|
|
this.db.close();
|
|
this.db = null;
|
|
}
|
|
}
|
|
|
|
// --- Identity ---
|
|
|
|
async saveIdentity(identity: StoredIdentity): Promise<void> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction("identity", "readwrite");
|
|
tx.objectStore("identity").put(identity);
|
|
return txDone(tx);
|
|
}
|
|
|
|
async loadIdentity(username: string): Promise<StoredIdentity | null> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction("identity", "readonly");
|
|
const req = tx.objectStore("identity").get(username);
|
|
return new Promise((resolve, reject) => {
|
|
req.onsuccess = () => resolve(req.result ?? null);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
// --- Conversations ---
|
|
|
|
async saveConversation(conv: StoredConversation): Promise<void> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction("conversations", "readwrite");
|
|
tx.objectStore("conversations").put(conv);
|
|
return txDone(tx);
|
|
}
|
|
|
|
async listConversations(): Promise<StoredConversation[]> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction("conversations", "readonly");
|
|
const req = tx.objectStore("conversations").index("lastActivity").getAll();
|
|
return new Promise((resolve, reject) => {
|
|
req.onsuccess = () => resolve((req.result ?? []).reverse());
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async getConversation(id: string): Promise<StoredConversation | null> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction("conversations", "readonly");
|
|
const req = tx.objectStore("conversations").get(id);
|
|
return new Promise((resolve, reject) => {
|
|
req.onsuccess = () => resolve(req.result ?? null);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
// --- Messages ---
|
|
|
|
async saveMessage(msg: StoredMessage): Promise<void> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction("messages", "readwrite");
|
|
tx.objectStore("messages").put(msg);
|
|
return txDone(tx);
|
|
}
|
|
|
|
async loadMessages(conversationId: string, limit = 200): Promise<StoredMessage[]> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction("messages", "readonly");
|
|
const index = tx.objectStore("messages").index("conversationId");
|
|
const req = index.getAll(conversationId);
|
|
return new Promise((resolve, reject) => {
|
|
req.onsuccess = () => {
|
|
const all: StoredMessage[] = req.result ?? [];
|
|
all.sort((a, b) => a.timestamp - b.timestamp);
|
|
resolve(all.slice(-limit));
|
|
};
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
// --- Clear ---
|
|
|
|
async clearAll(): Promise<void> {
|
|
const db = this.requireDB();
|
|
const tx = db.transaction(
|
|
["conversations", "messages", "identity"],
|
|
"readwrite",
|
|
);
|
|
tx.objectStore("conversations").clear();
|
|
tx.objectStore("messages").clear();
|
|
tx.objectStore("identity").clear();
|
|
return txDone(tx);
|
|
}
|
|
|
|
private requireDB(): IDBDatabase {
|
|
if (!this.db) {
|
|
throw new Error("LocalStore not opened. Call open() first.");
|
|
}
|
|
return this.db;
|
|
}
|
|
}
|
|
|
|
function txDone(tx: IDBTransaction): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|