hermes: in-panel messenger with Theseus ID signin, spread-wings mark
The sidebar panel used to be a launcher that opened the standalone Messages window; now it IS the messenger — signin screen with "Open with Theseus ID" (derives the Nostr key from the unlocked vault seed) or "Open an existing account" (12/24-word mnemonic fallback), then a contacts + thread view with a narrow-width single-column fallback. The ⧉ button still pops the standalone window out, so the Ctrl+Shift+E flow keeps working. - main.js: expose a global._hermesAPI with status/canUseVault/init/inbox/ send/markThreadRead/contacts* so the addon can proxy panel calls without granting it ipcRenderer or crossing the HERMES_ONLY sender gate. hermesEmit also fans out on an in-process __hermes_bus so the panel gets live inbox/status pushes decoupled from hermesWin's lifetime. - bundled-addons/hermes/index.js: proxies each allow-listed method onto global._hermesAPI, forwards bus events to api.emit for the panel. - panel.html: signin + app screens, mobile-style single column under 540px. - Mark updated to the spread-wings variant: envelope sized up (34×22), three tapered trails extending outside the viewBox so the wings don't bunch at the back of the envelope at any size. Applied to the addon icon (manifest base64), the panel header, both /hermes sites, and site-hermes-x's favicon + mark SVGs.
This commit is contained in:
parent
227d013013
commit
8777b1fc56
4 changed files with 800 additions and 60 deletions
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"id": "hermes",
|
||||
"name": "Hermes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Message anyone by their .bch name. End-to-end encrypted DMs over Nostr (NIP-17), identity derived from your wallet seed.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIyIDMyIDMyKSI+PHJlY3QgeD0iMjAiIHk9IjI2IiB3aWR0aD0iMzAiIGhlaWdodD0iMTgiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMjAgMjcgTDM1IDM3IEw1MCAyNyIvPjxwYXRoIGQ9Ik0yMCAzNiBMNCAzNiIvPjwvZz48L3N2Zz4K",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIwIDMyIDMyKSI+PHJlY3QgeD0iMTgiIHk9IjI0IiB3aWR0aD0iMzQiIGhlaWdodD0iMjIiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMTggMjUgTDM1IDM5IEw1MiAyNSIvPjxwYXRoIGQ9Ik0xOCAyOCBMLTQgMjQiLz48cGF0aCBkPSJNMTggMzYgTC02IDM2Ii8+PHBhdGggZD0iTTE4IDQ0IEwwIDQ2Ii8+PC9nPjwvc3ZnPg==",
|
||||
"main": "index.js",
|
||||
"activation": "on-demand",
|
||||
"panels": [
|
||||
{ "id": "main", "title": "Hermes", "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIyIDMyIDMyKSI+PHJlY3QgeD0iMjAiIHk9IjI2IiB3aWR0aD0iMzAiIGhlaWdodD0iMTgiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMjAgMjcgTDM1IDM3IEw1MCAyNyIvPjxwYXRoIGQ9Ik0yMCAzNiBMNCAzNiIvPjwvZz48L3N2Zz4K", "page": "panel.html", "side": "left" }
|
||||
{ "id": "main", "title": "Hermes", "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIwIDMyIDMyKSI+PHJlY3QgeD0iMTgiIHk9IjI0IiB3aWR0aD0iMzQiIGhlaWdodD0iMjIiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMTggMjUgTDM1IDM5IEw1MiAyNSIvPjxwYXRoIGQ9Ik0xOCAyOCBMLTQgMjQiLz48cGF0aCBkPSJNMTggMzYgTC02IDM2Ii8+PHBhdGggZD0iTTE4IDQ0IEwwIDQ2Ii8+PC9nPjwvc3ZnPg==", "page": "panel.html", "side": "left" }
|
||||
],
|
||||
"capabilities": ["sidebar-panel"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
// Hermes bundled add-on — thin launcher.
|
||||
// Hermes bundled add-on — thin proxy to the host's panel API.
|
||||
//
|
||||
// Real messaging code (NIP-17 wrap/unwrap, relay subscriptions, persistent
|
||||
// inbox, contacts) lives in Theseus main.js: too much raw network + crypto to
|
||||
// run under the add-on sandbox. This add-on exists for ONE reason: register a
|
||||
// left-strip sidebar entry so the Messages panel is discoverable without
|
||||
// pressing Ctrl+Shift+E.
|
||||
// All the real messaging code (NIP-17 wrap/unwrap, relay subscriptions,
|
||||
// persistent inbox, contacts) lives in Theseus main.js. Too much raw network
|
||||
// + crypto to run under the add-on sandbox, and the vault seed must never
|
||||
// cross the host/add-on boundary.
|
||||
//
|
||||
// The panel (panel.html) is a small chrome: mark, "Open Messages" button,
|
||||
// shortcut hint. Click → forwarded through api.onMessage → an in-process
|
||||
// ipcMain.emit that main.js listens for and resolves to openHermesWindow().
|
||||
// Keeping the forwarding tiny: nothing crypto-sensitive flows through this
|
||||
// add-on, so a compromise of this one-handler surface can at worst open an
|
||||
// otherwise-openable window.
|
||||
// This file does two things:
|
||||
// 1. Register the left-strip sidebar entry (so Hermes is discoverable
|
||||
// without pressing Ctrl+Shift+E).
|
||||
// 2. Proxy a small, allow-listed set of panel operations between the
|
||||
// panel's silentmode.invoke() calls and main.js's `global._hermesAPI`.
|
||||
// The panel never sees ipcRenderer — only this named handler surface.
|
||||
//
|
||||
// Events coming the other way (inbox updates, relay status changes) are
|
||||
// fanned out via a shared ipcMain event bus `__hermes_bus`: main.js's
|
||||
// hermesBusEmit publishes, this file forwards each payload to the panel.
|
||||
const { ipcMain } = require("electron");
|
||||
|
||||
const PANEL_METHODS = [
|
||||
"status", "canUseVault", "close", "init", "inbox", "send",
|
||||
"markThreadRead", "contactsList", "contactsAdd", "contactsUpdate",
|
||||
"contactsRemove",
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
activate(api) {
|
||||
api.registerSidebarPanel({
|
||||
|
|
@ -26,13 +35,47 @@ module.exports = {
|
|||
// declaring it in both keeps dormant vs active renders consistent.
|
||||
side: "left",
|
||||
});
|
||||
|
||||
// Legacy launcher behavior: panel.html can still ask to open the
|
||||
// standalone Messages window. Kept because Ctrl+Shift+E users have
|
||||
// muscle memory, and because a few flows (debug, multi-window)
|
||||
// benefit from a free-floating window.
|
||||
api.onMessage("open-messages", () => {
|
||||
// In-process emit: main.js listens on "hermes-open-request" and calls
|
||||
// openHermesWindow(). Using emit (not .send) because this is a
|
||||
// same-process signal, not an IPC to a renderer.
|
||||
ipcMain.emit("hermes-open-request");
|
||||
return { ok: true };
|
||||
});
|
||||
api.log("registered hermes sidebar panel");
|
||||
|
||||
// Panel API proxies. Every method listed in PANEL_METHODS invokes the
|
||||
// same-named function on global._hermesAPI. The main.js side defines
|
||||
// that object once hermes is initialised; before that, status/canUseVault
|
||||
// are the only safe methods and will return a stub-ok.
|
||||
for (const name of PANEL_METHODS) {
|
||||
api.onMessage(name, async (payload) => {
|
||||
const hermes = global._hermesAPI;
|
||||
if (!hermes || typeof hermes[name] !== "function") {
|
||||
// Host not ready. Treat status/canUseVault as "no identity yet"
|
||||
// so the panel can render its signin screen without a toast.
|
||||
if (name === "status") return { ok: true, ready: false };
|
||||
if (name === "canUseVault") return { ok: true, available: false };
|
||||
return { ok: false, error: "hermes host not ready" };
|
||||
}
|
||||
return await hermes[name](payload);
|
||||
});
|
||||
}
|
||||
|
||||
// Main-side → panel event bus. hermesBusEmit in main.js fires events on
|
||||
// ipcMain under __hermes_bus; forward each to the panel via api.emit so
|
||||
// panel-side `silentmode.on("hermes-message", cb)` works identically to
|
||||
// the ipcRenderer path used by the standalone window.
|
||||
const busForward = (channel, payload) => {
|
||||
try { api.emit(channel, payload); } catch {}
|
||||
};
|
||||
ipcMain.on("__hermes_bus", busForward);
|
||||
|
||||
api.onDeactivate && api.onDeactivate(() => {
|
||||
try { ipcMain.off("__hermes_bus", busForward); } catch {}
|
||||
});
|
||||
|
||||
api.log("hermes sidebar panel registered (proxy mode)");
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,63 +5,593 @@
|
|||
<title>Hermes</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||||
--amber:#f4c430; }
|
||||
--bg:#0e131c; --panel:#141a24; --panel-2:#1a2130; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678;
|
||||
--sky:#38BDF8; --sky-ink:#06111a; --amber:#f4c430;
|
||||
--err:#f6768a; --ok:#4fd1a5;
|
||||
--bubble-me: #0c3f55; --bubble-them: #1a2130; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --panel-2:#eef2fb; --line:rgba(0,0,0,.09);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
|
||||
--acid:#088A66; }
|
||||
--sky:#0891B2; --sky-ink:#ffffff;
|
||||
--bubble-me:#dceefa; --bubble-them:#eef1f5; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
html, body { margin:0; height:100%; }
|
||||
body { background: var(--bg); color: var(--ink);
|
||||
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 24px 20px; text-align: center; gap: 14px; }
|
||||
.mark { color: var(--acid); line-height: 0; }
|
||||
.mark svg { width: 72px; height: 72px; display: block; }
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: .3px; }
|
||||
.lede { color: var(--mut); font-size: 13px; margin: 0; max-width: 260px; }
|
||||
button { font: inherit; font-weight: 600; cursor: pointer;
|
||||
background: var(--acid); color: #0a0d13; border: none;
|
||||
padding: 10px 20px; border-radius: 999px; }
|
||||
button:hover { filter: brightness(1.08); }
|
||||
.shortcut { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
|
||||
.shortcut kbd { background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
|
||||
font-size: 10.5px; color: var(--ink); }
|
||||
.err { color: #f6768a; font-size: 12px; min-height: 1em; }
|
||||
font: 13.5px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
overflow: hidden; }
|
||||
|
||||
/* Top bar */
|
||||
.bar { display:flex; align-items:center; gap:10px;
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--line);
|
||||
background: var(--panel); min-height: 44px; }
|
||||
.bar .mk { color: var(--sky); line-height:0; }
|
||||
.bar .mk svg { width: 24px; height: 24px; display:block; }
|
||||
.bar .title { font-weight: 600; font-size: 13.5px; }
|
||||
.bar .spc { flex: 1; }
|
||||
.bar .status { font-size: 11.5px; color: var(--mut); display:flex; align-items:center; gap:6px; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; background: var(--dim); }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--amber); }
|
||||
.bar .ic { background: transparent; border: 1px solid var(--line);
|
||||
color: var(--mut); font: inherit; cursor: pointer;
|
||||
width: 26px; height: 26px; border-radius: 6px; display:grid; place-items:center; }
|
||||
.bar .ic:hover { color: var(--ink); border-color: var(--mut); }
|
||||
|
||||
/* Screens */
|
||||
.screen { position: absolute; inset: 44px 0 0 0; display: none; flex-direction: column; }
|
||||
.screen.active { display: flex; }
|
||||
|
||||
/* === signin screen === */
|
||||
#signin { padding: 24px 20px; align-items: center; justify-content: center; text-align: center; gap: 14px; }
|
||||
#signin .mk { color: var(--sky); line-height:0; }
|
||||
#signin .mk svg { width: 72px; height: 72px; display:block; }
|
||||
#signin h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
#signin .lede { color: var(--mut); font-size: 13px; margin: 0; max-width: 300px; line-height: 1.5; }
|
||||
#signin .actions { display:flex; flex-direction: column; gap: 10px; width: 100%; max-width: 300px; margin-top: 6px; }
|
||||
#signin button.primary { background: var(--sky); color: var(--sky-ink); border: none;
|
||||
font: inherit; font-weight: 600; padding: 11px 18px;
|
||||
border-radius: 10px; cursor: pointer; }
|
||||
#signin button.primary:hover { filter: brightness(1.08); }
|
||||
#signin button.primary[disabled] { opacity: .5; cursor: not-allowed; }
|
||||
#signin button.secondary { background: transparent; color: var(--ink);
|
||||
border: 1px solid var(--line); font: inherit;
|
||||
padding: 10px 18px; border-radius: 10px; cursor: pointer; }
|
||||
#signin button.secondary:hover { border-color: var(--mut); }
|
||||
#signin .mnemonic { width: 100%; max-width: 300px; display: none; flex-direction: column; gap: 8px; }
|
||||
#signin .mnemonic.show { display: flex; }
|
||||
#signin textarea { font: inherit; font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px; background: var(--panel); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: 10px; resize: vertical; min-height: 86px; width: 100%; }
|
||||
#signin .err { color: var(--err); font-size: 12px; min-height: 1em; }
|
||||
#signin .hint { color: var(--dim); font-size: 11px; }
|
||||
|
||||
/* === app screen (contacts + thread) === */
|
||||
#app { display: none; flex-direction: column; }
|
||||
#app.active { display: flex; }
|
||||
#app .cols { display: grid; grid-template-columns: 220px 1fr; height: 100%;
|
||||
min-height: 0; }
|
||||
@media (max-width: 540px) {
|
||||
#app .cols { grid-template-columns: 1fr; }
|
||||
#app.show-thread .contacts-pane { display: none; }
|
||||
#app:not(.show-thread) .thread-pane { display: none; }
|
||||
}
|
||||
@media (min-width: 541px) { #app.show-thread .contacts-pane { display: flex; } }
|
||||
|
||||
.contacts-pane { display:flex; flex-direction: column; min-height: 0;
|
||||
border-right: 1px solid var(--line); background: var(--panel); }
|
||||
.contacts-head { padding: 10px 10px 8px; display:flex; gap: 6px; }
|
||||
.contacts-head input { flex: 1; min-width: 0; font: inherit; font-size: 12.5px;
|
||||
background: var(--panel-2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 6px 10px; }
|
||||
.contacts-head button { font: inherit; font-size: 16px; cursor: pointer;
|
||||
background: var(--panel-2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 999px;
|
||||
width: 30px; height: 30px; }
|
||||
.contacts-head button:hover { border-color: var(--sky); color: var(--sky); }
|
||||
.contacts-list { flex: 1; overflow: auto; padding: 4px 0; }
|
||||
.contact { padding: 8px 12px; cursor: pointer; display:flex; align-items:center; gap:8px;
|
||||
border-left: 3px solid transparent; }
|
||||
.contact:hover { background: var(--panel-2); }
|
||||
.contact.active { background: var(--panel-2); border-left-color: var(--sky); }
|
||||
.contact .avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--panel-2);
|
||||
color: var(--sky); display:grid; place-items:center;
|
||||
font-size: 11.5px; font-weight: 600; flex: 0 0 auto; }
|
||||
.contact.active .avatar { background: var(--sky); color: var(--sky-ink); }
|
||||
.contact .meta { min-width: 0; flex: 1; }
|
||||
.contact .nm { font-size: 12.5px; font-weight: 600; overflow: hidden;
|
||||
white-space: nowrap; text-overflow: ellipsis; }
|
||||
.contact .sub { font-size: 11px; color: var(--mut); overflow: hidden;
|
||||
white-space: nowrap; text-overflow: ellipsis; }
|
||||
.contact .badge { background: var(--sky); color: var(--sky-ink);
|
||||
font-size: 10.5px; font-weight: 700; padding: 1px 7px;
|
||||
border-radius: 999px; min-width: 20px; text-align: center; }
|
||||
.empty { color: var(--mut); font-size: 12px; padding: 20px 14px; text-align: center; }
|
||||
|
||||
.thread-pane { display:flex; flex-direction: column; min-height: 0; background: var(--bg); }
|
||||
.thread-head { padding: 10px 12px; border-bottom: 1px solid var(--line);
|
||||
background: var(--panel); display:flex; align-items:center; gap: 10px; min-height: 44px; }
|
||||
.thread-head .back { display: none; background: transparent; border: 0; color: var(--mut);
|
||||
font: inherit; font-size: 18px; cursor: pointer; padding: 0 2px; }
|
||||
@media (max-width: 540px) { .thread-head .back { display: inline-block; } }
|
||||
.thread-head .nm { font-weight: 600; font-size: 13px; }
|
||||
.thread-head .pk { color: var(--dim); font-size: 11px; font-family: ui-monospace, monospace;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.thread { flex: 1; overflow: auto; padding: 14px 12px; display:flex; flex-direction: column; gap: 6px; }
|
||||
.msg { max-width: 86%; padding: 7px 11px; border-radius: 12px; font-size: 13px; word-wrap: break-word; }
|
||||
.msg.me { align-self: flex-end; background: var(--bubble-me); border-top-right-radius: 4px; }
|
||||
.msg.them { align-self: flex-start; background: var(--bubble-them); border-top-left-radius: 4px; }
|
||||
.msg .t { font-size: 10.5px; color: var(--mut); margin-top: 3px; }
|
||||
.compose { padding: 10px 12px; border-top: 1px solid var(--line); background: var(--panel);
|
||||
display:flex; gap: 8px; align-items: flex-end; }
|
||||
.compose textarea { flex: 1; font: inherit; font-size: 13px; min-height: 36px; max-height: 120px;
|
||||
background: var(--panel-2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 8px 10px; resize: none; outline: none; }
|
||||
.compose textarea:focus { border-color: var(--sky); }
|
||||
.compose button { background: var(--sky); color: var(--sky-ink); border: 0; font: inherit;
|
||||
font-weight: 600; padding: 8px 14px; border-radius: 10px; cursor: pointer; }
|
||||
.compose button[disabled] { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.thread-placeholder { flex: 1; display:grid; place-items:center; color: var(--mut);
|
||||
font-size: 12.5px; padding: 20px; text-align: center; }
|
||||
|
||||
/* add-contact modal */
|
||||
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
display: none; place-items: center; z-index: 100; padding: 12px; }
|
||||
.modal-bg.show { display: grid; }
|
||||
.modal { background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 16px; width: 100%; max-width: 320px; }
|
||||
.modal h3 { margin: 0 0 10px; font-size: 14px; }
|
||||
.modal input { font: inherit; font-size: 12.5px; width: 100%; padding: 8px 10px;
|
||||
background: var(--panel-2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; }
|
||||
.modal .err { color: var(--err); font-size: 11.5px; min-height: 1em; margin-bottom: 6px; }
|
||||
.modal .row { display:flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal button { font: inherit; cursor: pointer; padding: 7px 14px; border-radius: 8px; }
|
||||
.modal button.secondary { background: transparent; color: var(--ink); border: 1px solid var(--line); }
|
||||
.modal button.primary { background: var(--sky); color: var(--sky-ink); border: 0; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="mark" aria-hidden="true">
|
||||
|
||||
<!-- Top bar — present on both screens -->
|
||||
<div class="bar">
|
||||
<div class="mk" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" transform="rotate(-22 32 32)">
|
||||
<rect x="20" y="26" width="30" height="18" rx="1.5"/>
|
||||
<path d="M20 27 L35 37 L50 27"/>
|
||||
<path d="M20 30 L2 30"/>
|
||||
<path d="M20 36 L10 36"/>
|
||||
<path d="M20 42 L14 42"/>
|
||||
<g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" transform="rotate(-20 32 32)">
|
||||
<rect x="18" y="24" width="34" height="22" rx="1.5"/>
|
||||
<path d="M18 25 L35 39 L52 25"/>
|
||||
<path d="M18 28 L-4 24"/>
|
||||
<path d="M18 36 L-6 36"/>
|
||||
<path d="M18 44 L0 46"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="title">Hermes</div>
|
||||
<div class="spc"></div>
|
||||
<div class="status" id="status"><span class="dot" id="statusDot"></span><span id="statusText">—</span></div>
|
||||
<button class="ic" id="popOutBtn" title="Open in a window">⧉</button>
|
||||
</div>
|
||||
|
||||
<!-- ========== SIGNIN SCREEN ========== -->
|
||||
<div class="screen active" id="signin">
|
||||
<div class="mk" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" transform="rotate(-20 32 32)">
|
||||
<rect x="18" y="24" width="34" height="22" rx="1.5"/>
|
||||
<path d="M18 25 L35 39 L52 25"/>
|
||||
<path d="M18 28 L-4 24"/>
|
||||
<path d="M18 36 L-6 36"/>
|
||||
<path d="M18 44 L0 46"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Hermes</h1>
|
||||
<p class="lede">Message anyone by their <code>.bch</code> name. Sealed-sender DMs over Nostr.</p>
|
||||
<button id="openBtn">Open Messages</button>
|
||||
<div class="shortcut">or press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>E</kbd></div>
|
||||
<div class="err" id="err"></div>
|
||||
<p class="lede">End-to-end encrypted DMs. Your identity is your <code>.bch</code> name — same wallet seed as Theseus.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" id="useVaultBtn" disabled>Open with Theseus ID</button>
|
||||
<button class="secondary" id="toggleMnemonicBtn">Open an existing account</button>
|
||||
</div>
|
||||
<div class="mnemonic" id="mnemonicBox">
|
||||
<textarea id="mnemonicInput" placeholder="12 or 24 words, separated by spaces" autocomplete="off" spellcheck="false"></textarea>
|
||||
<button class="primary" id="useMnemonicBtn">Open</button>
|
||||
</div>
|
||||
<div class="err" id="signinErr"></div>
|
||||
<div class="hint" id="vaultHint"></div>
|
||||
</div>
|
||||
|
||||
<!-- ========== APP SCREEN ========== -->
|
||||
<div class="screen" id="app">
|
||||
<div class="cols">
|
||||
<div class="contacts-pane">
|
||||
<div class="contacts-head">
|
||||
<input id="search" placeholder="Search or type a .bch name" autocomplete="off" spellcheck="false">
|
||||
<button id="addBtn" title="New conversation">+</button>
|
||||
</div>
|
||||
<div class="contacts-list" id="contactsList"></div>
|
||||
</div>
|
||||
<div class="thread-pane">
|
||||
<div class="thread-head" id="threadHead" style="display:none">
|
||||
<button class="back" id="backBtn">←</button>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="nm" id="threadName">—</div>
|
||||
<div class="pk" id="threadPk"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread" id="thread"></div>
|
||||
<div class="thread-placeholder" id="threadPlaceholder">Pick a conversation, or add a new one.</div>
|
||||
<div class="compose" id="compose" style="display:none">
|
||||
<textarea id="composeInput" rows="1" placeholder="Message…"></textarea>
|
||||
<button id="sendBtn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- add-contact modal -->
|
||||
<div class="modal-bg" id="addModal">
|
||||
<div class="modal">
|
||||
<h3>Start a new conversation</h3>
|
||||
<input id="addName" placeholder="alice.bch, or an npub1…" autocomplete="off" spellcheck="false">
|
||||
<div class="err" id="addErr"></div>
|
||||
<div class="row">
|
||||
<button class="secondary" id="addCancel">Cancel</button>
|
||||
<button class="primary" id="addConfirm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const bridge = window.silentmode && typeof window.silentmode.invoke === "function" ? window.silentmode : null;
|
||||
const err = (m) => { document.getElementById("err").textContent = m || ""; };
|
||||
document.getElementById("openBtn").addEventListener("click", async () => {
|
||||
err("");
|
||||
if (!bridge) { err("sidebar bridge unavailable"); return; }
|
||||
const bridge = (window.silentmode && typeof window.silentmode.invoke === "function") ? window.silentmode : null;
|
||||
const sidebar = (window.silentmode && window.silentmode.sidebar) || null;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const err = (el, m) => { el.textContent = m || ""; };
|
||||
|
||||
const state = {
|
||||
ready: false,
|
||||
me: null, // { pkHex, npub }
|
||||
canUseVault: false,
|
||||
contacts: [],
|
||||
messages: [], // flat inbox from host
|
||||
readState: {}, // peerPkHex → epoch sec read-up-to
|
||||
selected: null, // peerPkHex (lowercase) of focused thread
|
||||
threads: new Map(), // peerPkHex → { peerName, messages: [...] }
|
||||
};
|
||||
|
||||
function h(tag, attrs, kids) {
|
||||
const n = document.createElement(tag);
|
||||
if (attrs) for (const k of Object.keys(attrs)) {
|
||||
if (k === "class") n.className = attrs[k];
|
||||
else if (k === "text") n.textContent = attrs[k];
|
||||
else if (k.startsWith("on")) n.addEventListener(k.slice(2), attrs[k]);
|
||||
else n.setAttribute(k, attrs[k]);
|
||||
}
|
||||
if (kids) for (const k of [].concat(kids)) if (k != null) n.append(k);
|
||||
return n;
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
const s = String(name || "").trim();
|
||||
if (!s) return "·";
|
||||
if (s.includes(".")) return s[0].toUpperCase();
|
||||
const parts = s.split(/\s+/);
|
||||
return (parts[0][0] + (parts[1] ? parts[1][0] : "")).toUpperCase();
|
||||
}
|
||||
|
||||
function groupByPeer(messages, contacts, mePk) {
|
||||
const nameOf = new Map();
|
||||
for (const c of contacts) nameOf.set(c.pkHex, c.nickname || c.name);
|
||||
const map = new Map();
|
||||
for (const m of messages) {
|
||||
const peer = m.peerPkHex || (m.direction === "out" ? null : m.senderPkHex);
|
||||
if (!peer) continue;
|
||||
const key = peer.toLowerCase();
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
peerPkHex: key,
|
||||
peerName: nameOf.get(key) || m.peerName || null,
|
||||
messages: [],
|
||||
});
|
||||
}
|
||||
map.get(key).messages.push(m);
|
||||
}
|
||||
for (const t of map.values()) t.messages.sort((a, b) => a.createdAt - b.createdAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
function unreadCount(thread, readUpTo) {
|
||||
let n = 0;
|
||||
for (const m of thread.messages) {
|
||||
if (m.direction === "in" && m.createdAt > (readUpTo || 0)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function fmtTime(sec) {
|
||||
const d = new Date(sec * 1000);
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const diffD = (now - d) / 86400000;
|
||||
if (diffD < 7) return d.toLocaleDateString([], { weekday: "short" });
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function renderContacts() {
|
||||
const list = $("contactsList");
|
||||
list.textContent = "";
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const nameOf = new Map();
|
||||
for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || c.name);
|
||||
const items = [];
|
||||
for (const [pk, t] of state.threads) {
|
||||
const label = t.peerName || nameOf.get(pk) || pk.slice(0, 10) + "…";
|
||||
if (q && !label.toLowerCase().includes(q) && !pk.includes(q)) continue;
|
||||
const last = t.messages[t.messages.length - 1];
|
||||
items.push({ pk, label, last, unread: unreadCount(t, state.readState[pk]) });
|
||||
}
|
||||
// Contacts with no messages yet
|
||||
for (const c of state.contacts) {
|
||||
if (state.threads.has(c.pkHex)) continue;
|
||||
const label = c.nickname || c.name || c.pkHex.slice(0, 10) + "…";
|
||||
if (q && !label.toLowerCase().includes(q) && !c.pkHex.includes(q)) continue;
|
||||
items.push({ pk: c.pkHex, label, last: null, unread: 0 });
|
||||
}
|
||||
items.sort((a, b) => (b.last?.createdAt || 0) - (a.last?.createdAt || 0));
|
||||
|
||||
if (items.length === 0) {
|
||||
list.append(h("div", { class: "empty", text: q ? "No matches" : "No conversations yet.\nPress + to add one." }));
|
||||
return;
|
||||
}
|
||||
for (const it of items) {
|
||||
const sub = it.last
|
||||
? (it.last.direction === "out" ? "You: " : "") + String(it.last.text).replace(/\s+/g, " ").slice(0, 60)
|
||||
: "No messages yet";
|
||||
const node = h("div", { class: "contact" + (state.selected === it.pk ? " active" : ""), onclick: () => selectThread(it.pk) }, [
|
||||
h("div", { class: "avatar", text: initials(it.label) }),
|
||||
h("div", { class: "meta" }, [
|
||||
h("div", { class: "nm", text: it.label }),
|
||||
h("div", { class: "sub", text: sub }),
|
||||
]),
|
||||
it.unread > 0 ? h("div", { class: "badge", text: String(it.unread) }) : null,
|
||||
]);
|
||||
list.append(node);
|
||||
}
|
||||
}
|
||||
|
||||
function renderThread() {
|
||||
const pk = state.selected;
|
||||
const head = $("threadHead");
|
||||
const thread = $("thread");
|
||||
const ph = $("threadPlaceholder");
|
||||
const comp = $("compose");
|
||||
thread.textContent = "";
|
||||
if (!pk) {
|
||||
head.style.display = "none";
|
||||
comp.style.display = "none";
|
||||
ph.style.display = "grid";
|
||||
return;
|
||||
}
|
||||
const t = state.threads.get(pk);
|
||||
const nameOf = new Map();
|
||||
for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || c.name);
|
||||
const label = (t && t.peerName) || nameOf.get(pk) || pk.slice(0, 16) + "…";
|
||||
$("threadName").textContent = label;
|
||||
$("threadPk").textContent = pk;
|
||||
head.style.display = "flex";
|
||||
ph.style.display = "none";
|
||||
comp.style.display = "flex";
|
||||
|
||||
if (!t || t.messages.length === 0) {
|
||||
thread.append(h("div", { class: "empty", text: "No messages yet. Say hi." }));
|
||||
return;
|
||||
}
|
||||
for (const m of t.messages) {
|
||||
thread.append(h("div", { class: "msg " + (m.direction === "out" ? "me" : "them") }, [
|
||||
document.createTextNode(m.text),
|
||||
h("div", { class: "t", text: fmtTime(m.createdAt) }),
|
||||
]));
|
||||
}
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
|
||||
async function selectThread(pk) {
|
||||
state.selected = pk;
|
||||
$("app").classList.add("show-thread");
|
||||
// Mark read up to now
|
||||
const t = state.threads.get(pk);
|
||||
const upTo = t && t.messages.length ? t.messages[t.messages.length - 1].createdAt : Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
const r = await bridge.invoke("open-messages");
|
||||
if (!r || r.ok !== true) err("could not open messages window");
|
||||
} catch (e) { err("error: " + (e && e.message || e)); }
|
||||
const r = await bridge.invoke("markThreadRead", { peerPkHex: pk, upToCreatedAt: upTo });
|
||||
if (r && r.ok) state.readState[pk] = r.readUpTo;
|
||||
} catch {}
|
||||
renderContacts();
|
||||
renderThread();
|
||||
}
|
||||
|
||||
function setStatus(payload) {
|
||||
const dot = $("statusDot");
|
||||
const txt = $("statusText");
|
||||
if (!payload || !payload.ready) {
|
||||
dot.className = "dot";
|
||||
txt.textContent = "Signed out";
|
||||
return;
|
||||
}
|
||||
const c = payload.relaysConnected || 0;
|
||||
const t = payload.relaysTotal || 0;
|
||||
if (c === 0) { dot.className = "dot warn"; txt.textContent = "Connecting…"; }
|
||||
else { dot.className = "dot ok"; txt.textContent = `${c}/${t} relays`; }
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (!bridge) return;
|
||||
const r = await bridge.invoke("status");
|
||||
if (r && r.ok) setStatus(r);
|
||||
}
|
||||
|
||||
async function refreshInbox() {
|
||||
if (!bridge || !state.ready) return;
|
||||
const r = await bridge.invoke("inbox");
|
||||
if (!r || !r.ok) return;
|
||||
state.messages = r.messages || [];
|
||||
state.readState = r.readState || {};
|
||||
state.threads = groupByPeer(state.messages, state.contacts, state.me && state.me.pkHex);
|
||||
renderContacts();
|
||||
if (state.selected) renderThread();
|
||||
}
|
||||
|
||||
async function refreshContacts() {
|
||||
if (!bridge) return;
|
||||
const r = await bridge.invoke("contactsList");
|
||||
if (!r || !r.ok) return;
|
||||
state.contacts = r.contacts || [];
|
||||
renderContacts();
|
||||
}
|
||||
|
||||
function showApp(me) {
|
||||
state.ready = true;
|
||||
state.me = me;
|
||||
$("signin").classList.remove("active");
|
||||
$("app").classList.add("active");
|
||||
refreshContacts().then(refreshInbox);
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
async function initWithOpts(opts) {
|
||||
err($("signinErr"), "");
|
||||
const btn = opts.useVault ? $("useVaultBtn") : $("useMnemonicBtn");
|
||||
const prev = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = "Opening…";
|
||||
try {
|
||||
const r = await bridge.invoke("init", opts);
|
||||
if (!r || !r.ok) throw new Error((r && r.error) || "init failed");
|
||||
showApp({ pkHex: r.pkHex, npub: r.npub });
|
||||
} catch (e) {
|
||||
err($("signinErr"), (e && e.message) || String(e));
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = prev;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- boot ----
|
||||
(async () => {
|
||||
if (!bridge) {
|
||||
err($("signinErr"), "sidebar bridge unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
// If a Hermes session is already live (user re-opened the panel), skip signin.
|
||||
try {
|
||||
const st = await bridge.invoke("status");
|
||||
setStatus(st);
|
||||
if (st && st.ok && st.ready) {
|
||||
showApp({ pkHex: st.pkHex, npub: st.npub });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Enable "Open with Theseus ID" iff the vault is unlocked AND was set up with a mnemonic.
|
||||
try {
|
||||
const v = await bridge.invoke("canUseVault");
|
||||
state.canUseVault = !!(v && v.available);
|
||||
$("useVaultBtn").disabled = !state.canUseVault;
|
||||
$("vaultHint").textContent = state.canUseVault
|
||||
? "Uses the same wallet seed as Theseus — nothing new to remember."
|
||||
: "To use Theseus ID, unlock the password vault first (set up with a 12/24-word mnemonic).";
|
||||
} catch {}
|
||||
|
||||
// Panel event bus — pushes from main.js.
|
||||
if (window.silentmode.on) {
|
||||
window.silentmode.on("hermes-message", () => refreshInbox());
|
||||
window.silentmode.on("hermes-status-update", (p) => setStatus(p));
|
||||
}
|
||||
|
||||
// Mild polling in case the event bus misses a tick (dev launcher, re-attach).
|
||||
setInterval(refreshStatus, 7000);
|
||||
})();
|
||||
|
||||
// ---- events ----
|
||||
$("useVaultBtn").addEventListener("click", () => initWithOpts({ useVault: true }));
|
||||
$("toggleMnemonicBtn").addEventListener("click", () => {
|
||||
$("mnemonicBox").classList.toggle("show");
|
||||
if ($("mnemonicBox").classList.contains("show")) $("mnemonicInput").focus();
|
||||
});
|
||||
$("useMnemonicBtn").addEventListener("click", () => {
|
||||
const mnemonic = $("mnemonicInput").value;
|
||||
initWithOpts({ useVault: false, mnemonic });
|
||||
});
|
||||
$("popOutBtn").addEventListener("click", () => bridge.invoke("open-messages"));
|
||||
|
||||
$("search").addEventListener("input", renderContacts);
|
||||
$("backBtn").addEventListener("click", () => {
|
||||
state.selected = null;
|
||||
$("app").classList.remove("show-thread");
|
||||
renderContacts();
|
||||
renderThread();
|
||||
});
|
||||
|
||||
$("addBtn").addEventListener("click", () => {
|
||||
$("addModal").classList.add("show");
|
||||
$("addName").value = "";
|
||||
err($("addErr"), "");
|
||||
$("addName").focus();
|
||||
});
|
||||
$("addCancel").addEventListener("click", () => $("addModal").classList.remove("show"));
|
||||
$("addModal").addEventListener("click", (e) => { if (e.target === $("addModal")) $("addModal").classList.remove("show"); });
|
||||
$("addName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("addConfirm").click(); });
|
||||
$("addConfirm").addEventListener("click", async () => {
|
||||
const name = $("addName").value.trim();
|
||||
if (!name) return;
|
||||
err($("addErr"), "");
|
||||
$("addConfirm").disabled = true;
|
||||
try {
|
||||
const r = await bridge.invoke("contactsAdd", { name });
|
||||
if (!r || !r.ok) throw new Error((r && r.error) || "could not add");
|
||||
$("addModal").classList.remove("show");
|
||||
state.contacts = r.contacts || state.contacts;
|
||||
renderContacts();
|
||||
if (r.contact && r.contact.pkHex) selectThread(r.contact.pkHex);
|
||||
} catch (e) {
|
||||
err($("addErr"), (e && e.message) || String(e));
|
||||
} finally {
|
||||
$("addConfirm").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("composeInput").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("sendBtn").click(); }
|
||||
});
|
||||
$("composeInput").addEventListener("input", () => {
|
||||
const t = $("composeInput");
|
||||
t.style.height = "auto";
|
||||
t.style.height = Math.min(120, t.scrollHeight) + "px";
|
||||
});
|
||||
|
||||
$("sendBtn").addEventListener("click", async () => {
|
||||
const text = $("composeInput").value.trim();
|
||||
if (!text || !state.selected) return;
|
||||
const t = state.threads.get(state.selected);
|
||||
const target = t && t.peerName ? t.peerName : state.selected;
|
||||
$("sendBtn").disabled = true;
|
||||
try {
|
||||
const r = await bridge.invoke("send", { to: target, text });
|
||||
if (!r || !r.ok) throw new Error((r && r.error) || "send failed");
|
||||
$("composeInput").value = "";
|
||||
$("composeInput").style.height = "auto";
|
||||
await refreshInbox();
|
||||
} catch (e) {
|
||||
// Surface failure as an inline "them"-style note; keeps the compose box non-blocking.
|
||||
const thread = $("thread");
|
||||
thread.append(h("div", { class: "msg them", style: "color: var(--err)" }, [
|
||||
document.createTextNode("Send failed: " + ((e && e.message) || e)),
|
||||
]));
|
||||
} finally {
|
||||
$("sendBtn").disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
167
main.js
167
main.js
|
|
@ -9781,6 +9781,11 @@ const hErr = (e) => ({ ok: false, err: String((e && e.message) || e) });
|
|||
|
||||
function hermesEmit(channel, payload) {
|
||||
if (hermesWin && !hermesWin.isDestroyed()) hermesWin.webContents.send(channel, payload);
|
||||
// Also fan out on an in-process bus so the bundled Hermes addon can forward
|
||||
// the event to its sidebar panel. The addon listens on __hermes_bus and
|
||||
// calls api.emit to deliver to the panel. Decoupled from hermesWin so the
|
||||
// panel receives live updates even when the standalone window is closed.
|
||||
try { ipcMain.emit("__hermes_bus", null, channel, payload); } catch {}
|
||||
}
|
||||
function hermesRecord(msg) {
|
||||
hermesState.inbox.push(msg);
|
||||
|
|
@ -10138,6 +10143,168 @@ function parseVcardContacts(text) {
|
|||
return out;
|
||||
}
|
||||
|
||||
// ---- Panel proxy ----------------------------------------------------------
|
||||
// Exposed to the bundled Hermes addon via global so the sidebar panel can
|
||||
// invoke hermes operations through silentmode.invoke(name) → api.onMessage
|
||||
// in the addon → these methods. Pre-existing ipcMain.handle wrappers above
|
||||
// remain for the standalone Messages window, which still uses them under
|
||||
// HERMES_ONLY gating. Bodies kept inline / duplicated with the handlers so
|
||||
// the refactor to a shared module stays optional.
|
||||
global._hermesAPI = {
|
||||
status: () => {
|
||||
if (!hermesState) return hOk({ ready: false });
|
||||
let connected = 0;
|
||||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
||||
return hOk({ ready: true, npub: hermesState.npub, pkHex: hermesState.pkHex,
|
||||
relaysConnected: connected, relaysTotal: hermesState.relays.size });
|
||||
},
|
||||
canUseVault: () => hOk({ available: !!(vaultState && vaultState.messengerRoot) }),
|
||||
close: () => { hermesTeardown(); return hOk(); },
|
||||
inbox: () => hermesState
|
||||
? hOk({ messages: hermesState.inbox.slice(), readState: hermesState.readState || {} })
|
||||
: hErr("not initialised"),
|
||||
init: async (opts = {}) => {
|
||||
try {
|
||||
hermesTeardown();
|
||||
const H = await loadHermesLib();
|
||||
let sk, pkHex, npub;
|
||||
if (opts.useVault) {
|
||||
if (!vaultState || !vaultState.messengerRoot) {
|
||||
return hErr("password vault is locked or was set up without a mnemonic");
|
||||
}
|
||||
({ sk, pkHex, npub } = H.nostrKeyFromRoot(vaultState.messengerRoot));
|
||||
} else {
|
||||
const mnemonic = opts.mnemonic;
|
||||
if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) {
|
||||
return hErr("enter a 12- or 24-word mnemonic");
|
||||
}
|
||||
({ 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);
|
||||
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));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
let connected = 0;
|
||||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
||||
return hOk({ npub, pkHex, source: opts.useVault ? "vault" : "mnemonic",
|
||||
relaysConnected: connected, relaysTotal: hermesState.relays.size });
|
||||
} catch (e) { hermesTeardown(); return hErr(e); }
|
||||
},
|
||||
markThreadRead: ({ 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 });
|
||||
},
|
||||
contactsList: () => hOk({ contacts: hermesLoadContacts().slice() }),
|
||||
contactsAdd: async ({ name, nickname } = {}) => {
|
||||
const raw = String(name || "").trim();
|
||||
if (!raw) return hErr("name is required (e.g. alice.bch or an npub)");
|
||||
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}`);
|
||||
pkHex = H.parseHermesRecords(entry).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 });
|
||||
},
|
||||
contactsUpdate: ({ 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");
|
||||
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 });
|
||||
},
|
||||
contactsRemove: ({ 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: async ({ to, text } = {}) => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
if (!to || !text) return hErr("to and text required");
|
||||
try {
|
||||
const H = await loadHermesLib();
|
||||
let recipientPkHex; let relays = HERMES_DEFAULT_RELAYS.slice();
|
||||
const trimmed = String(to).trim();
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) recipientPkHex = trimmed.toLowerCase();
|
||||
else if (trimmed.startsWith("npub1")) recipientPkHex = H.parseNpRecord(trimmed);
|
||||
else {
|
||||
const R = await getResolver();
|
||||
const name = R.normalizeName(trimmed);
|
||||
const entry = await R.resolveName(name, { WebSocket });
|
||||
if (!entry) return hErr(`no on-chain registration for ${name}`);
|
||||
const parsed = H.parseHermesRecords(entry, { defaultRelays: HERMES_DEFAULT_RELAYS });
|
||||
recipientPkHex = parsed.npPk;
|
||||
relays = parsed.relays;
|
||||
}
|
||||
const sk = Buffer.from(hermesState.skHex, "hex");
|
||||
const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text });
|
||||
const publishOne = (url) => new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
let settled = false;
|
||||
const done = (r) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(r); };
|
||||
const t = setTimeout(() => done({ url, ok: false, detail: "timeout" }), 8000);
|
||||
ws.on("open", () => ws.send(JSON.stringify(["EVENT", wrap])));
|
||||
ws.on("message", (buf) => {
|
||||
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
if (msg[0] === "OK" && msg[1] === wrap.id) {
|
||||
clearTimeout(t);
|
||||
done({ url, ok: !!msg[2], detail: msg[3] || "" });
|
||||
}
|
||||
});
|
||||
ws.on("error", (e) => done({ url, ok: false, detail: "ws error: " + e.message }));
|
||||
});
|
||||
const results = await Promise.all(relays.map(publishOne));
|
||||
const accepted = results.filter((r) => r.ok).length;
|
||||
if (accepted > 0) {
|
||||
const peerName = await hermesReverseResolve(recipientPkHex);
|
||||
hermesRecord({
|
||||
direction: "out", peerPkHex: recipientPkHex, peerName,
|
||||
senderPkHex: hermesState.pkHex, senderName: null,
|
||||
text, createdAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
return hOk({ recipientPkHex, accepted, total: results.length, results });
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
};
|
||||
|
||||
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
|
||||
// via the portable resolver (getResolver above), the `np` record parsed, then
|
||||
// wrapped + published to each relay listed in `nr` (falling back to defaults).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue