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:
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("/");
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user