theseus/bundled-addons/hermes/panel.html
Local Dev 2abd4d9616 hermes: crop the mark to the icon area, ship 128/256 px PNGs
The 1461x720 webp downscales badly to 24 px (sidebar strip) and 72 px (signin
header) — browser is doing a ~60x reduction and the thin wing lines turn to
mush. Pre-crop around the icon's content bbox, make it square with a small
breathing margin, resize to 128 px and 256 px, drop transparency on the dark
backdrop. Addon manifest icon is the 128 PNG as a data URI (16 KB vs the
26 KB uncropped webp), and the panel pulls mark-128.png / mark-256.png at
the two sizes it needs. Also home.html's Hermes.X tile now points at the
cropped PNG so the home card isn't blurry.

Version bumped to 0.2.3 so seedBundledAddons picks it up on a release
bundle swap. The installed bundle + user-data cache were hot-patched
too so the fix is visible immediately in the running app.
2026-10-21 22:14:00 +03:00

933 lines
44 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">
<img src="assets/mark-128.png" alt="" width="24" height="24" style="display:block">
</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="menuBtn" title="More" style="display:none">⋯</button>
<button class="ic" id="popOutBtn" title="Open in a window">⧉</button>
</div>
<!-- overflow menu -->
<div id="menu" style="position:absolute;top:46px;right:10px;z-index:50;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px;min-width:170px;display:none;box-shadow:0 6px 24px rgba(0,0,0,.3)">
<button class="menu-item" id="whoBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--mut);font:inherit;font-size:11px;padding:6px 10px;cursor:default">Signed in as —</button>
<div style="height:1px;background:var(--line);margin:2px 0"></div>
<button class="menu-item" id="openSettingsBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--ink);font:inherit;font-size:13px;padding:7px 10px;cursor:pointer;border-radius:6px">Settings…</button>
<button class="menu-item" id="signOutBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--err);font:inherit;font-size:13px;padding:7px 10px;cursor:pointer;border-radius:6px">Sign out</button>
</div>
<!-- Settings modal -->
<div class="modal-bg" id="settingsModal">
<div class="modal" style="max-width:380px">
<h3>Settings</h3>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Account</div>
<div style="font-size:13px;margin-bottom:4px"><span id="settingsHandle">—</span></div>
<div style="font-size:11px;color:var(--dim);font-family:ui-monospace,monospace;word-break:break-all;margin-bottom:14px" id="settingsNpub">—</div>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Profile</div>
<input id="profileDisplayName" placeholder="Display name" style="font:inherit;font-size:12.5px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;width:100%;margin-bottom:6px">
<input id="profilePicture" placeholder="Picture URL (https://…)" style="font:inherit;font-size:12.5px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;width:100%;margin-bottom:6px">
<textarea id="profileAbout" placeholder="A short bio (optional)" rows="2" style="font:inherit;font-size:12.5px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;width:100%;margin-bottom:6px;resize:vertical"></textarea>
<button class="primary" id="saveProfileBtn" style="background:var(--sky);color:var(--sky-ink);border:0;font:inherit;font-weight:600;padding:7px 12px;border-radius:8px;cursor:pointer;width:100%">Save profile</button>
<div class="err" id="profileErr" style="color:var(--err);font-size:11.5px;min-height:1em;margin-top:4px"></div>
<div style="color:var(--dim);font-size:11px;margin-bottom:14px">Published to relays as Nostr metadata. Independent of your handle — anyone can see it, change anytime.</div>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Display handles as</div>
<div style="display:flex;gap:6px;margin-bottom:14px" id="renderModes">
<button class="rmode" data-mode="scheme" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer">hermes:alice</button>
<button class="rmode" data-mode="dotted" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer">alice.erm</button>
<button class="rmode" data-mode="email" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer">alice@hermes.tel</button>
</div>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Phone discovery</div>
<div id="phoneHave" style="display:none;margin-bottom:6px">
<div style="font-size:13px;margin-bottom:4px">Linked: <span id="phoneShow" style="font-family:ui-monospace,monospace">—</span></div>
<button class="secondary" id="unlinkPhoneBtn" style="font:inherit;font-size:12px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer">Unlink</button>
</div>
<div id="phoneNone">
<input id="phoneInput" placeholder="+15551234567" style="font:inherit;font-size:12.5px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;width:100%;margin-bottom:6px">
<button class="primary" id="linkPhoneBtn" style="background:var(--sky);color:var(--sky-ink);border:0;font:inherit;font-weight:600;padding:7px 12px;border-radius:8px;cursor:pointer;width:100%">Link phone</button>
</div>
<div class="err" id="phoneErr" style="color:var(--err);font-size:11.5px;min-height:1em;margin-top:4px"></div>
<div style="color:var(--dim);font-size:11px;margin-bottom:14px">
We hash the number locally and publish a Nostr claim for the hash. Anyone who <i>already knows</i> your number can find you — same tradeoff as number-based messengers. Unlink any time.
</div>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Backup</div>
<div id="backupBox">
<div id="backupNone" style="font-size:12px;color:var(--mut);margin-bottom:12px">No seed phrase stored for this account.<br><span style="color:var(--dim);font-size:11px">(created-elsewhere / vault / mnemonic signins don't retain the words)</span></div>
<div id="backupHave" style="display:none">
<input id="backupPassword" type="password" placeholder="your account password" autocomplete="current-password" style="font:inherit;font-size:12.5px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;width:100%;margin-bottom:6px">
<button class="primary" id="revealSeedBtn" style="background:var(--sky);color:var(--sky-ink);border:0;font:inherit;font-weight:600;padding:8px 12px;border-radius:8px;cursor:pointer;width:100%">Reveal seed phrase</button>
<div class="err" id="backupErr" style="color:var(--err);font-size:11.5px;min-height:1em;margin-top:4px"></div>
<div id="backupReveal" style="display:none;font-family:ui-monospace,monospace;font-size:12px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:8px;line-height:1.7;word-break:break-word"></div>
</div>
</div>
<div class="row" style="margin-top:16px">
<button class="secondary" id="settingsClose">Close</button>
</div>
</div>
</div>
<!-- ========== SIGNIN SCREEN ========== -->
<div class="screen active" id="signin">
<div class="mk" aria-hidden="true">
<img src="assets/mark-256.png" alt="" width="72" height="72" style="display:block">
</div>
<h1>Hermes</h1>
<p class="lede">End-to-end encrypted DMs. Your identity is your on-chain registered 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="toggleCreateBtn">Create a new account</button>
<button class="secondary" id="toggleMnemonicBtn">Open an existing account</button>
</div>
<div class="mnemonic" id="createBox">
<input id="createHandle" type="text" placeholder="alice" autocomplete="off" spellcheck="false" style="font-family:inherit;font-size:13px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 10px;width:100%">
<input id="createPassword" type="password" placeholder="password (min 6)" autocomplete="new-password" style="font-family:inherit;font-size:13px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 10px;width:100%">
<input id="createPassword2" type="password" placeholder="confirm password" autocomplete="new-password" style="font-family:inherit;font-size:13px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 10px;width:100%">
<button class="primary" id="useCreateBtn">Create account</button>
<div class="hint">You'll get a <code>hermes:alice</code> handle. We'll generate a seed phrase — back it up in Settings.</div>
</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 class="hint" style="margin-top:4px;max-width:300px">You'll stay signed in. Sign out from the ⋯ menu.</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 hermes:alice" 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="hermes:alice · alice.x · +15551234567 · 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, handle? }
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: [...] }
renderMode: "scheme", // "scheme" (hermes:alice) | "dotted" | "email"
profiles: new Map(), // peerPkHex → { displayName, picture, about }
};
// Must stay in sync with HERMES_TLD / HERMES_EMAIL_PARENT in lib/hermes.js —
// used only for display; actual resolution happens host-side via parseHandle.
const HERMES_TLD = "erm";
const HERMES_EMAIL_PARENT = "hermes.tel";
function renderHandle(name, mode) {
const m = mode || state.renderMode;
const n = String(name || "").toLowerCase();
if (!n || !n.includes(".")) return n;
const suffix = "." + HERMES_TLD;
if (!n.endsWith(suffix)) return n;
const local = n.slice(0, -suffix.length);
if (m === "dotted") return n;
if (m === "email") return `${local}@${HERMES_EMAIL_PARENT}`;
return `hermes:${local}`;
}
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();
}
// Label cascade: explicit contact nickname → Nostr kind:0 display name →
// rendered BNS handle → pk preview. Keeps the chat list "humanized" even
// when the user has set a contact name but the recipient has also pushed
// a kind:0 profile with a nicer rendering.
function labelFor(pkHex, fallbackName) {
if (!pkHex) return fallbackName ? renderHandle(fallbackName) : "—";
const c = state.contacts.find((c) => c.pkHex === pkHex);
if (c && c.nickname) return c.nickname;
const p = state.profiles.get(pkHex);
if (p && p.displayName) return p.displayName;
if (c && c.name) return renderHandle(c.name);
if (fallbackName) return renderHandle(fallbackName);
return pkHex.slice(0, 10) + "…";
}
function avatarFor(pkHex, label) {
const p = pkHex && state.profiles.get(pkHex);
if (p && p.picture && /^https?:\/\//.test(p.picture)) {
const img = h("img", {
src: p.picture, alt: "", loading: "lazy",
onerror: (e) => { e.target.style.display = "none"; },
});
img.style.cssText = "width:100%;height:100%;object-fit:cover;border-radius:50%";
return img;
}
return document.createTextNode(initials(label));
}
async function ensureProfile(pkHex) {
if (!pkHex || state.profiles.has(pkHex)) return;
state.profiles.set(pkHex, null); // mark as pending so parallel renders don't refetch
try {
const r = await bridge.invoke("getProfile", { pkHex });
if (r && r.ok && r.profile) {
state.profiles.set(pkHex, r.profile);
renderContacts();
if (state.selected === pkHex) renderThread();
}
} catch {}
}
function groupByPeer(messages, contacts, mePk) {
const nameOf = new Map();
for (const c of contacts) nameOf.set(c.pkHex, c.nickname || renderHandle(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 items = [];
for (const [pk, t] of state.threads) {
const label = labelFor(pk, t.peerName);
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]) });
ensureProfile(pk); // fire-and-forget — refreshes when it lands
}
// Contacts with no messages yet
for (const c of state.contacts) {
if (state.threads.has(c.pkHex)) continue;
const label = labelFor(c.pkHex);
if (q && !label.toLowerCase().includes(q) && !c.pkHex.includes(q)) continue;
items.push({ pk: c.pkHex, label, last: null, unread: 0 });
ensureProfile(c.pkHex);
}
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" }, [avatarFor(it.pk, 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 label = labelFor(pk, t && t.peerName);
$("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");
const label = me && me.handle ? renderHandle(me.handle) :
me && me.npub ? me.npub.slice(0, 14) + "…" : "—";
$("whoBtn").textContent = "Signed in as " + label;
$("menuBtn").style.display = "grid";
refreshContacts().then(refreshInbox);
refreshStatus();
}
function showSignin() {
state.ready = false;
state.me = null;
state.selected = null;
state.messages = [];
state.threads = new Map();
$("app").classList.remove("active", "show-thread");
$("signin").classList.add("active");
$("menuBtn").style.display = "none";
closeMenu();
}
function openMenu() { $("menu").style.display = "block"; }
function closeMenu() { $("menu").style.display = "none"; }
document.addEventListener("click", (e) => {
const m = $("menu"); if (!m) return;
if (m.style.display === "block" && !m.contains(e.target) && e.target !== $("menuBtn")) closeMenu();
});
async function initWithOpts(opts) {
err($("signinErr"), "");
const btn = opts.create ? $("useCreateBtn")
: 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.err) || (r && r.error) || "init failed");
showApp({ pkHex: r.pkHex, npub: r.npub, handle: r.handle || null });
} 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;
}
// Hydrate display preference before the first render.
try {
if (window.silentmode.storage) {
const saved = await window.silentmode.storage.get("renderMode");
if (saved && ["scheme", "dotted", "email"].includes(saved)) state.renderMode = saved;
}
} catch {}
// 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, handle: st.handle || null });
}
} 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));
// Auto-restore fires on app launch if the user had a saved identity.
// The host already set hermesState up; the panel just needs to react
// as if it finished a signin.
window.silentmode.on("hermes-restored", (p) => {
if (!state.ready && p && p.pkHex) showApp({ pkHex: p.pkHex, npub: p.npub, handle: p.handle || null });
});
window.silentmode.on("hermes-profile", (p) => {
if (!p || !p.pkHex) return;
state.profiles.set(p.pkHex, p.profile || null);
renderContacts();
if (state.selected === p.pkHex) renderThread();
});
}
// Mild polling in case the event bus misses a tick (dev launcher, re-attach).
setInterval(refreshStatus, 7000);
})();
// ---- events ----
$("useVaultBtn").addEventListener("click", () => initWithOpts({ useVault: true }));
$("toggleCreateBtn").addEventListener("click", () => {
$("mnemonicBox").classList.remove("show");
$("createBox").classList.toggle("show");
if ($("createBox").classList.contains("show")) $("createHandle").focus();
});
$("toggleMnemonicBtn").addEventListener("click", () => {
$("createBox").classList.remove("show");
$("mnemonicBox").classList.toggle("show");
if ($("mnemonicBox").classList.contains("show")) $("mnemonicInput").focus();
});
$("useCreateBtn").addEventListener("click", () => {
const handle = $("createHandle").value.trim();
const p1 = $("createPassword").value;
const p2 = $("createPassword2").value;
if (!handle) { err($("signinErr"), "pick a name"); return; }
if (p1.length < 6) { err($("signinErr"), "password must be at least 6 characters"); return; }
if (p1 !== p2) { err($("signinErr"), "passwords don't match"); return; }
initWithOpts({ create: true, handle, password: p1 });
});
$("useMnemonicBtn").addEventListener("click", () => {
const mnemonic = $("mnemonicInput").value;
initWithOpts({ useVault: false, mnemonic });
});
$("popOutBtn").addEventListener("click", () => bridge.invoke("open-messages"));
$("menuBtn").addEventListener("click", (e) => {
e.stopPropagation();
if ($("menu").style.display === "block") closeMenu();
else openMenu();
});
$("signOutBtn").addEventListener("click", async () => {
closeMenu();
if (!confirm("Sign out of Hermes? Your messages stay on disk; sign in again to see them.")) return;
try { await bridge.invoke("signOut"); } catch {}
showSignin();
});
$("openSettingsBtn").addEventListener("click", async () => {
closeMenu();
await openSettings();
});
$("settingsClose").addEventListener("click", () => $("settingsModal").classList.remove("show"));
$("settingsModal").addEventListener("click", (e) => { if (e.target === $("settingsModal")) $("settingsModal").classList.remove("show"); });
// Display-mode picker. Persisted per-profile via silentmode.storage (the
// addon storage layer). Falls back to in-memory default on first run.
function paintRenderModes() {
for (const b of document.querySelectorAll(".rmode")) {
const active = b.dataset.mode === state.renderMode;
b.style.background = active ? "var(--sky)" : "var(--panel-2)";
b.style.color = active ? "var(--sky-ink)" : "var(--ink)";
b.style.borderColor = active ? "var(--sky)" : "var(--line)";
}
}
for (const b of document.querySelectorAll(".rmode")) {
b.addEventListener("click", async () => {
state.renderMode = b.dataset.mode;
try { if (window.silentmode.storage) await window.silentmode.storage.set("renderMode", state.renderMode); } catch {}
paintRenderModes();
renderContacts();
if (state.selected) renderThread();
});
}
async function openSettings() {
const m = state.me || {};
$("settingsHandle").textContent = m.handle ? renderHandle(m.handle) : "(no handle set)";
$("settingsNpub").textContent = m.npub || m.pkHex || "—";
paintRenderModes();
// Prefill the profile editor from the host's cache (which includes the
// user's own kind:0 if it was ever published or just saved locally).
err($("profileErr"), "");
try {
const r = await bridge.invoke("getProfile", { pkHex: m.pkHex });
const p = r && r.ok && r.profile ? r.profile : null;
$("profileDisplayName").value = (p && p.displayName) || "";
$("profilePicture").value = (p && p.picture) || "";
$("profileAbout").value = (p && p.about) || "";
} catch {}
// Phone discovery state.
err($("phoneErr"), "");
try {
const r = await bridge.invoke("getPhone");
const linked = r && r.ok && r.phone;
$("phoneHave").style.display = linked ? "block" : "none";
$("phoneNone").style.display = linked ? "none" : "block";
$("phoneShow").textContent = linked ? r.phone : "—";
$("phoneInput").value = "";
} catch {}
let hasBackup = false;
try {
const r = await bridge.invoke("hasBackup");
hasBackup = !!(r && r.ok && r.hasBackup);
} catch {}
$("backupNone").style.display = hasBackup ? "none" : "block";
$("backupHave").style.display = hasBackup ? "block" : "none";
$("backupPassword").value = "";
$("backupReveal").style.display = "none";
$("backupReveal").textContent = "";
err($("backupErr"), "");
$("settingsModal").classList.add("show");
}
$("linkPhoneBtn").addEventListener("click", async () => {
const phone = $("phoneInput").value.trim();
if (!phone) { err($("phoneErr"), "enter a phone number"); return; }
err($("phoneErr"), "");
$("linkPhoneBtn").disabled = true;
const prev = $("linkPhoneBtn").textContent;
$("linkPhoneBtn").textContent = "Publishing…";
try {
const r = await bridge.invoke("setPhone", { phone });
if (!r || !r.ok) throw new Error((r && r.err) || "could not link");
$("phoneHave").style.display = "block";
$("phoneNone").style.display = "none";
$("phoneShow").textContent = r.phone;
$("linkPhoneBtn").textContent = prev;
} catch (e) {
err($("phoneErr"), (e && e.message) || String(e));
$("linkPhoneBtn").textContent = prev;
} finally { $("linkPhoneBtn").disabled = false; }
});
$("unlinkPhoneBtn").addEventListener("click", async () => {
if (!confirm("Unlink your phone? We'll ask relays to delete the claim, but copies that already propagated may linger until the relay expires them.")) return;
err($("phoneErr"), "");
$("unlinkPhoneBtn").disabled = true;
try {
const r = await bridge.invoke("clearPhone");
if (!r || !r.ok) throw new Error((r && r.err) || "could not unlink");
$("phoneHave").style.display = "none";
$("phoneNone").style.display = "block";
$("phoneShow").textContent = "—";
$("phoneInput").value = "";
} catch (e) {
err($("phoneErr"), (e && e.message) || String(e));
} finally { $("unlinkPhoneBtn").disabled = false; }
});
$("saveProfileBtn").addEventListener("click", async () => {
const displayName = $("profileDisplayName").value.trim();
const picture = $("profilePicture").value.trim();
const about = $("profileAbout").value.trim();
if (picture && !/^https?:\/\//i.test(picture)) {
err($("profileErr"), "picture must be a http(s) URL"); return;
}
err($("profileErr"), "");
$("saveProfileBtn").disabled = true;
const prev = $("saveProfileBtn").textContent;
$("saveProfileBtn").textContent = "Publishing…";
try {
const r = await bridge.invoke("updateProfile", { displayName, picture, about });
if (!r || !r.ok) throw new Error((r && r.err) || "publish failed");
// Update the local cache for our own pk so the UI reflects the change.
const me = state.me || {};
if (me.pkHex) state.profiles.set(me.pkHex, { displayName, picture, about });
$("saveProfileBtn").textContent = r.accepted > 0
? `Saved · ${r.accepted}/${r.total} relays`
: "Saved locally (no relay accepted)";
renderContacts();
setTimeout(() => { $("saveProfileBtn").textContent = prev; }, 2500);
} catch (e) {
err($("profileErr"), (e && e.message) || String(e));
$("saveProfileBtn").textContent = prev;
} finally { $("saveProfileBtn").disabled = false; }
});
$("revealSeedBtn").addEventListener("click", async () => {
const password = $("backupPassword").value;
if (!password) { err($("backupErr"), "enter your password"); return; }
$("revealSeedBtn").disabled = true;
try {
const r = await bridge.invoke("getBackup", { password });
if (!r || !r.ok) throw new Error((r && r.err) || (r && r.error) || "wrong password");
const words = String(r.mnemonic).trim().split(/\s+/);
$("backupReveal").innerHTML = words.map((w, i) =>
`<span style="display:inline-block;min-width:68px;padding:2px 4px;margin:1px"><span style="color:var(--dim);font-size:10px">${i + 1}</span> ${w}</span>`
).join("");
$("backupReveal").style.display = "block";
err($("backupErr"), "");
} catch (e) {
err($("backupErr"), (e && e.message) || String(e));
} finally { $("revealSeedBtn").disabled = false; }
});
$("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>