Rename all project references from quicproquo/qpq to quicprochat/qpc across documentation, Docker configuration, CI workflows, packaging scripts, operational configs, and build tooling. - Docker: crate paths, binary names, user/group, data dirs, env vars - CI: workflow crate references, binary names, artifact names - Docs: all markdown files under docs/, SDK READMEs, book.toml - Packaging: OpenWrt Makefile, init script, UCI config (file renames) - Scripts: justfile, dev-shell, screenshot, cross-compile, ai_team - Operations: Prometheus config, alert rules, Grafana dashboard - Config: .env.example (QPQ_* → QPC_*), CODEOWNERS paths - Top-level: README, CONTRIBUTING, ROADMAP, CLAUDE.md
409 lines
11 KiB
TypeScript
409 lines
11 KiB
TypeScript
/**
|
|
* 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 $ = <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();
|