/** * quicprochat 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 $ = (id: string) => document.getElementById(id) as T; const loginScreen = $("login-screen"); const chatScreen = $("chat-screen"); const loginForm = $("login-form"); const serverInput = $("server-input"); const usernameInput = $("username-input"); const passwordInput = $("password-input"); const loginBtn = $("login-btn"); const registerBtn = $("register-btn"); const loginError = $("login-error"); const userDisplay = $("user-display"); const logoutBtn = $("logout-btn"); const conversationList = $("conversation-list"); const newDmBtn = $("new-dm-btn"); const chatTitle = $("chat-title"); const epochBadge = $("epoch-badge"); const connBadge = $("conn-badge"); const messagesDiv = $("messages"); const sendForm = $("send-form"); const msgInput = $("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 { 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): Promise { 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();