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:
parent
4e865a6286
commit
a3b20a2446
3 changed files with 648 additions and 115 deletions
255
main.js
255
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:
|
||||
// <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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
479
messages.html
479
messages.html
|
|
@ -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";
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue