theseus/messages.html
Local Dev 8eae448dde Hermes: Ermis shortcut (Ctrl+Shift+E), sidebar add-on, vCard import, brand kit
Shortcut: Ctrl+Shift+M -> Ctrl+Shift+E. Chrome owns Ctrl+Shift+M as
'switch profile' and E matches the Greek spelling (Ermis).

Sidebar discoverability: new bundled add-on at bundled-addons/hermes/
registers a left-strip icon + launcher panel. The panel is a thin chrome
(mark + 'Open Messages' + shortcut hint) that forwards clicks through
api.onMessage -> ipcMain.emit('hermes-open-request') -> openHermesWindow.
All real messaging code stays in main.js — add-on is purely for discovery.

Address book: opt-in vCard (.vcf) import. Dialog in the Add Contact modal
lets the user pick a file; main.js parses BEGIN:VCARD blocks for X-BNS-NAME
/ X-NOSTR-NPUB / X-NOSTR-PUBKEY plus .bch names and npubs mentioned in
NOTE fields. Candidates are previewed per-contact with checkboxes; nothing
is added until the user confirms. Non-matching vCards are returned too (so
the user can still add manually) rather than silently dropped. Nothing is
uploaded; parsing is local.

site/hermes: status badge flipped from 'designed - not yet built' to
'MVP - desktop'. New 'What ships today' grid covers threaded chat, send by
name, address book, local history (all live) and mobile + Tor (next /
planned). Hero carries the new Ctrl+Shift+E shortcut and a link to
/hermes/branding.

site/hermes/branding: new page. Mark SVG (winged envelope, two variants
for 16-20px and 24-160px display sizes), palette (reuses Silent Mode
tokens + an amber accent that's distinctly warm-yellow, not Hermes-orange
— so it never collides with Hermès the fashion house), name explanation
(English Hermes / Greek Ermis), voice notes, do/don't table, MPL-2.0
attribution.

Hermes/MOBILE-RECOMMENDATION.md: analysis of three mobile paths (inside
Ariadne / standalone / hybrid). Recommendation is Option 1: ship as a
screen inside Ariadne. Reuses the wallet seed, aligns with the design
doc's 'one seed many purposes' architecture, lowest total cost.
2026-10-21 00:45:25 +03:00

