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