hermes-site/messenger/index.html
Local Dev 86e67d05e7 hermes.x/messenger: wrap the Theseus panel as a stand-alone webapp
The Hermes sidebar panel from TheseusNavigator/bundled-addons/hermes/
runs as a web page here with no changes to the UI code — all the host
API it needs (bridge.invoke, bridge.on, storage) is provided by the
shim.js next to it. The two pieces together give visitors without
Theseus a working browser messenger: paste an nsec or 12/24-word
mnemonic, resolve a .x name on-chain, send a NIP-17 gift-wrapped DM
over a WebSocket to the recipient's declared relays.

What landed:

- index.html — copy of the panel's panel.html with the site topnav
  wrapped above it (so Overview / Branding / home stay reachable) and
  the panel's own .bar + .screen in a .panel-root flex container. The
  .screen stays position:absolute inset:44px 0 0 0 — just anchored to
  the panel root now instead of the viewport. Shared.css provides the
  topnav styling; the panel's inline <style> keeps the rest. Three
  assets/mark.svg references rewritten to ../assets/mark.svg so the
  injected <base href="/bns/hermes.x/messenger/"> resolves them to the
  site-root assets/ directory, not messenger/assets/.

- shim.js — ~540 lines implementing window.silentmode.{invoke,on,off,
  storage,sidebar}. invoke handlers: init(nsec | mnemonic | create),
  status, inbox, send, contactsList, contactsAdd, markThreadRead,
  getProfile (stub), signOut, canUseVault (always false in a browser),
  phone/backup/updateProfile stubs for the Theseus-only paths. Crypto
  is pure nostr-tools from jsdelivr ESM — nip44 v2 for the content key,
  wrapChat / unwrapChat mirroring TheseusNavigator/lib/hermes.js exactly.
  Keys: nsec1 via nip19, BIP-39 via WebCrypto PBKDF2+HKDF with info
  "silentmode/messenger/0" (same path as the Theseus host).

  Name resolution hits /api/name/<name> for np + nr; the shim parses np
  as hex(64) or npub1, nr as whitespace/comma-separated wss:// URLs,
  with wss://nos.lol as the default when the owner publishes no nr.

  localStorage falls back to an in-memory Map when it throws — the
  navigate.st relay serves BNS sites under a CSP sandbox without
  allow-same-origin, which disables localStorage. Signed in state
  survives the tab's lifetime; reload re-prompts for the key. Loaded
  outside the sandbox (Theseus direct origin), localStorage works and
  state persists across sessions.
2026-10-22 02:21:56 +03:00

973 lines
46 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hermes.X — Messenger</title>
<meta name="description" content="Hermes browser messenger — end-to-end encrypted DMs over Nostr, addressed by on-chain registered name. Sign in with an nsec or a 12/24-word mnemonic.">
<link rel="icon" href="../assets/favicon.svg" type="image/svg+xml">
<link rel="mask-icon" href="../assets/favicon.svg" color="#38BDF8">
<meta property="og:title" content="Hermes.X — Messenger">
<meta property="og:description" content="Browser messenger for the hermes.x name-addressed Nostr DMs.">
<meta property="og:image" content="https://hermes.x/assets/mark.svg">
<meta name="twitter:card" content="summary">
<link rel="stylesheet" href="../assets/shared.css">
<script src="shim.js"></script>
<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; }
/* ---- site-nav wrap -----------------------------------------------------
The panel.html upstream assumes it owns the whole viewport (body has no
margin; .screen is absolute inset:44px 0 0 0 against the body). When
served as a stand-alone webapp page we also want the hermes.x site nav
at the top so users can get back to Overview / Branding / home without
the browser back-button. Keep the panel's own .bar (relay status + sign
out live there) and put it under the site nav; .screen stays absolute
but now anchors to .panel-root instead of the viewport. */
body { display: flex; flex-direction: column; }
nav.topnav { flex: 0 0 auto; }
.panel-root { position: relative; flex: 1 1 auto; min-height: 0; }
.panel-root .bar { position: relative; }
/* The settings/add-contact modals cover the panel surface, not the site
nav — leave the nav visible so users can navigate out mid-dialog. */
.panel-root .modal-bg { position: absolute; inset: 0; }
/* === 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>
<nav class="topnav">
<a class="brand" href="../">
<img src="../assets/mark.svg" alt="Hermes" width="22" height="22" style="display:block">
<span>Hermes<span class="x">.X</span></span>
</a>
<a href="../overview/">Overview</a>
<a href="./" class="here">Messenger</a>
<a href="../branding/">Branding</a>
<a href="https://theseus.x/" class="get">Get Theseus</a>
</nav>
<div class="panel-root">
<!-- Top bar — present on both screens -->
<div class="bar">
<div class="mk" aria-hidden="true">
<img src="../assets/mark.svg" 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.svg" 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="nsec1… or 12/24-word mnemonic — stays in this tab, never sent to the server" 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>
</div><!-- /.panel-root -->
<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>