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

408
sdks/web/src/app.ts Normal file
View File

@@ -0,0 +1,408 @@
/**
* quicproquo web client — main application.
*
* Connects the TS SDK, WASM crypto, IndexedDB store, and the DOM together.
*/
import { LocalStore } from "./store.js";
import type { StoredConversation, StoredMessage } from "./store.js";
// --- DOM references ---
const $ = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const loginScreen = $<HTMLDivElement>("login-screen");
const chatScreen = $<HTMLDivElement>("chat-screen");
const loginForm = $<HTMLFormElement>("login-form");
const serverInput = $<HTMLInputElement>("server-input");
const usernameInput = $<HTMLInputElement>("username-input");
const passwordInput = $<HTMLInputElement>("password-input");
const loginBtn = $<HTMLButtonElement>("login-btn");
const registerBtn = $<HTMLButtonElement>("register-btn");
const loginError = $<HTMLParagraphElement>("login-error");
const userDisplay = $<HTMLSpanElement>("user-display");
const logoutBtn = $<HTMLButtonElement>("logout-btn");
const conversationList = $<HTMLUListElement>("conversation-list");
const newDmBtn = $<HTMLButtonElement>("new-dm-btn");
const chatTitle = $<HTMLSpanElement>("chat-title");
const epochBadge = $<HTMLSpanElement>("epoch-badge");
const connBadge = $<HTMLSpanElement>("conn-badge");
const messagesDiv = $<HTMLDivElement>("messages");
const sendForm = $<HTMLFormElement>("send-form");
const msgInput = $<HTMLInputElement>("msg-input");
// --- App state ---
const store = new LocalStore();
let wsTransport: WebSocket | null = null;
let currentUser: string | null = null;
let conversations: StoredConversation[] = [];
let activeConvId: string | null = null;
let connected = false;
// --- Init ---
async function init() {
await store.open();
// Register service worker for PWA offline support.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("sw.js").catch(() => {
// Service worker registration failed — non-critical.
});
}
loginForm.addEventListener("submit", (e) => {
e.preventDefault();
handleLogin();
});
registerBtn.addEventListener("click", handleRegister);
logoutBtn.addEventListener("click", handleLogout);
sendForm.addEventListener("submit", (e) => {
e.preventDefault();
handleSend();
});
newDmBtn.addEventListener("click", handleNewDm);
}
// --- Auth ---
async function handleLogin() {
const server = serverInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!server || !username || !password) {
showError("All fields are required");
return;
}
loginBtn.disabled = true;
hideError();
try {
await connectWS(server);
await rpcCall("login", { username, password });
currentUser = username;
showChat();
} catch (err: unknown) {
showError(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
loginBtn.disabled = false;
}
}
async function handleRegister() {
const server = serverInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!server || !username || !password) {
showError("All fields are required");
return;
}
registerBtn.disabled = true;
hideError();
try {
await connectWS(server);
await rpcCall("register", { username, password });
await rpcCall("login", { username, password });
currentUser = username;
showChat();
} catch (err: unknown) {
showError(`Registration failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
registerBtn.disabled = false;
}
}
function handleLogout() {
currentUser = null;
activeConvId = null;
if (wsTransport) {
wsTransport.close();
wsTransport = null;
}
setConnected(false);
loginScreen.hidden = false;
chatScreen.hidden = true;
}
// --- WebSocket transport ---
let rpcId = 0;
const pendingCalls = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
>();
function connectWS(addr: string): Promise<void> {
return new Promise((resolve, reject) => {
if (wsTransport) {
wsTransport.close();
}
const ws = new WebSocket(addr);
const timeout = setTimeout(() => {
reject(new Error("Connection timeout"));
ws.close();
}, 5000);
ws.addEventListener("open", () => {
clearTimeout(timeout);
wsTransport = ws;
setConnected(true);
resolve();
});
ws.addEventListener("close", () => {
clearTimeout(timeout);
setConnected(false);
rejectAllPending("Connection closed");
});
ws.addEventListener("error", () => {
clearTimeout(timeout);
reject(new Error("WebSocket error"));
});
ws.addEventListener("message", (ev) => {
if (typeof ev.data === "string") {
try {
const resp = JSON.parse(ev.data);
const pending = pendingCalls.get(resp.id);
if (pending) {
pendingCalls.delete(resp.id);
if (resp.ok) {
pending.resolve(resp.result);
} else {
pending.reject(new Error(resp.error ?? "RPC error"));
}
}
// Handle server push events.
if (resp.type === "message") {
handleIncomingMessage(resp);
}
} catch {
// Ignore malformed messages.
}
}
});
});
}
function rpcCall(method: string, params: Record<string, unknown>): Promise<unknown> {
if (!wsTransport || wsTransport.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("Not connected"));
}
const id = ++rpcId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingCalls.delete(id);
reject(new Error(`RPC timeout: ${method}`));
}, 10000);
pendingCalls.set(id, {
resolve: (v) => {
clearTimeout(timer);
resolve(v);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
wsTransport!.send(JSON.stringify({ id, method, params }));
});
}
function rejectAllPending(reason: string) {
for (const [id, p] of pendingCalls) {
p.reject(new Error(reason));
pendingCalls.delete(id);
}
}
// --- UI updates ---
function setConnected(value: boolean) {
connected = value;
connBadge.textContent = value ? "Online" : "Offline";
connBadge.className = value ? "online" : "offline";
}
function showError(msg: string) {
loginError.textContent = msg;
loginError.hidden = false;
}
function hideError() {
loginError.hidden = true;
}
async function showChat() {
loginScreen.hidden = true;
chatScreen.hidden = false;
userDisplay.textContent = currentUser ?? "";
// Load conversations from IndexedDB.
conversations = await store.listConversations();
renderConversations();
}
function renderConversations() {
conversationList.innerHTML = "";
for (const conv of conversations) {
const li = document.createElement("li");
if (conv.id === activeConvId) li.classList.add("active");
const nameSpan = document.createElement("span");
nameSpan.className = "conv-name";
nameSpan.textContent = conv.name;
li.appendChild(nameSpan);
if (conv.unread > 0) {
const badge = document.createElement("span");
badge.className = "unread-badge";
badge.textContent = String(conv.unread);
li.appendChild(badge);
}
li.addEventListener("click", () => selectConversation(conv.id));
conversationList.appendChild(li);
}
}
async function selectConversation(id: string) {
activeConvId = id;
const conv = conversations.find((c) => c.id === id);
if (conv) {
chatTitle.textContent = conv.name;
conv.unread = 0;
await store.saveConversation(conv);
}
renderConversations();
await renderMessages();
}
async function renderMessages() {
messagesDiv.innerHTML = "";
if (!activeConvId) return;
const msgs = await store.loadMessages(activeConvId);
for (const msg of msgs) {
appendMessageElement(msg);
}
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function appendMessageElement(msg: StoredMessage) {
const div = document.createElement("div");
div.className = `message${msg.isOutgoing ? " outgoing" : ""}`;
const meta = document.createElement("div");
meta.className = "meta";
const time = new Date(msg.timestamp);
const timeStr = `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
meta.textContent = `${timeStr} ${msg.sender}`;
const body = document.createElement("div");
body.className = "body";
body.textContent = msg.body;
div.appendChild(meta);
div.appendChild(body);
messagesDiv.appendChild(div);
}
// --- Send ---
async function handleSend() {
const text = msgInput.value.trim();
if (!text || !activeConvId || !currentUser) return;
msgInput.value = "";
const msg: StoredMessage = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
conversationId: activeConvId,
sender: currentUser,
body: text,
timestamp: Date.now(),
isOutgoing: true,
};
await store.saveMessage(msg);
appendMessageElement(msg);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
// Send via RPC (best effort).
try {
await rpcCall("send", {
conversationId: activeConvId,
body: text,
});
} catch {
// Message saved locally even if RPC fails.
}
}
// --- Receive ---
async function handleIncomingMessage(data: {
conversationId?: string;
sender?: string;
body?: string;
}) {
if (!data.conversationId || !data.body) return;
const msg: StoredMessage = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
conversationId: data.conversationId,
sender: data.sender ?? "unknown",
body: data.body,
timestamp: Date.now(),
isOutgoing: false,
};
await store.saveMessage(msg);
// Update unread or display.
if (data.conversationId === activeConvId) {
appendMessageElement(msg);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
} else {
const conv = conversations.find((c) => c.id === data.conversationId);
if (conv) {
conv.unread += 1;
await store.saveConversation(conv);
renderConversations();
}
}
}
// --- New DM ---
async function handleNewDm() {
const peerName = prompt("Enter username for DM:");
if (!peerName) return;
const convId = `dm-${[currentUser, peerName].sort().join("-")}`;
// Check if already exists.
if (conversations.find((c) => c.id === convId)) {
await selectConversation(convId);
return;
}
const conv: StoredConversation = {
id: convId,
name: peerName,
unread: 0,
lastActivity: Date.now(),
};
await store.saveConversation(conv);
conversations.unshift(conv);
renderConversations();
await selectConversation(convId);
}
// --- Bootstrap ---
init();

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);
});
}