feat(web): add browser web client with IndexedDB, Service Worker, and PWA

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
This commit is contained in:
2026-03-04 20:55:05 +01:00
parent 496f83067a
commit 2d56824834
8 changed files with 1049 additions and 0 deletions

170
sdks/web/src/store.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* 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);
});
}