theseus/bundled-addons/hermes/panel.html
Local Dev 8777b1fc56 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.
2026-10-21 03:00:17 +03:00

598 lines
25 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hermes</title>
<style>
:root { color-scheme: light dark;
--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; --panel-2:#eef2fb; --line:rgba(0,0,0,.09);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--sky:#0891B2; --sky-ink:#ffffff;
--bubble-me:#dceefa; --bubble-them:#eef1f5; }
}
* { box-sizing: border-box; }
html, body { margin:0; height:100%; }
body { background: var(--bg); color: var(--ink);
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>
<!-- 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="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">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 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("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>
</html>