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:
170
sdks/web/src/store.ts
Normal file
170
sdks/web/src/store.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user