diff --git a/sdks/web/package.json b/sdks/web/package.json new file mode 100644 index 0000000..9e16fc3 --- /dev/null +++ b/sdks/web/package.json @@ -0,0 +1,17 @@ +{ + "name": "@quicproquo/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && cp -r public/* dist/", + "dev": "tsc --watch", + "clean": "rm -rf dist" + }, + "dependencies": { + "@quicproquo/client": "file:../typescript" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/sdks/web/public/index.html b/sdks/web/public/index.html new file mode 100644 index 0000000..a9d0269 --- /dev/null +++ b/sdks/web/public/index.html @@ -0,0 +1,54 @@ + + + + + + + quicproquo + + + + +
+ +
+

quicproquo

+

End-to-end encrypted messenger

+
+ + + + + +
+ +
+ + + +
+ + + + diff --git a/sdks/web/public/manifest.json b/sdks/web/public/manifest.json new file mode 100644 index 0000000..3b80a0d --- /dev/null +++ b/sdks/web/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "quicproquo", + "short_name": "qpq", + "description": "End-to-end encrypted group messenger", + "start_url": "/", + "display": "standalone", + "background_color": "#0d1117", + "theme_color": "#58a6ff", + "icons": [ + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/sdks/web/public/style.css b/sdks/web/public/style.css new file mode 100644 index 0000000..491b9f4 --- /dev/null +++ b/sdks/web/public/style.css @@ -0,0 +1,266 @@ +/* quicproquo web client — dark theme */ +:root { + --bg: #0d1117; + --bg-surface: #161b22; + --bg-input: #21262d; + --fg: #c9d1d9; + --fg-muted: #8b949e; + --accent: #58a6ff; + --accent-dim: #1f6feb; + --green: #3fb950; + --red: #f85149; + --yellow: #d29922; + --border: #30363d; + --radius: 6px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + height: 100vh; + overflow: hidden; +} + +#app { + height: 100vh; + display: flex; +} + +/* --- Login screen --- */ +.screen { width: 100%; } + +#login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; +} + +#login-screen h1 { + font-size: 2rem; + color: var(--accent); +} + +.subtitle { + color: var(--fg-muted); + margin-bottom: 1rem; +} + +#login-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 320px; +} + +input[type="text"], +input[type="password"] { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 0.8rem; + color: var(--fg); + font-size: 0.95rem; +} + +input:focus { + outline: none; + border-color: var(--accent); +} + +button { + background: var(--accent-dim); + color: var(--fg); + border: none; + border-radius: var(--radius); + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.95rem; +} + +button:hover { + background: var(--accent); + color: var(--bg); +} + +.error { + color: var(--red); + font-size: 0.85rem; +} + +/* --- Chat screen --- */ +#chat-screen { + display: flex; + height: 100vh; +} + +#sidebar { + width: 260px; + background: var(--bg-surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +#sidebar-header { + padding: 0.75rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +#user-display { + color: var(--accent); + font-weight: 600; +} + +#logout-btn { + background: transparent; + color: var(--fg-muted); + font-size: 0.8rem; + padding: 0.3rem 0.5rem; +} + +#conversation-list { + list-style: none; + flex: 1; + overflow-y: auto; +} + +#conversation-list li { + padding: 0.6rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +#conversation-list li:hover { + background: var(--bg-input); +} + +#conversation-list li.active { + background: var(--bg-input); + border-left: 3px solid var(--accent); +} + +.conv-name { flex: 1; } + +.unread-badge { + background: var(--accent-dim); + color: var(--fg); + border-radius: 10px; + padding: 0.1rem 0.5rem; + font-size: 0.75rem; + min-width: 1.2rem; + text-align: center; +} + +#new-dm-btn { + margin: 0.5rem; + background: transparent; + border: 1px dashed var(--border); + color: var(--fg-muted); +} + +/* --- Chat main area --- */ +#chat-main { + flex: 1; + display: flex; + flex-direction: column; +} + +#chat-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 0.75rem; +} + +#chat-title { + flex: 1; + font-weight: 600; +} + +#epoch-badge { + font-size: 0.8rem; + color: var(--fg-muted); + background: var(--bg-input); + padding: 0.15rem 0.5rem; + border-radius: var(--radius); +} + +#conn-badge { + font-size: 0.8rem; + padding: 0.15rem 0.5rem; + border-radius: var(--radius); +} + +#conn-badge.online { + background: var(--green); + color: var(--bg); +} + +#conn-badge.offline { + background: var(--red); + color: var(--fg); +} + +#messages { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.message { + max-width: 70%; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + background: var(--bg-surface); + border: 1px solid var(--border); +} + +.message.outgoing { + align-self: flex-end; + background: var(--accent-dim); + border-color: var(--accent-dim); +} + +.message .meta { + font-size: 0.75rem; + color: var(--fg-muted); + margin-bottom: 0.25rem; +} + +.message.outgoing .meta { + color: rgba(255, 255, 255, 0.6); +} + +.message .body { + word-break: break-word; +} + +#send-form { + display: flex; + padding: 0.5rem; + border-top: 1px solid var(--border); + gap: 0.5rem; +} + +#msg-input { + flex: 1; +} diff --git a/sdks/web/public/sw.js b/sdks/web/public/sw.js new file mode 100644 index 0000000..fec7358 --- /dev/null +++ b/sdks/web/public/sw.js @@ -0,0 +1,96 @@ +/** + * quicproquo Service Worker — provides offline caching and background + * notification support for the PWA. + */ + +const CACHE_NAME = "qpq-cache-v1"; +const STATIC_ASSETS = [ + "/", + "/index.html", + "/style.css", + "/app.js", + "/manifest.json", +]; + +// Install: cache static assets. +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)), + ); + self.skipWaiting(); +}); + +// Activate: clean up old caches. +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ), + ); + self.clients.claim(); +}); + +// Fetch: serve from cache, fall back to network. +self.addEventListener("fetch", (event) => { + // Only cache GET requests for same-origin resources. + if (event.request.method !== "GET") return; + + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + // Cache successful same-origin responses. + if ( + response.ok && + new URL(event.request.url).origin === self.location.origin + ) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, clone); + }); + } + return response; + }); + }), + ); +}); + +// Push notifications (future: wired to server push events). +self.addEventListener("push", (event) => { + if (!event.data) return; + + let payload; + try { + payload = event.data.json(); + } catch { + payload = { title: "quicproquo", body: event.data.text() }; + } + + event.waitUntil( + self.registration.showNotification(payload.title || "quicproquo", { + body: payload.body || "New message", + icon: "/icon-192.png", + badge: "/icon-192.png", + tag: payload.conversationId || "default", + }), + ); +}); + +// Notification click: open the app. +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil( + self.clients + .matchAll({ type: "window" }) + .then((clients) => { + if (clients.length > 0) { + return clients[0].focus(); + } + return self.clients.openWindow("/"); + }), + ); +}); diff --git a/sdks/web/src/app.ts b/sdks/web/src/app.ts new file mode 100644 index 0000000..06f4b1f --- /dev/null +++ b/sdks/web/src/app.ts @@ -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 $ = (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(); diff --git a/sdks/web/src/store.ts b/sdks/web/src/store.ts new file mode 100644 index 0000000..9af35b5 --- /dev/null +++ b/sdks/web/src/store.ts @@ -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 { + 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 { + this.db = await openDB(); + } + + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + } + } + + // --- Identity --- + + async saveIdentity(identity: StoredIdentity): Promise { + const db = this.requireDB(); + const tx = db.transaction("identity", "readwrite"); + tx.objectStore("identity").put(identity); + return txDone(tx); + } + + async loadIdentity(username: string): Promise { + 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 { + const db = this.requireDB(); + const tx = db.transaction("conversations", "readwrite"); + tx.objectStore("conversations").put(conv); + return txDone(tx); + } + + async listConversations(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} diff --git a/sdks/web/tsconfig.json b/sdks/web/tsconfig.json new file mode 100644 index 0000000..81fdba3 --- /dev/null +++ b/sdks/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "sourceMap": true, + "declaration": false, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}