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
    <userData>/hermes/<pkHex>/inbox.jsonl (append-only JSONL, replayed on
    init). Read-state at <userData>/hermes/<pkHex>/read.json so unread
    badges survive restarts.
  - Address book at <userData>/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.
This commit is contained in:
Local Dev 2026-10-20 23:21:34 +03:00
parent 4e865a6286
commit a3b20a2446
3 changed files with 648 additions and 115 deletions

255
main.js
View file

@ -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:
// <userData>/hermes/contacts.json — address book (user-trusted)
// <userData>/hermes/<pkHex>/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<url, { ws, ready }> }
@ -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

View file

@ -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);

View file

@ -5,32 +5,27 @@
<title>Messages — Theseus</title>
<style>
:root{
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --panel3:#1d2634;
--line:rgba(255,255,255,.09); --line2:rgba(255,255,255,.14);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--ok:#4fd1a5; --err:#f6768a; --warn:#ffc75f;
}
@media (prefers-color-scheme: light) {
:root{
--bg:#f6f8fb; --panel:#ffffff; --panel2:#f0f4f9; --line:rgba(0,0,0,.10);
--ink:#1a1f28; --mut:#3c4453; --dim:#697280;
/* Darker acid for light backgrounds — ~5.5:1 on white. */
--acid: #0AC18E;
}
--me:#2a3a1e; /* outgoing bubble */
--them:#1d2634; /* incoming bubble */
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; background:var(--bg); color:var(--ink);
font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
display:flex; flex-direction:column;
display:flex; flex-direction:column; overflow:hidden;
}
header{
padding:12px 16px; border-bottom:1px solid var(--line);
padding:10px 14px; border-bottom:1px solid var(--line);
background:rgba(11,14,20,.9); display:flex; align-items:center; gap:12px;
flex-wrap:wrap;
}
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.2px}
header .mark{font-size:20px}
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.2px}
#ident{
color:var(--mut); font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
font-size:12px; margin-left:auto; user-select:text;
@ -43,50 +38,120 @@
#status.ok{color:var(--ok); background:rgba(79,209,165,.12)}
#status.err{color:var(--err); background:rgba(246,118,138,.12)}
main{display:grid; grid-template-columns:1fr; gap:0; flex:1; min-height:0}
#setup, #app{padding:16px; overflow:auto}
#setup{display:none; max-width:520px; margin:auto; align-self:center}
#app{display:none; grid-template-rows:1fr auto; gap:12px}
main{flex:1; min-height:0}
#setup, #app{padding:0; overflow:hidden; height:100%; display:none}
#setup{display:none; padding:16px; overflow:auto}
body.mode-setup #setup{display:block}
body.mode-app #app{display:grid}
#inbox{
overflow:auto; background:var(--panel); border:1px solid var(--line);
border-radius:10px; padding:8px;
}
.msg{padding:8px 10px; border-radius:8px; margin-bottom:6px; background:var(--panel2)}
.msg .from{font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; color:var(--mut); margin-bottom:3px}
.msg .from b{color:var(--acid); font-weight:500}
.msg .body{white-space:pre-wrap; word-break:break-word}
.msg .time{float:right; color:var(--dim); font-size:11px}
.empty{color:var(--dim); text-align:center; padding:24px 0}
#compose{display:grid; grid-template-columns:200px 1fr auto; gap:8px}
input, textarea, button{
font:inherit; color:var(--ink); background:var(--panel);
border:1px solid var(--line); border-radius:8px; padding:10px 12px;
}
textarea{resize:vertical; min-height:44px; max-height:200px}
input:focus, textarea:focus{outline:none; border-color:var(--acid)}
button{cursor:pointer; background:var(--acid); color:#0a0d13; font-weight:600; border-color:transparent}
button:hover{filter:brightness(1.05)}
button:disabled{opacity:.5; cursor:default}
body.mode-app #app{display:grid; grid-template-columns:260px 1fr}
/* ---------- SETUP ---------- */
#setup{max-width:540px; margin:0 auto; align-self:start}
.lede{color:var(--mut); font-size:13px; margin:0 0 12px}
#setup label{display:block; margin-bottom:12px; color:var(--mut); font-size:12px}
#setup textarea{width:100%; margin-top:6px; min-height:80px}
.row{display:flex; gap:8px; align-items:center; margin-top:8px}
#setupErr{color:var(--err); font-size:12px; min-height:1em; margin-top:8px}
#sendErr{color:var(--err); font-size:12px; min-height:1em; margin-top:4px}
.warn{
font-size:12px; color:var(--warn); padding:8px 10px;
background:rgba(255,199,95,.08); border:1px solid rgba(255,199,95,.24);
border-radius:8px; margin-bottom:12px;
}
.row{display:flex; gap:8px; align-items:center}
code.inline{
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
background:rgba(255,255,255,.05); padding:1px 5px; border-radius:4px; font-size:12px;
}
input, textarea, button, select{
font:inherit; color:var(--ink); background:var(--panel);
border:1px solid var(--line); border-radius:8px; padding:9px 11px;
}
textarea{resize:vertical; min-height:44px; max-height:200px; width:100%}
input{width:100%}
input:focus, textarea:focus, select:focus{outline:none; border-color:var(--acid)}
button{cursor:pointer; background:var(--acid); color:#0a0d13; font-weight:600; border-color:transparent}
button.ghost{background:var(--panel); color:var(--ink); border:1px solid var(--line); font-weight:400}
button:hover{filter:brightness(1.08)}
button:disabled{opacity:.5; cursor:default}
label{display:block; margin-bottom:12px; color:var(--mut); font-size:12px}
label > textarea, label > input{margin-top:6px}
#setupErr{color:var(--err); font-size:12px; min-height:1em; margin-top:8px}
/* ---------- APP (threaded) ---------- */
#sidebar{
display:flex; flex-direction:column; min-height:0;
background:var(--panel); border-right:1px solid var(--line);
}
#sideHead{
padding:10px 10px 6px; display:flex; gap:6px; align-items:center;
border-bottom:1px solid var(--line);
}
#sideHead input{padding:7px 9px; font-size:13px}
#addBtn{padding:7px 10px; font-size:13px}
#contactsList{overflow:auto; flex:1; min-height:0}
.contactRow{
padding:9px 12px; cursor:pointer; border-bottom:1px solid rgba(255,255,255,.03);
display:flex; align-items:center; gap:8px;
}
.contactRow:hover{background:var(--panel2)}
.contactRow.active{background:var(--panel3)}
.contactRow .avatar{
width:30px; height:30px; border-radius:50%; flex-shrink:0;
background:linear-gradient(135deg, #2a3a4a, #1a2432);
display:flex; align-items:center; justify-content:center;
font-size:12px; color:var(--mut); font-weight:600; text-transform:uppercase;
}
.contactRow .meta{min-width:0; flex:1}
.contactRow .name{
font-size:13px; font-weight:500; color:var(--ink);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.contactRow .last{
font-size:11px; color:var(--mut);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.contactRow .unread{
background:var(--acid); color:#0a0d13; font-size:10px; font-weight:700;
padding:1px 6px; border-radius:999px; min-width:18px; text-align:center;
}
#sidebarFoot{padding:8px 10px; border-top:1px solid var(--line); font-size:11px; color:var(--dim)}
#thread{display:flex; flex-direction:column; min-height:0; background:var(--bg)}
#threadHead{
padding:12px 16px; border-bottom:1px solid var(--line);
display:flex; align-items:center; gap:12px;
}
#threadHead .name{font-weight:600; font-size:14px}
#threadHead .pk{font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; color:var(--mut)}
#threadHead .trash{
margin-left:auto; background:none; border:none; color:var(--dim); cursor:pointer;
padding:4px 8px; font-size:12px;
}
#threadHead .trash:hover{color:var(--err)}
#threadBody{
flex:1; overflow:auto; padding:12px 16px;
display:flex; flex-direction:column; gap:6px;
}
.bubble{max-width:70%; padding:7px 11px; border-radius:14px; line-height:1.4}
.bubble .body{white-space:pre-wrap; word-break:break-word}
.bubble .time{display:block; font-size:10px; color:var(--dim); margin-top:3px}
.bubble.in{background:var(--them); align-self:flex-start; border-bottom-left-radius:4px}
.bubble.out{background:var(--me); align-self:flex-end; border-bottom-right-radius:4px}
.empty{color:var(--dim); text-align:center; padding:32px 16px; font-size:13px}
#composeRow{display:flex; gap:8px; padding:10px 14px; border-top:1px solid var(--line); background:var(--panel)}
#composeRow textarea{flex:1; min-height:38px; max-height:160px; resize:none}
#sendBtn{padding:0 18px}
#sendStatus{font-size:11px; color:var(--mut); padding:0 14px 6px; min-height:12px}
/* ---------- Add contact / new thread modal ---------- */
.modal-bg{
position:fixed; inset:0; background:rgba(0,0,0,.5);
display:none; align-items:center; justify-content:center; z-index:30;
}
.modal-bg.show{display:flex}
.modal{
width:380px; max-width:90vw; background:var(--panel); border:1px solid var(--line2);
border-radius:12px; padding:16px;
}
.modal h2{margin:0 0 10px; font-size:14px}
.modal .buttons{display:flex; gap:8px; justify-content:flex-end; margin-top:10px}
.modal label{margin-bottom:10px}
#addErr{color:var(--err); font-size:12px; min-height:1em}
</style>
</head>
<body class="mode-setup">
@ -100,11 +165,12 @@
<main>
<!-- SETUP SCREEN -->
<section id="setup">
<p class="lede">Hermes derives its Nostr identity at
<code class="inline">silentmode/messenger/0</code> — 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.</p>
Message history is stored locally on this device, encrypted at rest.</p>
<div class="warn">This is a proof build. Use a wallet you don't hold funds in.</div>
<div id="vaultBind" style="display:none; margin-bottom:16px">
@ -125,115 +191,324 @@
<div id="setupErr"></div>
</section>
<!-- APP (threaded) -->
<section id="app">
<div id="inbox"><div class="empty">no messages yet — subscribed to inbox…</div></div>
<div>
<div id="compose">
<input id="to" placeholder="to (e.g. alice.bch or 64-char hex pk)" autocomplete="off">
<textarea id="text" placeholder="message" rows="1"></textarea>
<button id="btnSend">Send</button>
<aside id="sidebar">
<div id="sideHead">
<input id="search" placeholder="Search contacts…" autocomplete="off">
<button id="addBtn" title="Add contact / start new chat">+</button>
</div>
<div id="sendErr"></div>
</div>
<div id="contactsList"></div>
<div id="sidebarFoot">Chats update live as messages arrive.</div>
</aside>
<section id="thread">
<div id="threadHead" style="display:none">
<div>
<div class="name" id="threadName">—</div>
<div class="pk" id="threadPk">—</div>
</div>
<button class="trash" id="btnRemoveContact" title="Remove this contact">remove</button>
</div>
<div id="threadBody">
<div class="empty">Pick a contact on the left — or hit <b>+</b> to add one.</div>
</div>
<div id="composeRow" style="display:none">
<textarea id="composeText" placeholder="Message…" rows="1"></textarea>
<button id="sendBtn">Send</button>
</div>
<div id="sendStatus"></div>
</section>
</section>
</main>
<!-- Add contact modal -->
<div class="modal-bg" id="addModal">
<div class="modal">
<h2>Add contact</h2>
<label>
Name (.bch), npub, or 64-char hex pubkey
<input id="addName" placeholder="alice.bch" autocomplete="off">
</label>
<label>
Nickname (optional)
<input id="addNick" placeholder="Alice" autocomplete="off">
</label>
<div id="addErr"></div>
<div class="buttons">
<button class="ghost" id="addCancel">Cancel</button>
<button id="addSave">Add</button>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const setMode = (m) => { document.body.className = "mode-" + m; };
const setStatus = (text, cls = "") => {
const el = $("status");
el.textContent = text;
el.className = cls;
const el = $("status"); el.textContent = text; el.className = cls;
};
const shortPk = (pk) => pk ? pk.slice(0, 8) + "…" + pk.slice(-4) : "";
// Hermes messaging is a .bch-first product — the default TLD is noise in the
// chat list. Strip it for display; other TLDs stay (they disambiguate).
// Hermes is a .bch-first product — the TLD is noise in a chat list.
const displayName = (name) => name && name.endsWith(".bch") ? name.slice(0, -4) : name;
const initials = (n) => {
if (!n) return "?";
const s = String(n).replace(/\.bch$/,"").trim();
return s.slice(0, 2);
};
const fmtTime = (ts) => {
if (!ts) return "";
const d = new Date(ts * 1000);
const sameDay = d.toDateString() === new Date().toDateString();
if (sameDay) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return d.toLocaleDateString([], { month: "short", day: "numeric" }) + " "
+ d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
async function refreshInbox() {
const res = await hermes.inbox();
if (!res.ok) return;
const box = $("inbox");
if (!res.messages.length) {
box.innerHTML = '<div class="empty">no messages yet — subscribed to inbox…</div>';
return;
// ---- State mirror of the main-side inbox ----
// Grouped by peerPkHex so the UI can be contact-centric.
let state = {
messages: [], // raw list, in arrival order
readState: {}, // peerPkHex -> latest createdAt the user has acknowledged
contacts: [], // address book from main
myPkHex: null,
selectedPk: null, // currently open thread
};
function threadsFromMessages() {
// Group messages by the OTHER party's pkHex. Each thread carries:
// { peerPkHex, peerName, messages: [...], lastAt, unreadCount }
const byPk = new Map();
for (const m of state.messages) {
const peerPk = (m.peerPkHex || m.senderPkHex || "").toLowerCase();
if (!peerPk) continue;
let t = byPk.get(peerPk);
if (!t) {
t = { peerPkHex: peerPk, peerName: null, messages: [], lastAt: 0, unreadCount: 0 };
byPk.set(peerPk, t);
}
// Prefer a non-null name from any message in this thread.
if (!t.peerName && m.peerName) t.peerName = m.peerName;
t.messages.push(m);
if (m.createdAt > t.lastAt) t.lastAt = m.createdAt;
}
box.innerHTML = "";
for (const m of res.messages) {
const el = document.createElement("div");
el.className = "msg";
const from = m.senderName ? `<b>${displayName(m.senderName)}</b> · ${shortPk(m.senderPkHex)}` : shortPk(m.senderPkHex);
const when = new Date(m.createdAt * 1000).toLocaleTimeString();
el.innerHTML = `<div class="from">${from}<span class="time">${when}</span></div>` +
`<div class="body"></div>`;
el.querySelector(".body").textContent = m.text; // never innerHTML for user content
box.appendChild(el);
// Union with contacts so an empty thread (just added) still shows up.
for (const c of state.contacts) {
const k = (c.pkHex || "").toLowerCase();
if (!k) continue;
if (!byPk.has(k)) byPk.set(k, { peerPkHex: k, peerName: c.nickname || c.name, messages: [], lastAt: 0, unreadCount: 0 });
else if (!byPk.get(k).peerName) byPk.get(k).peerName = c.nickname || c.name;
}
box.scrollTop = box.scrollHeight;
// Unread = incoming messages newer than the readState for that peer.
for (const t of byPk.values()) {
const read = state.readState[t.peerPkHex] || 0;
t.unreadCount = t.messages.filter((m) => m.direction === "in" && m.createdAt > read).length;
}
return [...byPk.values()].sort((a, b) => b.lastAt - a.lastAt);
}
function renderContacts() {
const list = $("contactsList");
const q = $("search").value.trim().toLowerCase();
const threads = threadsFromMessages();
const filtered = q
? threads.filter((t) => (t.peerName || "").toLowerCase().includes(q)
|| t.peerPkHex.includes(q))
: threads;
if (!filtered.length) {
list.innerHTML = '<div class="empty">No chats yet.<br>Hit <b>+</b> to start one.</div>';
return;
}
list.innerHTML = "";
for (const t of filtered) {
const row = document.createElement("div");
row.className = "contactRow" + (t.peerPkHex === state.selectedPk ? " active" : "");
const label = displayName(t.peerName) || shortPk(t.peerPkHex);
const lastMsg = t.messages[t.messages.length - 1];
const lastText = lastMsg
? (lastMsg.direction === "out" ? "you: " : "") + lastMsg.text
: "no messages yet";
row.innerHTML = `
<div class="avatar"></div>
<div class="meta">
<div class="name"></div>
<div class="last"></div>
</div>
${t.unreadCount > 0 ? `<span class="unread">${t.unreadCount}</span>` : ""}
`;
row.querySelector(".avatar").textContent = initials(label);
row.querySelector(".name").textContent = label;
row.querySelector(".last").textContent = lastText;
row.addEventListener("click", () => selectThread(t.peerPkHex));
list.appendChild(row);
}
}
function renderThread() {
const body = $("threadBody");
const head = $("threadHead");
const compose = $("composeRow");
if (!state.selectedPk) {
head.style.display = "none";
compose.style.display = "none";
body.innerHTML = '<div class="empty">Pick a contact on the left — or hit <b>+</b> to add one.</div>';
return;
}
const threads = threadsFromMessages();
const t = threads.find((x) => x.peerPkHex === state.selectedPk);
const label = (t && displayName(t.peerName)) || shortPk(state.selectedPk);
head.style.display = "flex";
$("threadName").textContent = label;
$("threadPk").textContent = state.selectedPk;
compose.style.display = "flex";
if (!t || !t.messages.length) {
body.innerHTML = '<div class="empty">No messages yet. Say hi.</div>';
return;
}
body.innerHTML = "";
for (const m of t.messages) {
const b = document.createElement("div");
b.className = "bubble " + (m.direction === "out" ? "out" : "in");
const txt = document.createElement("div");
txt.className = "body";
txt.textContent = m.text; // never innerHTML for untrusted content
const t2 = document.createElement("span");
t2.className = "time";
t2.textContent = fmtTime(m.createdAt);
b.appendChild(txt); b.appendChild(t2);
body.appendChild(b);
}
body.scrollTop = body.scrollHeight;
// Mark this thread read up to the latest message, if any incoming is newer.
const latestIn = Math.max(0, ...t.messages.filter((m) => m.direction === "in").map((m) => m.createdAt));
const curRead = state.readState[state.selectedPk] || 0;
if (latestIn > curRead) {
state.readState[state.selectedPk] = latestIn;
hermes.markThreadRead(state.selectedPk, latestIn).catch(() => {});
renderContacts(); // refresh unread badges
}
}
function selectThread(pkHex) {
state.selectedPk = pkHex.toLowerCase();
renderContacts();
renderThread();
}
async function reloadAll() {
const [ibx, cs] = await Promise.all([hermes.inbox(), hermes.contacts.list()]);
if (ibx && ibx.ok) {
state.messages = ibx.messages || [];
state.readState = ibx.readState || {};
}
if (cs && cs.ok) state.contacts = cs.contacts || [];
renderContacts();
renderThread();
}
// ---- Setup flow ----
function afterInit(res) {
if (!res.ok) { $("setupErr").textContent = "error: " + res.err; return false; }
state.myPkHex = res.pkHex;
$("ident").innerHTML = "your npub: <b>" + res.npub.slice(0, 12) + "…" + res.npub.slice(-6) + "</b>"
+ (res.source ? " <span style='color:var(--dim); font-size:11px'>(" + res.source + ")</span>" : "");
setStatus(res.relaysConnected + "/" + res.relaysTotal + " relays",
res.relaysConnected > 0 ? "ok" : "");
setMode("app");
refreshInbox();
reloadAll();
return true;
}
$("btnInit").addEventListener("click", async () => {
const mnemonic = $("mnemonic").value.trim();
if (!mnemonic) { $("setupErr").textContent = "enter a mnemonic"; return; }
$("btnInit").disabled = true;
$("setupErr").textContent = "";
const res = await hermes.init(mnemonic);
const mn = $("mnemonic").value.trim();
if (!mn) { $("setupErr").textContent = "enter a mnemonic"; return; }
$("btnInit").disabled = true; $("setupErr").textContent = "";
const res = await hermes.init(mn);
$("btnInit").disabled = false;
afterInit(res);
});
$("btnVault").addEventListener("click", async () => {
$("btnVault").disabled = true;
$("setupErr").textContent = "";
$("btnVault").disabled = true; $("setupErr").textContent = "";
const res = await hermes.initFromVault();
$("btnVault").disabled = false;
afterInit(res);
});
$("btnSend").addEventListener("click", async () => {
const to = $("to").value.trim();
const text = $("text").value.trim();
if (!to || !text) { $("sendErr").textContent = "to and message required"; return; }
$("btnSend").disabled = true;
$("sendErr").textContent = "";
const res = await hermes.send(to, text);
$("btnSend").disabled = false;
if (!res.ok) { $("sendErr").textContent = "error: " + res.err; return; }
$("sendErr").textContent = "sent to " + res.accepted + "/" + res.total + " relay(s) — recipient pk " + shortPk(res.recipientPkHex);
$("text").value = "";
// ---- App events ----
$("search").addEventListener("input", renderContacts);
$("addBtn").addEventListener("click", () => {
$("addName").value = ""; $("addNick").value = ""; $("addErr").textContent = "";
$("addModal").classList.add("show"); $("addName").focus();
});
$("addCancel").addEventListener("click", () => $("addModal").classList.remove("show"));
$("addSave").addEventListener("click", async () => {
const name = $("addName").value.trim();
const nick = $("addNick").value.trim() || null;
if (!name) { $("addErr").textContent = "enter a name or key"; return; }
$("addSave").disabled = true; $("addErr").textContent = "";
const res = await hermes.contacts.add(name, nick);
$("addSave").disabled = false;
if (!res.ok) { $("addErr").textContent = res.err; return; }
$("addModal").classList.remove("show");
state.contacts = res.contacts || [];
renderContacts();
if (res.contact && res.contact.pkHex) selectThread(res.contact.pkHex);
});
hermes.onMessage(() => refreshInbox());
$("btnRemoveContact").addEventListener("click", async () => {
if (!state.selectedPk) return;
if (!confirm("Remove this contact? Message history for this thread stays on disk.")) return;
await hermes.contacts.remove(state.selectedPk);
state.selectedPk = null;
await reloadAll();
});
$("composeText").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendCurrent(); }
});
$("sendBtn").addEventListener("click", sendCurrent);
async function sendCurrent() {
const text = $("composeText").value.trim();
if (!text || !state.selectedPk) return;
$("sendBtn").disabled = true;
$("sendStatus").textContent = "sending…";
// Send by pkHex (direct) — recipient is already known for an existing thread.
const res = await hermes.send(state.selectedPk, text);
$("sendBtn").disabled = false;
if (!res.ok) {
$("sendStatus").textContent = "error: " + res.err;
return;
}
$("sendStatus").textContent = `sent to ${res.accepted}/${res.total} relay(s)`;
$("composeText").value = "";
// The main-side send handler records the outgoing message via hermesRecord,
// which pushes a "hermes-message" to us — that triggers reloadAll + rerender.
setTimeout(() => { $("sendStatus").textContent = ""; }, 3000);
}
// ---- Pushes from main ----
hermes.onMessage(() => reloadAll());
hermes.onStatus((s) => {
setStatus(s.relaysConnected + "/" + s.relaysTotal + " relays",
s.relaysConnected > 0 ? "ok" : "err");
s.relaysConnected > 0 ? "ok" : "");
});
// Query status on load: maybe already initialised from a previous open.
// ---- Boot ----
hermes.status().then(async (r) => {
if (r && r.ok && r.ready) {
state.myPkHex = r.pkHex;
$("ident").innerHTML = "your npub: <b>" + r.npub.slice(0, 12) + "…" + r.npub.slice(-6) + "</b>";
setStatus(r.relaysConnected + "/" + r.relaysTotal + " relays", "ok");
setMode("app");
refreshInbox();
reloadAll();
return;
}
setStatus("locked", "");
// Show the vault sign-in shortcut iff the vault is currently unlocked AND
// was set up from a mnemonic (so it carries the messenger root).
setStatus("locked");
const v = await hermes.canUseVault();
if (v && v.ok && v.available) $("vaultBind").style.display = "block";
});