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:
17
sdks/web/package.json
Normal file
17
sdks/web/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
54
sdks/web/public/index.html
Normal file
54
sdks/web/public/index.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#58a6ff" />
|
||||
<title>quicproquo</title>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Login screen -->
|
||||
<div id="login-screen" class="screen">
|
||||
<h1>quicproquo</h1>
|
||||
<p class="subtitle">End-to-end encrypted messenger</p>
|
||||
<form id="login-form">
|
||||
<input id="server-input" type="text" placeholder="Server (ws://host:port)" value="ws://127.0.0.1:9000" />
|
||||
<input id="username-input" type="text" placeholder="Username" autocomplete="username" />
|
||||
<input id="password-input" type="password" placeholder="Password" autocomplete="current-password" />
|
||||
<button type="submit" id="login-btn">Connect & Login</button>
|
||||
<button type="button" id="register-btn">Register</button>
|
||||
</form>
|
||||
<p id="login-error" class="error" hidden></p>
|
||||
</div>
|
||||
|
||||
<!-- Chat screen -->
|
||||
<div id="chat-screen" class="screen" hidden>
|
||||
<aside id="sidebar">
|
||||
<div id="sidebar-header">
|
||||
<span id="user-display"></span>
|
||||
<button id="logout-btn" title="Logout">Logout</button>
|
||||
</div>
|
||||
<ul id="conversation-list"></ul>
|
||||
<button id="new-dm-btn">+ New DM</button>
|
||||
</aside>
|
||||
<main id="chat-main">
|
||||
<header id="chat-header">
|
||||
<span id="chat-title">Select a conversation</span>
|
||||
<span id="epoch-badge" title="MLS epoch">epoch --</span>
|
||||
<span id="conn-badge" class="offline">Offline</span>
|
||||
</header>
|
||||
<div id="messages"></div>
|
||||
<form id="send-form">
|
||||
<input id="msg-input" type="text" placeholder="Type a message..." autocomplete="off" />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
sdks/web/public/manifest.json
Normal file
21
sdks/web/public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
266
sdks/web/public/style.css
Normal file
266
sdks/web/public/style.css
Normal file
@@ -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;
|
||||
}
|
||||
96
sdks/web/public/sw.js
Normal file
96
sdks/web/public/sw.js
Normal file
@@ -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("/");
|
||||
}),
|
||||
);
|
||||
});
|
||||
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();
|
||||
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);
|
||||
});
|
||||
}
|
||||
17
sdks/web/tsconfig.json
Normal file
17
sdks/web/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user