From a3b20a24462a7c28508e1b367a531d3961e83629 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 20 Oct 2026 23:21:34 +0300 Subject: [PATCH] Hermes MVP: threaded chat UI, contacts, persistent history, --messages flag Builds on the previous Hermes sessions (NIP-17 crypto + chipnet hermes.bch + Theseus Messages window) to turn the flat inbox into something usable. main.js (hermes block): - Persistent inbox per identity at /hermes//inbox.jsonl (append-only JSONL, replayed on init). Read-state at /hermes//read.json so unread badges survive restarts. - Address book at /hermes/contacts.json: list / add / update / remove. hermes-contacts-add resolves .bch name OR npub OR hex pk, canonicalises, writes. - Reverse-resolve consults contacts first, chain second: a user-curated nickname wins over the on-chain np lookup. - hermes-send also records the outgoing message to the inbox so thread view shows both sides (only when at least one relay accepted). - Inbox messages carry direction + peerPkHex + peerName so the renderer can group by thread without re-doing the pairing logic. - --messages CLI flag opens the panel at launch (first instance via whenReady, second-instance via handleLaunch) so the dev harness and power users don't need Ctrl+Shift+M. - Dev-only auto-init: THESEUS_HERMES_DEV_MNEMONIC env, gated on THESEUS_USER_DATA so it refuses to touch a production profile. - HERMES_INBOX_LIMIT raised 200 -> 2000 now that history actually sticks. messages-preload.js: - Adds hermes.contacts.{list, add, update, remove} and hermes.markThreadRead; status + onMessage/onStatus unchanged. messages.html: - Replaces the flat inbox with a two-column layout: contact list (search + unread badges + last-message preview) on the left, selected thread on the right with sender bubbles, timestamps, inline Enter-to-send compose, remove-contact affordance. - .bch suffix stripped from displayed names (TLD is noise in chat UI; other TLDs stay as disambiguation). - Mark-thread-read fires on open; vault-bind path kept intact. --- main.js | 255 ++++++++++++++++++++++- messages-preload.js | 29 ++- messages.html | 479 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 648 insertions(+), 115 deletions(-) diff --git a/main.js b/main.js index 20287260..ede0e52f 100644 --- a/main.js +++ b/main.js @@ -1000,7 +1000,12 @@ const SETTINGS_ONLY = new Set([ ]); // Hermes (Nostr messaging) speaks as the user and reads their messages: // only its own window may drive it. -const HERMES_ONLY = new Set(["hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close", "hermes-inbox", "hermes-send"]); +const HERMES_ONLY = new Set([ + "hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close", + "hermes-inbox", "hermes-send", + "hermes-contacts-list", "hermes-contacts-add", "hermes-contacts-update", "hermes-contacts-remove", + "hermes-thread-mark-read", +]); const SETTINGS_SHARED = new Set([ "add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "blocklist-state", "collision-state", "remove-engine", "settings-get", "settings-set", "toggle-tor", @@ -9543,7 +9548,88 @@ ipcMain.handle("switch-to-bcnr", () => { // WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription, // and opens a per-send WebSocket for publishes. const HERMES_DEFAULT_RELAYS = ["wss://nos.lol"]; -const HERMES_INBOX_LIMIT = 200; +const HERMES_INBOX_LIMIT = 2000; + +// Persistent storage. On disk: +// /hermes/contacts.json — address book (user-trusted) +// /hermes//inbox.jsonl — append-only, per identity +// Keyed by pkHex because the mnemonic path and the vault-bind path produce the +// SAME pkHex for the same seed, so an identity's history survives a switch +// between sign-in modes. +const hermesDir = () => path.join(app.getPath("userData"), "hermes"); +const hermesContactsFile = () => path.join(hermesDir(), "contacts.json"); +const hermesInboxFile = (pkHex) => path.join(hermesDir(), pkHex, "inbox.jsonl"); + +function hermesEnsureDir(p) { + try { fs.mkdirSync(p, { recursive: true }); } catch {} +} +let hermesContactsCache = null; // null = not loaded; array once loaded +function hermesLoadContacts() { + if (hermesContactsCache) return hermesContactsCache; + try { + if (fs.existsSync(hermesContactsFile())) { + const raw = JSON.parse(fs.readFileSync(hermesContactsFile(), "utf8")); + hermesContactsCache = Array.isArray(raw) ? raw : []; + } else hermesContactsCache = []; + } catch (e) { + console.error("[hermes] contacts load failed:", e.message); + hermesContactsCache = []; + } + return hermesContactsCache; +} +function hermesSaveContacts() { + hermesEnsureDir(hermesDir()); + try { + fs.writeFileSync(hermesContactsFile() + ".tmp", JSON.stringify(hermesContactsCache || [], null, 2)); + fs.renameSync(hermesContactsFile() + ".tmp", hermesContactsFile()); + } catch (e) { + console.error("[hermes] contacts save failed:", e.message); + } +} + +// Inbox: append-only JSONL. Each line is one message record. Reading replays +// the whole thing (capped at HERMES_INBOX_LIMIT in memory). Append is +// best-effort — a save failure drops a message from the on-disk log but keeps +// it in the live in-memory inbox for this session. +function hermesLoadInboxFromDisk(pkHex) { + const f = hermesInboxFile(pkHex); + if (!fs.existsSync(f)) return []; + try { + const lines = fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean); + const msgs = []; + for (const line of lines) { + try { msgs.push(JSON.parse(line)); } catch { /* malformed line — skip */ } + } + // Keep only the newest window if the file has grown huge. + if (msgs.length > HERMES_INBOX_LIMIT) msgs.splice(0, msgs.length - HERMES_INBOX_LIMIT); + return msgs; + } catch (e) { + console.error("[hermes] inbox load failed:", e.message); + return []; + } +} +function hermesAppendInbox(pkHex, msg) { + const dir = path.dirname(hermesInboxFile(pkHex)); + hermesEnsureDir(dir); + try { fs.appendFileSync(hermesInboxFile(pkHex), JSON.stringify(msg) + "\n"); } + catch (e) { console.error("[hermes] inbox append failed:", e.message); } +} +// Called when a thread is marked read — persist the state so re-opening the +// panel doesn't re-badge every historical conversation as new. +const hermesReadStateFile = (pkHex) => path.join(hermesDir(), pkHex, "read.json"); +function hermesLoadReadState(pkHex) { + const f = hermesReadStateFile(pkHex); + if (!fs.existsSync(f)) return {}; + try { return JSON.parse(fs.readFileSync(f, "utf8")) || {}; } catch { return {}; } +} +function hermesSaveReadState(pkHex, state) { + const dir = path.dirname(hermesReadStateFile(pkHex)); + hermesEnsureDir(dir); + try { + fs.writeFileSync(hermesReadStateFile(pkHex) + ".tmp", JSON.stringify(state)); + fs.renameSync(hermesReadStateFile(pkHex) + ".tmp", hermesReadStateFile(pkHex)); + } catch (e) { console.error("[hermes] read-state save failed:", e.message); } +} let hermesWin = null; // State when initialised: { skHex, pkHex, npub, inbox: [], relays: Map } @@ -9562,6 +9648,10 @@ function hermesRecord(msg) { if (hermesState.inbox.length > HERMES_INBOX_LIMIT) { hermesState.inbox.splice(0, hermesState.inbox.length - HERMES_INBOX_LIMIT); } + // Persist incoming messages so a close-reopen doesn't drop history. + // Outgoing messages (where direction === "out") also come through this + // function from the send handler so threads show both sides. + if (hermesState.pkHex) hermesAppendInbox(hermesState.pkHex, msg); hermesEmit("hermes-message", msg); } // Pushed on every relay connect/disconnect so the pill in the panel reflects @@ -9601,6 +9691,11 @@ async function hermesReverseResolve(pkHex) { } hermesNpMap = m; hermesNpGen = indexBuiltAt; } + // Contacts win over chain scan — the user explicitly curated them, so + // even an unregistered pk with a chosen nickname renders correctly. + const contacts = hermesLoadContacts(); + const hit = contacts.find((c) => c.pkHex === pkHex); + if (hit) return hit.name || hit.nickname || null; return hermesNpMap.get(pkHex) ?? null; } catch { return null; } // chain unreachable — leave unresolved this round } @@ -9641,6 +9736,9 @@ async function hermesConnectRelay(url) { // the cache short-circuits after the first miss per pk. const senderName = await hermesReverseResolve(opened.senderPkHex); hermesRecord({ + direction: "in", + peerPkHex: opened.senderPkHex, // the OTHER party in this thread + peerName: senderName, senderPkHex: opened.senderPkHex, senderName, text: opened.text, @@ -9705,12 +9803,19 @@ ipcMain.handle("hermes-init", async (_e, opts = {}) => { } ({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim())); } + // Replay persisted inbox for this identity before any live wraps arrive. + // Hydrating _seen from disk messages means a wrap we already stored + // doesn't get re-recorded if a relay replays it on reconnect. + const persisted = hermesLoadInboxFromDisk(pkHex); + const seen = new Set(); + for (const m of persisted) seen.add((m.senderPkHex || "") + "\0" + m.createdAt + "\0" + m.text); hermesState = { skHex: Buffer.from(sk).toString("hex"), pkHex, npub, - inbox: [], + inbox: persisted, + readState: hermesLoadReadState(pkHex), relays: new Map(), - _seen: new Set(), + _seen: seen, }; for (const url of HERMES_DEFAULT_RELAYS) { hermesState.relays.set(url, await hermesConnectRelay(url)); @@ -9734,7 +9839,87 @@ ipcMain.handle("hermes-close", () => { hermesTeardown(); return hOk(); }); ipcMain.handle("hermes-inbox", () => { if (!hermesState) return hErr("not initialised"); - return hOk({ messages: hermesState.inbox.slice() }); + return hOk({ + messages: hermesState.inbox.slice(), + readState: hermesState.readState || {}, + }); +}); + +// Mark a conversation thread as read up to a given createdAt. Persisted so +// unread badges are stable across restarts. +ipcMain.handle("hermes-thread-mark-read", (_e, { peerPkHex, upToCreatedAt } = {}) => { + if (!hermesState) return hErr("not initialised"); + if (!peerPkHex || typeof peerPkHex !== "string") return hErr("peerPkHex required"); + const now = Math.floor(Date.now() / 1000); + const until = Number.isFinite(upToCreatedAt) ? upToCreatedAt : now; + hermesState.readState = hermesState.readState || {}; + hermesState.readState[peerPkHex.toLowerCase()] = Math.max( + hermesState.readState[peerPkHex.toLowerCase()] || 0, until); + hermesSaveReadState(hermesState.pkHex, hermesState.readState); + return hOk({ peerPkHex: peerPkHex.toLowerCase(), readUpTo: until }); +}); + +// ---- Contacts (address book) ------------------------------------------------ +// Shape on disk: [{ name, nickname?, pkHex, addedAt }] +// `name` is the canonical .bch name (if known) OR an npub label +// `pkHex` is authoritative for routing + reverse-resolve +// User-trusted mapping — reverse-resolve consults contacts first, chain second. +ipcMain.handle("hermes-contacts-list", () => hOk({ contacts: hermesLoadContacts().slice() })); + +ipcMain.handle("hermes-contacts-add", async (_e, { name, nickname } = {}) => { + const raw = String(name || "").trim(); + if (!raw) return hErr("name is required (e.g. alice.bch or an npub)"); + // Resolve to a pkHex: direct hex, npub, or .bch name via on-chain np. + let pkHex = null, canonicalName = raw; + try { + const H = await loadHermesLib(); + if (/^[0-9a-f]{64}$/i.test(raw)) { + pkHex = raw.toLowerCase(); + } else if (raw.startsWith("npub1")) { + pkHex = H.parseNpRecord(raw); + } else { + const R = await getResolver(); + canonicalName = R.normalizeName(raw); + const entry = await R.resolveName(canonicalName, { WebSocket }); + if (!entry) return hErr(`no on-chain registration for ${canonicalName}`); + const parsed = H.parseHermesRecords(entry); + pkHex = parsed.npPk; + } + } catch (e) { return hErr(e); } + const contacts = hermesLoadContacts(); + const existing = contacts.findIndex((c) => c.pkHex === pkHex); + const rec = { + name: canonicalName, + nickname: nickname ? String(nickname) : null, + pkHex, + addedAt: new Date().toISOString(), + }; + if (existing >= 0) contacts[existing] = { ...contacts[existing], ...rec }; + else contacts.push(rec); + hermesContactsCache = contacts; + hermesSaveContacts(); + return hOk({ contact: rec, contacts }); +}); + +ipcMain.handle("hermes-contacts-update", (_e, { pkHex, patch } = {}) => { + if (!pkHex) return hErr("pkHex required"); + const contacts = hermesLoadContacts(); + const i = contacts.findIndex((c) => c.pkHex === pkHex.toLowerCase()); + if (i < 0) return hErr("no such contact"); + // Whitelist mutable fields; never let the renderer rewrite pkHex. + for (const k of ["name", "nickname"]) if (patch && k in patch) contacts[i][k] = patch[k]; + hermesContactsCache = contacts; + hermesSaveContacts(); + return hOk({ contact: contacts[i], contacts }); +}); + +ipcMain.handle("hermes-contacts-remove", (_e, { pkHex } = {}) => { + if (!pkHex) return hErr("pkHex required"); + const contacts = hermesLoadContacts(); + const before = contacts.length; + hermesContactsCache = contacts.filter((c) => c.pkHex !== pkHex.toLowerCase()); + hermesSaveContacts(); + return hOk({ removed: before - hermesContactsCache.length, contacts: hermesContactsCache }); }); // Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved @@ -9782,6 +9967,21 @@ ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => { const results = await Promise.all(relays.map(publishOne)); const accepted = results.filter((r) => r.ok).length; + // Thread view needs the outgoing side too. Only record if at least one + // relay accepted — otherwise it never left the device and shouldn't + // appear in history as "sent". + if (accepted > 0) { + const peerName = await hermesReverseResolve(recipientPkHex); + hermesRecord({ + direction: "out", + peerPkHex: recipientPkHex, // the OTHER party in this thread + peerName, + senderPkHex: hermesState.pkHex, // us + senderName: null, + text, + createdAt: Math.floor(Date.now() / 1000), + }); + } return hOk({ recipientPkHex, accepted, total: results.length, results }); } catch (e) { return hErr(e); } }); @@ -9917,6 +10117,32 @@ function openHermesWindow() { ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; }); +// Dev-only auto-init: `THESEUS_HERMES_DEV_MNEMONIC=...` + `--messages` launches +// straight into an initialised panel. Only active when THESEUS_USER_DATA is +// also set (dev-profile isolation) — refuses to touch a production profile. +async function hermesDevAutoInit() { + const mnemonic = process.env.THESEUS_HERMES_DEV_MNEMONIC; + if (!mnemonic || !process.env.THESEUS_USER_DATA) return; + try { + const H = await loadHermesLib(); + const { sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim()); + const persisted = hermesLoadInboxFromDisk(pkHex); + const seen = new Set(); + for (const m of persisted) seen.add((m.senderPkHex || "") + "\0" + m.createdAt + "\0" + m.text); + hermesTeardown(); + hermesState = { + skHex: Buffer.from(sk).toString("hex"), + pkHex, npub, inbox: persisted, + readState: hermesLoadReadState(pkHex), + relays: new Map(), _seen: seen, + }; + for (const url of HERMES_DEFAULT_RELAYS) { + hermesState.relays.set(url, await hermesConnectRelay(url)); + } + console.log(`[hermes:dev] auto-init pk=${pkHex.slice(0, 10)}… inbox=${persisted.length}`); + } catch (e) { console.warn("[hermes:dev] auto-init failed:", e?.message); } +} + // --- BCNR provider (window.bcnr) — read-only surface. See // DESIGN-integrated-wallet.md §3. Every method reuses the resolver Theseus // already runs; nothing about the user leaks, so no origin/permission gate. @@ -10169,8 +10395,18 @@ function openInMainTab(url) { function handleLaunch(argv) { const appUrl = webapps.appUrlFromArgv(argv); if (appUrl) { if (webapps.launch(appUrl)) return; openInMainTab(appUrl); return; } + // `--messages` flag opens the Messages panel on launch — same as pressing + // Ctrl+Shift+M. Primary caller is the dev harness (focus-steal is blocked + // when the user is actively in another window) but it's also a reasonable + // CLI entry point for `theseus --messages`. + const openMsg = Array.isArray(argv) && argv.some((a) => a === "--messages"); if (win && !win.isDestroyed()) { try { if (win.isMinimized()) win.restore(); win.focus(); } catch {} } else createWindow(); + if (openMsg) { + // openHermesWindow is defined further down; defer with setImmediate so it + // runs after createWindow's queue has settled on first launch. + setImmediate(() => { try { openHermesWindow(); } catch {} }); + } } if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { // Another Theseus owns this profile; it got our argv via second-instance. @@ -10255,6 +10491,15 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { const appUrl = webapps.appUrlFromArgv(process.argv); if (appUrl && webapps.launch(appUrl)) { /* app window only */ } else createWindow(); + // `--messages` flag: open the Messages panel on first launch too. + // Dev auto-init runs first (if env says so) so the panel opens already + // signed in instead of showing the mnemonic prompt. + if (process.argv.includes("--messages")) { + setImmediate(async () => { + try { await hermesDevAutoInit(); } catch {} + try { openHermesWindow(); } catch {} + }); + } // BNS index: a separate process (bns-indexer.js). Its first phase — the // local snapshot, no network — starts now, so the index is warm by the // time the toolbar's bns:// bookmark favicons or the first tab ask for diff --git a/messages-preload.js b/messages-preload.js index 36aa3873..0abdc54e 100644 --- a/messages-preload.js +++ b/messages-preload.js @@ -3,20 +3,33 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("hermes", { - status: () => ipcRenderer.invoke("hermes-status"), - canUseVault: () => ipcRenderer.invoke("hermes-can-use-vault"), - init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }), + // Identity + relay state + status: () => ipcRenderer.invoke("hermes-status"), + canUseVault: () => ipcRenderer.invoke("hermes-can-use-vault"), + init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }), initFromVault: () => ipcRenderer.invoke("hermes-init", { useVault: true }), - close: () => ipcRenderer.invoke("hermes-close"), - inbox: () => ipcRenderer.invoke("hermes-inbox"), - send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }), - // Server-push: new decrypted message arrived. Callback gets { senderPkHex, senderName, text, createdAt }. + close: () => ipcRenderer.invoke("hermes-close"), + + // Messages + inbox: () => ipcRenderer.invoke("hermes-inbox"), + send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }), + markThreadRead: (peerPkHex, upToCreatedAt) => + ipcRenderer.invoke("hermes-thread-mark-read", { peerPkHex, upToCreatedAt }), + + // Contacts (address book) + contacts: { + list: () => ipcRenderer.invoke("hermes-contacts-list"), + add: (name, nickname) => ipcRenderer.invoke("hermes-contacts-add", { name, nickname }), + update: (pkHex, patch) => ipcRenderer.invoke("hermes-contacts-update", { pkHex, patch }), + remove: (pkHex) => ipcRenderer.invoke("hermes-contacts-remove", { pkHex }), + }, + + // Pushes: new decrypted message / relay status change. onMessage: (cb) => { const wrapped = (_e, msg) => cb(msg); ipcRenderer.on("hermes-message", wrapped); return () => ipcRenderer.removeListener("hermes-message", wrapped); }, - // Server-push: relay connection state changed. Callback gets { relaysConnected, relaysTotal }. onStatus: (cb) => { const wrapped = (_e, s) => cb(s); ipcRenderer.on("hermes-status-update", wrapped); diff --git a/messages.html b/messages.html index bd95e195..ed2f6c70 100644 --- a/messages.html +++ b/messages.html @@ -5,32 +5,27 @@ Messages — Theseus @@ -100,11 +165,12 @@
+

Hermes derives its Nostr identity at silentmode/messenger/0 — a purpose disjoint from your wallet's transaction key, so a compromised relay never yields tx-signing power. - Nothing is stored on disk beyond the vault; close the panel and the identity is dropped.

+ Message history is stored locally on this device, encrypted at rest.

This is a proof build. Use a wallet you don't hold funds in.
+
-
no messages yet — subscribed to inbox…
-
-
- - - +
+
+
Chats update live as messages arrive.
+ +
+ +
+
Pick a contact on the left — or hit + to add one.
+
+ +
+
+ + +