594 lines
24 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Messages — Theseus</title>
<style>
:root{
--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;
--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; overflow:hidden;
}
header{
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 .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;
}
#ident b{color:var(--acid); font-weight:500}
#status{
font-size:11px; padding:2px 8px; border-radius:999px;
background:var(--panel); color:var(--mut);
}
#status.ok{color:var(--ok); background:rgba(79,209,165,.12)}
#status.err{color:var(--err); background:rgba(246,118,138,.12)}
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; 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}
.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">
<header>
<span class="mark">⛓️✉️</span>
<h1>Hermes Messages</h1>
<span id="status">connecting…</span>
<span id="ident"></span>
</header>
<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.
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">
<div class="row">
<button id="btnVault">Sign in with password vault</button>
<span style="color:var(--mut); font-size:12px">recommended — no mnemonic to re-type</span>
</div>
<div style="text-align:center; color:var(--dim); font-size:12px; margin:14px 0">— or —</div>
</div>
<label>
Mnemonic (12 or 24 words)
<textarea id="mnemonic" placeholder="word word word …" autocomplete="off" spellcheck="false"></textarea>
</label>
<div class="row">
<button id="btnInit">Open messages</button>
</div>
<div id="setupErr"></div>
</section>
<!-- APP (threaded) -->
<section id="app">
<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="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" style="justify-content:space-between">
<button class="ghost" id="importBtn" title="Import contacts from a .vcf file">Import vCard…</button>
<div style="display:flex; gap:8px">
<button class="ghost" id="addCancel">Cancel</button>
<button id="addSave">Add</button>
</div>
</div>
</div>
</div>
<!-- Import preview modal: a reviewable list of parsed contacts. Each row is
user-togglable; nothing is added without an explicit "Import N contacts" click.
This is the "opt-in and reviewable" part of the address-book integration. -->
<div class="modal-bg" id="importModal">
<div class="modal" style="width:520px">
<h2>Import contacts <span id="importFrom" style="font-size:11px; color:var(--mut); font-weight:400"></span></h2>
<p class="lede" style="font-size:12px">Nothing is sent anywhere. Pick who you want to add; the rest are ignored.</p>
<div id="importList" style="max-height:300px; overflow:auto; margin:10px 0; background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:4px">
<div class="empty" style="padding:20px">No importable contacts found.</div>
</div>
<div id="importErr" style="color:var(--err); font-size:12px; min-height:1em"></div>
<div class="buttons">
<button class="ghost" id="importCancel">Close</button>
<button id="importConfirm">Import selected</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 shortPk = (pk) => pk ? pk.slice(0, 8) + "…" + pk.slice(-4) : "";
// 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" });
};
// ---- 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;
}
// 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;
}
// 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");
reloadAll();
return true;
}
$("btnInit").addEventListener("click", async () => {
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 = "";
const res = await hermes.initFromVault();
$("btnVault").disabled = false;
afterInit(res);
});
// ---- 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);
});
// ---- vCard import flow (opt-in) ----
$("importBtn").addEventListener("click", async () => {
$("addErr").textContent = "";
const res = await hermes.contacts.importPick();
if (!res.ok) { $("addErr").textContent = "import: " + res.err; return; }
const candidates = res.candidates || [];
if (!candidates.length) { $("addErr").textContent = "nothing to import from that file"; return; }
// Switch to the import preview modal.
$("addModal").classList.remove("show");
$("importFrom").textContent = res.fromFile ? "from " + res.fromFile : "";
$("importList").innerHTML = "";
$("importErr").textContent = "";
const importable = candidates.filter((c) => c.importable);
if (!importable.length) {
$("importList").innerHTML = '<div class="empty" style="padding:20px">No contacts in that file carry a .bch name or Nostr pubkey.<br><small>You can still add them manually one at a time.</small></div>';
} else {
for (const c of importable) {
const row = document.createElement("label");
row.style.cssText = "display:flex; gap:10px; padding:8px 10px; align-items:center; cursor:pointer; border-radius:6px;";
const cb = document.createElement("input");
cb.type = "checkbox"; cb.checked = true;
cb.dataset.identifier = c.identifier;
cb.dataset.nickname = c.displayName || "";
const meta = document.createElement("div");
meta.style.cssText = "flex:1; min-width:0";
const title = document.createElement("div");
title.style.cssText = "font-weight:500; font-size:13px; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap";
title.textContent = c.displayName || c.identifier;
const sub = document.createElement("div");
sub.style.cssText = "font-size:11px; color:var(--mut); font-family:ui-monospace,monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap";
sub.textContent = c.identifier;
meta.appendChild(title); meta.appendChild(sub);
row.appendChild(cb); row.appendChild(meta);
$("importList").appendChild(row);
}
}
$("importModal").classList.add("show");
});
$("importCancel").addEventListener("click", () => $("importModal").classList.remove("show"));
$("importConfirm").addEventListener("click", async () => {
const picked = Array.from($("importList").querySelectorAll('input[type=checkbox]:checked'));
if (!picked.length) { $("importErr").textContent = "nothing selected"; return; }
$("importConfirm").disabled = true;
let added = 0, failed = 0;
for (const cb of picked) {
const res = await hermes.contacts.add(cb.dataset.identifier, cb.dataset.nickname || null);
if (res && res.ok) added++; else failed++;
}
$("importConfirm").disabled = false;
$("importErr").textContent = `added ${added}, failed ${failed}`;
if (added) await reloadAll();
if (added && !failed) {
setTimeout(() => $("importModal").classList.remove("show"), 1200);
}
});
$("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" : "");
});
// ---- 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");
reloadAll();
return;
}
setStatus("locked");
const v = await hermes.canUseVault();
if (v && v.ok && v.available) $("vaultBind").style.display = "block";
});
</script>
</body>
</html>