Theseus's sidebar panel.html had the pre-redesign flow (two primary buttons + Create a new account toggle + Name+password/Seed-phrase chip switcher). Replaced with the same persistent pill tab bar the web messenger uses: Sign in / Sign up visible together, active one filled, inactive outlined; tapping an inactive tab swaps the form below with no back step. "Recover with seed phrase" is a quiet link that opens its own form (with ← Back). The Theseus-only "Open with Theseus ID" quick path now lives inside the Sign in tab's form and hides entirely when the vault is locked — no more disabled button + hint about unlocking the vault cluttering the signin screen. Bumped the bundled addon to 0.2.9.
1159 lines
58 KiB
HTML
1159 lines
58 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 ==========
|
|
Same tab bar as the web messenger: Sign in / Sign up persistent pill,
|
|
active tab filled. The Theseus-only "Open with Theseus ID" quick path
|
|
slots into the Sign in tab's form when the vault is unlocked. -->
|
|
<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>
|
|
|
|
<!-- Persistent tab bar. Clicking an inactive tab swaps the form below. -->
|
|
<div id="startRow" style="display:flex;gap:0;width:100%;max-width:300px;border:1px solid var(--line);border-radius:999px;padding:3px;background:var(--panel)">
|
|
<button class="tab tab-active" id="startSignInBtn" style="flex:1;font:inherit;font-weight:600;font-size:13px;padding:8px 10px;background:var(--sky);color:var(--sky-ink);border:0;border-radius:999px;cursor:pointer">Sign in</button>
|
|
<button class="tab" id="startSignUpBtn" style="flex:1;font:inherit;font-weight:600;font-size:13px;padding:8px 10px;background:transparent;color:var(--ink);border:0;border-radius:999px;cursor:pointer">Sign up</button>
|
|
</div>
|
|
|
|
<!-- Sign-in form (default). Theseus-ID button appears above when the
|
|
vault is unlocked (toggled by boot code based on canUseVault). -->
|
|
<div id="signinForm" style="display:flex;width:100%;max-width:300px;flex-direction:column;gap:8px">
|
|
<button class="primary" id="useVaultBtn" disabled style="display:none">Open with Theseus ID</button>
|
|
<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%">
|
|
<button class="primary" id="signinBtn">Sign in</button>
|
|
</div>
|
|
|
|
<!-- Sign-up form (hidden until Sign up tab active). -->
|
|
<div id="signupForm" style="display:none;width:100%;max-width:300px;flex-direction:column;gap:8px">
|
|
<input id="createHandle" type="text" placeholder="pick a name — 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.</span>
|
|
</label>
|
|
<button class="primary" id="useCreateBtn">Create account</button>
|
|
<div class="hint" style="margin-top:0">We'll generate a seed phrase — back it up in Settings after you sign in.</div>
|
|
</div>
|
|
|
|
<!-- Recover-with-seed form. Reached from the "Recover with seed phrase"
|
|
link, has its own ← Back since recovery is a one-shot flow. -->
|
|
<div id="recoverForm" style="display:none;width:100%;max-width:300px;flex-direction:column;gap:8px">
|
|
<textarea id="mnemonicInput" placeholder="nsec1… or 12/24-word mnemonic" 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>
|
|
<button class="primary" id="recoverBtn">Recover account</button>
|
|
<button class="secondary" id="recoverBack" style="font:inherit;background:transparent;color:var(--mut);border:0;cursor:pointer;font-size:12px;padding:4px">← Back to sign in</button>
|
|
<div class="hint">Stays in this panel, never leaves the device.</div>
|
|
</div>
|
|
|
|
<!-- Hidden legacy button so the old toggleCreateBtn handler doesn't crash. -->
|
|
<div style="display:none">
|
|
<button id="toggleCreateBtn"></button>
|
|
</div>
|
|
|
|
<div id="recoverLinkRow" style="margin-top:6px">
|
|
<a href="#" id="recoverLink" style="color:var(--mut);font-size:12px;text-decoration:underline">Recover with seed phrase</a>
|
|
</div>
|
|
|
|
<div class="err" id="signinErr"></div>
|
|
<div class="hint" id="vaultHint" style="font-size:11px;max-width:300px;text-align:center"></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();
|
|
showSigninView("signin");
|
|
}
|
|
|
|
// One of: "signin" (default), "signup", "recover". Sign in / Sign up tabs
|
|
// stay visible on the first two; recover hides them (its own ← Back).
|
|
function showSigninView(name) {
|
|
const inTabs = name === "signin" || name === "signup";
|
|
$("startRow").style.display = inTabs ? "flex" : "none";
|
|
$("signinForm").style.display = name === "signin" ? "flex" : "none";
|
|
$("signupForm").style.display = name === "signup" ? "flex" : "none";
|
|
$("recoverForm").style.display = name === "recover" ? "flex" : "none";
|
|
$("recoverLinkRow").style.display = inTabs ? "block" : "none";
|
|
|
|
const styleTab = (btn, active) => {
|
|
btn.classList.toggle("tab-active", active);
|
|
btn.style.background = active ? "var(--sky)" : "transparent";
|
|
btn.style.color = active ? "var(--sky-ink)" : "var(--ink)";
|
|
};
|
|
styleTab($("startSignInBtn"), name === "signin");
|
|
styleTab($("startSignUpBtn"), name === "signup");
|
|
|
|
err($("signinErr"), "");
|
|
const focusEl = {
|
|
signin: $("signinHandle"),
|
|
signup: $("createHandle"),
|
|
recover: $("mnemonicInput"),
|
|
}[name];
|
|
if (focusEl) setTimeout(() => focusEl.focus(), 50);
|
|
}
|
|
|
|
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")
|
|
: $("recoverBtn");
|
|
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. The button hides entirely when unavailable so the
|
|
// signin form stays clean on untyped vaults.
|
|
try {
|
|
const v = await bridge.invoke("canUseVault");
|
|
state.canUseVault = !!(v && v.available);
|
|
const btn = $("useVaultBtn");
|
|
btn.disabled = !state.canUseVault;
|
|
btn.style.display = state.canUseVault ? "block" : "none";
|
|
$("vaultHint").textContent = state.canUseVault
|
|
? "Uses the same wallet seed as Theseus — nothing new to remember."
|
|
: "";
|
|
} 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: tab switcher + recover link ----
|
|
$("startSignInBtn").addEventListener("click", () => showSigninView("signin"));
|
|
$("startSignUpBtn").addEventListener("click", () => showSigninView("signup"));
|
|
$("recoverLink").addEventListener("click", (e) => { e.preventDefault(); showSigninView("recover"); });
|
|
$("recoverBack").addEventListener("click", () => showSigninView("signin"));
|
|
showSigninView("signin");
|
|
|
|
// Theseus-only quick path: unlock Hermes with the vault seed.
|
|
$("useVaultBtn").addEventListener("click", () => initWithOpts({ useVault: true }));
|
|
|
|
$("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); }
|
|
}
|
|
});
|
|
|
|
$("signinBtn").addEventListener("click", async () => {
|
|
err($("signinErr"), "");
|
|
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; }
|
|
}
|
|
});
|
|
|
|
// Submit: Recover with seed phrase
|
|
$("recoverBtn").addEventListener("click", () => {
|
|
err($("signinErr"), "");
|
|
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>
|