theseus/bundled-addons/hermes/panel.html
Local Dev bbe5d3d298 hermes: cloud-wrapped backup (opt-in B) + mobile site polish
Users can now sign in with name + password on a device that has never seen the
account by opting into a cloud backup at account creation. The seed phrase is
locally re-wrapped with scrypt + AES-GCM keyed by the password and published
as a NIP-78 kind:30078 event under d-tag `hermes.backup.v1/<sha256(name)>`.
Relays only ever see opaque ciphertext; the password is the only decryption
key, so backup strength is password strength.

Signin tries the local wrap first (fast, no network) and falls back to the
cloud copy if the device hasn't been used before. Settings → Backup now has
"Back up to cloud" / "Re-upload" / "Remove cloud backup" controls that reuse
the Reveal-seed password field as the key.

Site: added @media breakpoints to shared.css so the header collapses cleanly
on narrow screens (icon-only brand under 380px, hidden "Sign in" label under
640px), the hero mark drops to 84px, h1 scales down, and buttons shrink to
touch sizes. Also fixed a stale reference to the removed `mnemonicBox` id in
the Create-account toggle that was throwing a TypeError on mobile.
2026-10-22 03:46:45 +03:00

1130 lines
56 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;
max-height: calc(100vh - 24px); overflow-y: auto; -webkit-overflow-scrolling: touch; }
.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.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="bare" 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</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">Address book</div>
<input id="vcardFileInput" type="file" accept=".vcf,text/vcard,text/x-vcard" style="display:none">
<button class="secondary" id="importVcardBtn" style="font:inherit;font-size:12.5px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 12px;cursor:pointer;width:100%;margin-bottom:6px">Import contacts from vCard</button>
<div class="err" id="vcardErr" style="color:var(--err);font-size:11.5px;min-height:1em"></div>
<div id="vcardResult" style="color:var(--dim);font-size:11px;min-height:1em"></div>
<div style="color:var(--dim);font-size:11px;margin-bottom:14px">
Open a .vcf export from your contacts. We parse it locally, look up any linked phone numbers against Hermes, and show the matches to add — nothing uploaded.
</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>
<!-- Cloud backup controls — opt-in B. Visible only when a local wrap
exists (otherwise there's nothing to upload). The password field
above doubles as the key for publishing/removing the cloud copy. -->
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Cloud backup</div>
<div id="cloudBackupStatus" style="font-size:11.5px;color:var(--mut);margin-bottom:6px"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button id="cloudBackupBtn" style="flex:1;min-width:120px;font:inherit;font-size:12px;padding:7px 10px;background:var(--sky);color:var(--sky-ink);border:0;border-radius:6px;cursor:pointer;font-weight:600">Back up to cloud</button>
<button id="cloudRemoveBtn" style="flex:1;min-width:120px;font:inherit;font-size:12px;padding:7px 10px;background:transparent;color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer;display:none">Remove cloud backup</button>
</div>
<div id="cloudBackupErr" style="color:var(--err);font-size:11.5px;min-height:1em;margin-top:4px"></div>
<div style="font-size:11px;color:var(--dim);margin-top:4px">Lets you sign in with name + password on another device. Your password is the key; relays only see opaque ciphertext.</div>
</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>
</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%">
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--mut);text-align:left;cursor:pointer">
<input id="createCloud" type="checkbox" style="margin-top:3px;accent-color:var(--sky)">
<span>Back up to the cloud so I can sign in on other devices with this name + password. (You can turn this off in Settings. Your password is the only key; relays never see the seed.)</span>
</label>
<button class="primary" id="useCreateBtn">Create account</button>
<div class="hint">You'll get a handle like <code>alice.erm</code>. We'll generate a seed phrase — back it up in Settings.</div>
</div>
<div style="display:flex;align-items:center;gap:10px;width:100%;max-width:300px;margin-top:8px">
<span style="flex:1;height:1px;background:var(--line)"></span>
<span style="color:var(--mut);font-size:11.5px;letter-spacing:.4px;text-transform:uppercase">Sign in</span>
<span style="flex:1;height:1px;background:var(--line)"></span>
</div>
<div class="signin-chips" style="display:flex;gap:6px;width:100%;max-width:300px">
<button class="chip chip-active" data-method="namepw" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--sky);color:#06111a;border:1px solid var(--sky);border-radius:6px;cursor:pointer;font-weight:600">Name + password</button>
<button class="chip" data-method="seed" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer">Seed phrase</button>
</div>
<div id="methodNamepw" class="method" style="width:100%;max-width:300px;display:flex;flex-direction:column;gap:8px">
<input id="signinHandle" type="text" placeholder="alice" autocomplete="username" 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="signinPassword" type="password" placeholder="password" autocomplete="current-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%">
<div class="hint">Works on this device, or on any device if you turned on cloud backup. Otherwise use seed phrase.</div>
</div>
<div id="methodSeed" class="method" style="width:100%;max-width:300px;display:none;flex-direction:column;gap:8px">
<textarea id="mnemonicInput" placeholder="nsec1… or 12/24-word mnemonic — stays in this tab, never sent to the server" autocomplete="off" spellcheck="false" style="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;width:100%;min-height:86px;resize:vertical"></textarea>
<div class="hint">Paste your 12 or 24 words.</div>
</div>
<button class="primary" id="signinBtn" style="width:100%;max-width:300px;margin-top:4px">Sign in</button>
<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 a 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 · alice.erm · +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: "bare", // "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 local; // bare mode
}
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 && ["bare", "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", () => {
$("createBox").classList.toggle("show");
if ($("createBox").classList.contains("show")) $("createHandle").focus();
});
$("useCreateBtn").addEventListener("click", async () => {
const handle = $("createHandle").value.trim();
const p1 = $("createPassword").value;
const p2 = $("createPassword2").value;
const wantCloud = $("createCloud").checked;
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; }
await initWithOpts({ create: true, handle, password: p1 });
if (wantCloud && state.ready) {
try {
const r = await bridge.invoke("publishCloudBackup", { password: p1 });
if (!r || !r.ok) console.warn("cloud backup publish failed:", r && r.err);
} catch (e) { console.warn("cloud backup publish threw:", e); }
}
});
// --- unified Sign in button + chip switcher ---
function switchMethod(name) {
for (const c of document.querySelectorAll(".signin-chips .chip")) {
const active = c.dataset.method === name;
c.classList.toggle("chip-active", active);
c.style.background = active ? "var(--sky)" : "var(--panel)";
c.style.color = active ? "#06111a" : "var(--ink)";
c.style.borderColor = active ? "var(--sky)" : "var(--line)";
c.style.fontWeight = active ? "600" : "400";
}
$("methodNamepw").style.display = name === "namepw" ? "flex" : "none";
$("methodSeed").style.display = name === "seed" ? "flex" : "none";
state._signinMethod = name;
const focusEl = name === "namepw" ? $("signinHandle") : $("mnemonicInput");
setTimeout(() => focusEl && focusEl.focus(), 50);
}
state._signinMethod = "namepw";
for (const c of document.querySelectorAll(".signin-chips .chip")) {
c.addEventListener("click", () => switchMethod(c.dataset.method));
}
$("signinBtn").addEventListener("click", async () => {
const m = state._signinMethod || "namepw";
err($("signinErr"), "");
if (m === "namepw") {
const handle = $("signinHandle").value.trim();
const password = $("signinPassword").value;
if (!handle) { err($("signinErr"), "enter your name"); return; }
if (!password) { err($("signinErr"), "enter your password"); return; }
const btn = $("signinBtn"); const prev = btn.textContent;
btn.disabled = true; btn.textContent = "Signing in\u2026";
try {
// Try the local wrap first — fastest path, no network. If this device
// never saw the account, fall back to the cloud backup (opt-in B).
let mnemonic = null;
const r = await bridge.invoke("getBackup", { password });
if (r && r.ok) {
mnemonic = r.mnemonic;
} else {
btn.textContent = "Looking up backup…";
const rc = await bridge.invoke("fetchCloudBackup", { name: handle, password });
if (!rc || !rc.ok) {
throw new Error((rc && rc.err) || (r && r.err) || "wrong password or no account found");
}
mnemonic = rc.mnemonic;
}
const r2 = await bridge.invoke("init", { mnemonic, handle, remember: true });
if (!r2 || !r2.ok) throw new Error((r2 && r2.err) || "sign-in failed");
showApp({ pkHex: r2.pkHex, npub: r2.npub, handle: r2.handle || handle });
} catch (e) {
err($("signinErr"), (e && e.message) || String(e));
} finally { btn.disabled = false; btn.textContent = prev; }
} else {
const mnemonic = $("mnemonicInput").value.trim();
if (!mnemonic || mnemonic.split(/\s+/).length < 12) {
err($("signinErr"), "paste a 12- or 24-word mnemonic"); return;
}
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"));
// --- vCard import (Address book) ---
$("importVcardBtn").addEventListener("click", () => $("vcardFileInput").click());
$("vcardFileInput").addEventListener("change", async () => {
const f = $("vcardFileInput").files && $("vcardFileInput").files[0];
if (!f) return;
err($("vcardErr"), "");
$("vcardResult").textContent = "Reading " + f.name + "\u2026";
try {
const text = await f.text();
// Minimal RFC 6350 subset: pull FN + TEL per VCARD block.
const entries = [];
for (const block of text.split(/BEGIN:VCARD/i).slice(1)) {
const body = block.split(/END:VCARD/i)[0] || "";
const fn = (body.match(/^FN(?:;[^:]*)?:(.+)$/mi) || [])[1];
const tel = (body.match(/^TEL(?:;[^:]*)?:(.+)$/mi) || [])[1];
if (!tel) continue;
entries.push({ name: (fn || "").trim(), phone: tel.trim() });
}
if (!entries.length) { $("vcardResult").textContent = "No phone numbers in that file."; return; }
$("vcardResult").textContent = "Looking up " + entries.length + " number(s) on Hermes\u2026";
const r = await bridge.invoke("findByPhones", { phones: entries.map(e => e.phone) });
if (!r || !r.ok) throw new Error((r && r.err) || "lookup failed");
const found = r.matches || [];
if (!found.length) { $("vcardResult").textContent = "No matches among your contacts yet."; return; }
let added = 0;
for (const m of found) {
const label = (entries.find(e => e.phone.replace(/\D+/g, "") === m.phone.replace(/\D+/g, "")) || {}).name || m.phone;
try {
const a = await bridge.invoke("contactsAdd", { name: m.pkHex, nickname: label });
if (a && a.ok) added++;
} catch {}
}
$("vcardResult").textContent = "Added " + added + " of " + found.length + " matches. Refresh the chat list.";
try { const r2 = await bridge.invoke("contactsList"); if (r2 && r2.ok) { state.contacts = r2.contacts || []; renderContacts(); } } catch {}
} catch (e) {
err($("vcardErr"), (e && e.message) || String(e));
$("vcardResult").textContent = "";
} finally {
$("vcardFileInput").value = "";
}
});
$("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"), "");
// Cloud backup status.
err($("cloudBackupErr"), "");
try {
const r = await bridge.invoke("hasCloudBackup");
const has = !!(r && r.ok && r.hasCloudBackup);
$("cloudBackupStatus").textContent = has
? "Backed up to the cloud" + (r.at ? " · " + new Date(r.at).toLocaleDateString() : "")
: "Not backed up to the cloud";
$("cloudBackupStatus").style.color = has ? "var(--ok,#4fd1a5)" : "var(--mut)";
$("cloudBackupBtn").textContent = has ? "Re-upload backup" : "Back up to cloud";
$("cloudRemoveBtn").style.display = has ? "inline-block" : "none";
} catch {}
$("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; }
});
$("cloudBackupBtn").addEventListener("click", async () => {
const password = $("backupPassword").value;
err($("cloudBackupErr"), "");
if (!password) { err($("cloudBackupErr"), "enter your password above"); return; }
const btn = $("cloudBackupBtn"); const prev = btn.textContent;
btn.disabled = true; btn.textContent = "Uploading…";
try {
const r = await bridge.invoke("publishCloudBackup", { password });
if (!r || !r.ok) throw new Error((r && r.err) || "upload failed");
$("cloudBackupStatus").textContent = "Backed up to the cloud (" + r.accepted + "/" + r.total + " relays)";
$("cloudBackupStatus").style.color = "var(--ok,#4fd1a5)";
$("cloudRemoveBtn").style.display = "inline-block";
btn.textContent = "Re-upload backup";
} catch (e) {
err($("cloudBackupErr"), (e && e.message) || String(e));
btn.textContent = prev;
} finally { btn.disabled = false; }
});
$("cloudRemoveBtn").addEventListener("click", async () => {
err($("cloudBackupErr"), "");
if (!confirm("Remove the cloud backup for this account? You'll no longer be able to sign in with name + password on a new device.")) return;
const btn = $("cloudRemoveBtn"); const prev = btn.textContent;
btn.disabled = true; btn.textContent = "Removing…";
try {
const r = await bridge.invoke("removeCloudBackup");
if (!r || !r.ok) throw new Error((r && r.err) || "remove failed");
$("cloudBackupStatus").textContent = "Not backed up to the cloud";
$("cloudBackupStatus").style.color = "var(--mut)";
$("cloudBackupBtn").textContent = "Back up to cloud";
btn.style.display = "none";
} catch (e) {
err($("cloudBackupErr"), (e && e.message) || String(e));
btn.textContent = prev;
} finally { btn.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>