Shortcut: Ctrl+Shift+M -> Ctrl+Shift+E. Chrome owns Ctrl+Shift+M as
'switch profile' and E matches the Greek spelling (Ermis).
Sidebar discoverability: new bundled add-on at bundled-addons/hermes/
registers a left-strip icon + launcher panel. The panel is a thin chrome
(mark + 'Open Messages' + shortcut hint) that forwards clicks through
api.onMessage -> ipcMain.emit('hermes-open-request') -> openHermesWindow.
All real messaging code stays in main.js — add-on is purely for discovery.
Address book: opt-in vCard (.vcf) import. Dialog in the Add Contact modal
lets the user pick a file; main.js parses BEGIN:VCARD blocks for X-BNS-NAME
/ X-NOSTR-NPUB / X-NOSTR-PUBKEY plus .bch names and npubs mentioned in
NOTE fields. Candidates are previewed per-contact with checkboxes; nothing
is added until the user confirms. Non-matching vCards are returned too (so
the user can still add manually) rather than silently dropped. Nothing is
uploaded; parsing is local.
site/hermes: status badge flipped from 'designed - not yet built' to
'MVP - desktop'. New 'What ships today' grid covers threaded chat, send by
name, address book, local history (all live) and mobile + Tor (next /
planned). Hero carries the new Ctrl+Shift+E shortcut and a link to
/hermes/branding.
site/hermes/branding: new page. Mark SVG (winged envelope, two variants
for 16-20px and 24-160px display sizes), palette (reuses Silent Mode
tokens + an amber accent that's distinctly warm-yellow, not Hermes-orange
— so it never collides with Hermès the fashion house), name explanation
(English Hermes / Greek Ermis), voice notes, do/don't table, MPL-2.0
attribution.
Hermes/MOBILE-RECOMMENDATION.md: analysis of three mobile paths (inside
Ariadne / standalone / hybrid). Recommendation is Option 1: ship as a
screen inside Ariadne. Reuses the wallet seed, aligns with the design
doc's 'one seed many purposes' architecture, lowest total cost.
594 lines
24 KiB
HTML
594 lines
24 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Messages — Theseus</title>
|
|
<style>
|
|
:root{
|
|
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --panel3:#1d2634;
|
|
--line:rgba(255,255,255,.09); --line2:rgba(255,255,255,.14);
|
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
|
--ok:#4fd1a5; --err:#f6768a; --warn:#ffc75f;
|
|
--me:#2a3a1e; /* outgoing bubble */
|
|
--them:#1d2634; /* incoming bubble */
|
|
}
|
|
*{box-sizing:border-box}
|
|
html,body{height:100%}
|
|
body{
|
|
margin:0; background:var(--bg); color:var(--ink);
|
|
font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
|
display:flex; flex-direction:column; overflow:hidden;
|
|
}
|
|
header{
|
|
padding:10px 14px; border-bottom:1px solid var(--line);
|
|
background:rgba(11,14,20,.9); display:flex; align-items:center; gap:12px;
|
|
flex-wrap:wrap;
|
|
}
|
|
header .mark{font-size:20px}
|
|
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.2px}
|
|
#ident{
|
|
color:var(--mut); font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
font-size:12px; margin-left:auto; user-select:text;
|
|
}
|
|
#ident b{color:var(--acid); font-weight:500}
|
|
#status{
|
|
font-size:11px; padding:2px 8px; border-radius:999px;
|
|
background:var(--panel); color:var(--mut);
|
|
}
|
|
#status.ok{color:var(--ok); background:rgba(79,209,165,.12)}
|
|
#status.err{color:var(--err); background:rgba(246,118,138,.12)}
|
|
|
|
main{flex:1; min-height:0}
|
|
#setup, #app{padding:0; overflow:hidden; height:100%; display:none}
|
|
#setup{display:none; padding:16px; overflow:auto}
|
|
body.mode-setup #setup{display:block}
|
|
body.mode-app #app{display:grid; grid-template-columns:260px 1fr}
|
|
|
|
/* ---------- SETUP ---------- */
|
|
#setup{max-width:540px; margin:0 auto; align-self:start}
|
|
.lede{color:var(--mut); font-size:13px; margin:0 0 12px}
|
|
.warn{
|
|
font-size:12px; color:var(--warn); padding:8px 10px;
|
|
background:rgba(255,199,95,.08); border:1px solid rgba(255,199,95,.24);
|
|
border-radius:8px; margin-bottom:12px;
|
|
}
|
|
.row{display:flex; gap:8px; align-items:center}
|
|
code.inline{
|
|
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
background:rgba(255,255,255,.05); padding:1px 5px; border-radius:4px; font-size:12px;
|
|
}
|
|
input, textarea, button, select{
|
|
font:inherit; color:var(--ink); background:var(--panel);
|
|
border:1px solid var(--line); border-radius:8px; padding:9px 11px;
|
|
}
|
|
textarea{resize:vertical; min-height:44px; max-height:200px; width:100%}
|
|
input{width:100%}
|
|
input:focus, textarea:focus, select:focus{outline:none; border-color:var(--acid)}
|
|
button{cursor:pointer; background:var(--acid); color:#0a0d13; font-weight:600; border-color:transparent}
|
|
button.ghost{background:var(--panel); color:var(--ink); border:1px solid var(--line); font-weight:400}
|
|
button:hover{filter:brightness(1.08)}
|
|
button:disabled{opacity:.5; cursor:default}
|
|
label{display:block; margin-bottom:12px; color:var(--mut); font-size:12px}
|
|
label > textarea, label > input{margin-top:6px}
|
|
#setupErr{color:var(--err); font-size:12px; min-height:1em; margin-top:8px}
|
|
|
|
/* ---------- APP (threaded) ---------- */
|
|
#sidebar{
|
|
display:flex; flex-direction:column; min-height:0;
|
|
background:var(--panel); border-right:1px solid var(--line);
|
|
}
|
|
#sideHead{
|
|
padding:10px 10px 6px; display:flex; gap:6px; align-items:center;
|
|
border-bottom:1px solid var(--line);
|
|
}
|
|
#sideHead input{padding:7px 9px; font-size:13px}
|
|
#addBtn{padding:7px 10px; font-size:13px}
|
|
#contactsList{overflow:auto; flex:1; min-height:0}
|
|
.contactRow{
|
|
padding:9px 12px; cursor:pointer; border-bottom:1px solid rgba(255,255,255,.03);
|
|
display:flex; align-items:center; gap:8px;
|
|
}
|
|
.contactRow:hover{background:var(--panel2)}
|
|
.contactRow.active{background:var(--panel3)}
|
|
.contactRow .avatar{
|
|
width:30px; height:30px; border-radius:50%; flex-shrink:0;
|
|
background:linear-gradient(135deg, #2a3a4a, #1a2432);
|
|
display:flex; align-items:center; justify-content:center;
|
|
font-size:12px; color:var(--mut); font-weight:600; text-transform:uppercase;
|
|
}
|
|
.contactRow .meta{min-width:0; flex:1}
|
|
.contactRow .name{
|
|
font-size:13px; font-weight:500; color:var(--ink);
|
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
}
|
|
.contactRow .last{
|
|
font-size:11px; color:var(--mut);
|
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
}
|
|
.contactRow .unread{
|
|
background:var(--acid); color:#0a0d13; font-size:10px; font-weight:700;
|
|
padding:1px 6px; border-radius:999px; min-width:18px; text-align:center;
|
|
}
|
|
#sidebarFoot{padding:8px 10px; border-top:1px solid var(--line); font-size:11px; color:var(--dim)}
|
|
|
|
#thread{display:flex; flex-direction:column; min-height:0; background:var(--bg)}
|
|
#threadHead{
|
|
padding:12px 16px; border-bottom:1px solid var(--line);
|
|
display:flex; align-items:center; gap:12px;
|
|
}
|
|
#threadHead .name{font-weight:600; font-size:14px}
|
|
#threadHead .pk{font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; color:var(--mut)}
|
|
#threadHead .trash{
|
|
margin-left:auto; background:none; border:none; color:var(--dim); cursor:pointer;
|
|
padding:4px 8px; font-size:12px;
|
|
}
|
|
#threadHead .trash:hover{color:var(--err)}
|
|
#threadBody{
|
|
flex:1; overflow:auto; padding:12px 16px;
|
|
display:flex; flex-direction:column; gap:6px;
|
|
}
|
|
.bubble{max-width:70%; padding:7px 11px; border-radius:14px; line-height:1.4}
|
|
.bubble .body{white-space:pre-wrap; word-break:break-word}
|
|
.bubble .time{display:block; font-size:10px; color:var(--dim); margin-top:3px}
|
|
.bubble.in{background:var(--them); align-self:flex-start; border-bottom-left-radius:4px}
|
|
.bubble.out{background:var(--me); align-self:flex-end; border-bottom-right-radius:4px}
|
|
.empty{color:var(--dim); text-align:center; padding:32px 16px; font-size:13px}
|
|
#composeRow{display:flex; gap:8px; padding:10px 14px; border-top:1px solid var(--line); background:var(--panel)}
|
|
#composeRow textarea{flex:1; min-height:38px; max-height:160px; resize:none}
|
|
#sendBtn{padding:0 18px}
|
|
#sendStatus{font-size:11px; color:var(--mut); padding:0 14px 6px; min-height:12px}
|
|
|
|
/* ---------- Add contact / new thread modal ---------- */
|
|
.modal-bg{
|
|
position:fixed; inset:0; background:rgba(0,0,0,.5);
|
|
display:none; align-items:center; justify-content:center; z-index:30;
|
|
}
|
|
.modal-bg.show{display:flex}
|
|
.modal{
|
|
width:380px; max-width:90vw; background:var(--panel); border:1px solid var(--line2);
|
|
border-radius:12px; padding:16px;
|
|
}
|
|
.modal h2{margin:0 0 10px; font-size:14px}
|
|
.modal .buttons{display:flex; gap:8px; justify-content:flex-end; margin-top:10px}
|
|
.modal label{margin-bottom:10px}
|
|
#addErr{color:var(--err); font-size:12px; min-height:1em}
|
|
</style>
|
|
</head>
|
|
<body class="mode-setup">
|
|
|
|
<header>
|
|
<span class="mark">⛓️✉️</span>
|
|
<h1>Hermes Messages</h1>
|
|
<span id="status">connecting…</span>
|
|
<span id="ident"></span>
|
|
</header>
|
|
|
|
<main>
|
|
|
|
<!-- SETUP SCREEN -->
|
|
<section id="setup">
|
|
<p class="lede">Hermes derives its Nostr identity at
|
|
<code class="inline">silentmode/messenger/0</code> — a purpose disjoint from your
|
|
wallet's transaction key, so a compromised relay never yields tx-signing power.
|
|
Message history is stored locally on this device, encrypted at rest.</p>
|
|
<div class="warn">This is a proof build. Use a wallet you don't hold funds in.</div>
|
|
|
|
<div id="vaultBind" style="display:none; margin-bottom:16px">
|
|
<div class="row">
|
|
<button id="btnVault">Sign in with password vault</button>
|
|
<span style="color:var(--mut); font-size:12px">recommended — no mnemonic to re-type</span>
|
|
</div>
|
|
<div style="text-align:center; color:var(--dim); font-size:12px; margin:14px 0">— or —</div>
|
|
</div>
|
|
|
|
<label>
|
|
Mnemonic (12 or 24 words)
|
|
<textarea id="mnemonic" placeholder="word word word …" autocomplete="off" spellcheck="false"></textarea>
|
|
</label>
|
|
<div class="row">
|
|
<button id="btnInit">Open messages</button>
|
|
</div>
|
|
<div id="setupErr"></div>
|
|
</section>
|
|
|
|
<!-- APP (threaded) -->
|
|
<section id="app">
|
|
<aside id="sidebar">
|
|
<div id="sideHead">
|
|
<input id="search" placeholder="Search contacts…" autocomplete="off">
|
|
<button id="addBtn" title="Add contact / start new chat">+</button>
|
|
</div>
|
|
<div id="contactsList"></div>
|
|
<div id="sidebarFoot">Chats update live as messages arrive.</div>
|
|
</aside>
|
|
<section id="thread">
|
|
<div id="threadHead" style="display:none">
|
|
<div>
|
|
<div class="name" id="threadName">—</div>
|
|
<div class="pk" id="threadPk">—</div>
|
|
</div>
|
|
<button class="trash" id="btnRemoveContact" title="Remove this contact">remove</button>
|
|
</div>
|
|
<div id="threadBody">
|
|
<div class="empty">Pick a contact on the left — or hit <b>+</b> to add one.</div>
|
|
</div>
|
|
<div id="composeRow" style="display:none">
|
|
<textarea id="composeText" placeholder="Message…" rows="1"></textarea>
|
|
<button id="sendBtn">Send</button>
|
|
</div>
|
|
<div id="sendStatus"></div>
|
|
</section>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<!-- Add contact modal -->
|
|
<div class="modal-bg" id="addModal">
|
|
<div class="modal">
|
|
<h2>Add contact</h2>
|
|
<label>
|
|
Name (.bch), npub, or 64-char hex pubkey
|
|
<input id="addName" placeholder="alice.bch" autocomplete="off">
|
|
</label>
|
|
<label>
|
|
Nickname (optional)
|
|
<input id="addNick" placeholder="Alice" autocomplete="off">
|
|
</label>
|
|
<div id="addErr"></div>
|
|
<div class="buttons" style="justify-content:space-between">
|
|
<button class="ghost" id="importBtn" title="Import contacts from a .vcf file">Import vCard…</button>
|
|
<div style="display:flex; gap:8px">
|
|
<button class="ghost" id="addCancel">Cancel</button>
|
|
<button id="addSave">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import preview modal: a reviewable list of parsed contacts. Each row is
|
|
user-togglable; nothing is added without an explicit "Import N contacts" click.
|
|
This is the "opt-in and reviewable" part of the address-book integration. -->
|
|
<div class="modal-bg" id="importModal">
|
|
<div class="modal" style="width:520px">
|
|
<h2>Import contacts <span id="importFrom" style="font-size:11px; color:var(--mut); font-weight:400"></span></h2>
|
|
<p class="lede" style="font-size:12px">Nothing is sent anywhere. Pick who you want to add; the rest are ignored.</p>
|
|
<div id="importList" style="max-height:300px; overflow:auto; margin:10px 0; background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:4px">
|
|
<div class="empty" style="padding:20px">No importable contacts found.</div>
|
|
</div>
|
|
<div id="importErr" style="color:var(--err); font-size:12px; min-height:1em"></div>
|
|
<div class="buttons">
|
|
<button class="ghost" id="importCancel">Close</button>
|
|
<button id="importConfirm">Import selected</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
const setMode = (m) => { document.body.className = "mode-" + m; };
|
|
const setStatus = (text, cls = "") => {
|
|
const el = $("status"); el.textContent = text; el.className = cls;
|
|
};
|
|
const shortPk = (pk) => pk ? pk.slice(0, 8) + "…" + pk.slice(-4) : "";
|
|
// Hermes is a .bch-first product — the TLD is noise in a chat list.
|
|
const displayName = (name) => name && name.endsWith(".bch") ? name.slice(0, -4) : name;
|
|
const initials = (n) => {
|
|
if (!n) return "?";
|
|
const s = String(n).replace(/\.bch$/,"").trim();
|
|
return s.slice(0, 2);
|
|
};
|
|
const fmtTime = (ts) => {
|
|
if (!ts) return "";
|
|
const d = new Date(ts * 1000);
|
|
const sameDay = d.toDateString() === new Date().toDateString();
|
|
if (sameDay) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
return d.toLocaleDateString([], { month: "short", day: "numeric" }) + " "
|
|
+ d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
};
|
|
|
|
// ---- State mirror of the main-side inbox ----
|
|
// Grouped by peerPkHex so the UI can be contact-centric.
|
|
let state = {
|
|
messages: [], // raw list, in arrival order
|
|
readState: {}, // peerPkHex -> latest createdAt the user has acknowledged
|
|
contacts: [], // address book from main
|
|
myPkHex: null,
|
|
selectedPk: null, // currently open thread
|
|
};
|
|
|
|
function threadsFromMessages() {
|
|
// Group messages by the OTHER party's pkHex. Each thread carries:
|
|
// { peerPkHex, peerName, messages: [...], lastAt, unreadCount }
|
|
const byPk = new Map();
|
|
for (const m of state.messages) {
|
|
const peerPk = (m.peerPkHex || m.senderPkHex || "").toLowerCase();
|
|
if (!peerPk) continue;
|
|
let t = byPk.get(peerPk);
|
|
if (!t) {
|
|
t = { peerPkHex: peerPk, peerName: null, messages: [], lastAt: 0, unreadCount: 0 };
|
|
byPk.set(peerPk, t);
|
|
}
|
|
// Prefer a non-null name from any message in this thread.
|
|
if (!t.peerName && m.peerName) t.peerName = m.peerName;
|
|
t.messages.push(m);
|
|
if (m.createdAt > t.lastAt) t.lastAt = m.createdAt;
|
|
}
|
|
// Union with contacts so an empty thread (just added) still shows up.
|
|
for (const c of state.contacts) {
|
|
const k = (c.pkHex || "").toLowerCase();
|
|
if (!k) continue;
|
|
if (!byPk.has(k)) byPk.set(k, { peerPkHex: k, peerName: c.nickname || c.name, messages: [], lastAt: 0, unreadCount: 0 });
|
|
else if (!byPk.get(k).peerName) byPk.get(k).peerName = c.nickname || c.name;
|
|
}
|
|
// Unread = incoming messages newer than the readState for that peer.
|
|
for (const t of byPk.values()) {
|
|
const read = state.readState[t.peerPkHex] || 0;
|
|
t.unreadCount = t.messages.filter((m) => m.direction === "in" && m.createdAt > read).length;
|
|
}
|
|
return [...byPk.values()].sort((a, b) => b.lastAt - a.lastAt);
|
|
}
|
|
|
|
function renderContacts() {
|
|
const list = $("contactsList");
|
|
const q = $("search").value.trim().toLowerCase();
|
|
const threads = threadsFromMessages();
|
|
const filtered = q
|
|
? threads.filter((t) => (t.peerName || "").toLowerCase().includes(q)
|
|
|| t.peerPkHex.includes(q))
|
|
: threads;
|
|
if (!filtered.length) {
|
|
list.innerHTML = '<div class="empty">No chats yet.<br>Hit <b>+</b> to start one.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = "";
|
|
for (const t of filtered) {
|
|
const row = document.createElement("div");
|
|
row.className = "contactRow" + (t.peerPkHex === state.selectedPk ? " active" : "");
|
|
const label = displayName(t.peerName) || shortPk(t.peerPkHex);
|
|
const lastMsg = t.messages[t.messages.length - 1];
|
|
const lastText = lastMsg
|
|
? (lastMsg.direction === "out" ? "you: " : "") + lastMsg.text
|
|
: "no messages yet";
|
|
row.innerHTML = `
|
|
<div class="avatar"></div>
|
|
<div class="meta">
|
|
<div class="name"></div>
|
|
<div class="last"></div>
|
|
</div>
|
|
${t.unreadCount > 0 ? `<span class="unread">${t.unreadCount}</span>` : ""}
|
|
`;
|
|
row.querySelector(".avatar").textContent = initials(label);
|
|
row.querySelector(".name").textContent = label;
|
|
row.querySelector(".last").textContent = lastText;
|
|
row.addEventListener("click", () => selectThread(t.peerPkHex));
|
|
list.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function renderThread() {
|
|
const body = $("threadBody");
|
|
const head = $("threadHead");
|
|
const compose = $("composeRow");
|
|
if (!state.selectedPk) {
|
|
head.style.display = "none";
|
|
compose.style.display = "none";
|
|
body.innerHTML = '<div class="empty">Pick a contact on the left — or hit <b>+</b> to add one.</div>';
|
|
return;
|
|
}
|
|
const threads = threadsFromMessages();
|
|
const t = threads.find((x) => x.peerPkHex === state.selectedPk);
|
|
const label = (t && displayName(t.peerName)) || shortPk(state.selectedPk);
|
|
head.style.display = "flex";
|
|
$("threadName").textContent = label;
|
|
$("threadPk").textContent = state.selectedPk;
|
|
compose.style.display = "flex";
|
|
|
|
if (!t || !t.messages.length) {
|
|
body.innerHTML = '<div class="empty">No messages yet. Say hi.</div>';
|
|
return;
|
|
}
|
|
body.innerHTML = "";
|
|
for (const m of t.messages) {
|
|
const b = document.createElement("div");
|
|
b.className = "bubble " + (m.direction === "out" ? "out" : "in");
|
|
const txt = document.createElement("div");
|
|
txt.className = "body";
|
|
txt.textContent = m.text; // never innerHTML for untrusted content
|
|
const t2 = document.createElement("span");
|
|
t2.className = "time";
|
|
t2.textContent = fmtTime(m.createdAt);
|
|
b.appendChild(txt); b.appendChild(t2);
|
|
body.appendChild(b);
|
|
}
|
|
body.scrollTop = body.scrollHeight;
|
|
|
|
// Mark this thread read up to the latest message, if any incoming is newer.
|
|
const latestIn = Math.max(0, ...t.messages.filter((m) => m.direction === "in").map((m) => m.createdAt));
|
|
const curRead = state.readState[state.selectedPk] || 0;
|
|
if (latestIn > curRead) {
|
|
state.readState[state.selectedPk] = latestIn;
|
|
hermes.markThreadRead(state.selectedPk, latestIn).catch(() => {});
|
|
renderContacts(); // refresh unread badges
|
|
}
|
|
}
|
|
|
|
function selectThread(pkHex) {
|
|
state.selectedPk = pkHex.toLowerCase();
|
|
renderContacts();
|
|
renderThread();
|
|
}
|
|
|
|
async function reloadAll() {
|
|
const [ibx, cs] = await Promise.all([hermes.inbox(), hermes.contacts.list()]);
|
|
if (ibx && ibx.ok) {
|
|
state.messages = ibx.messages || [];
|
|
state.readState = ibx.readState || {};
|
|
}
|
|
if (cs && cs.ok) state.contacts = cs.contacts || [];
|
|
renderContacts();
|
|
renderThread();
|
|
}
|
|
|
|
// ---- Setup flow ----
|
|
function afterInit(res) {
|
|
if (!res.ok) { $("setupErr").textContent = "error: " + res.err; return false; }
|
|
state.myPkHex = res.pkHex;
|
|
$("ident").innerHTML = "your npub: <b>" + res.npub.slice(0, 12) + "…" + res.npub.slice(-6) + "</b>"
|
|
+ (res.source ? " <span style='color:var(--dim); font-size:11px'>(" + res.source + ")</span>" : "");
|
|
setStatus(res.relaysConnected + "/" + res.relaysTotal + " relays",
|
|
res.relaysConnected > 0 ? "ok" : "");
|
|
setMode("app");
|
|
reloadAll();
|
|
return true;
|
|
}
|
|
|
|
$("btnInit").addEventListener("click", async () => {
|
|
const mn = $("mnemonic").value.trim();
|
|
if (!mn) { $("setupErr").textContent = "enter a mnemonic"; return; }
|
|
$("btnInit").disabled = true; $("setupErr").textContent = "";
|
|
const res = await hermes.init(mn);
|
|
$("btnInit").disabled = false;
|
|
afterInit(res);
|
|
});
|
|
|
|
$("btnVault").addEventListener("click", async () => {
|
|
$("btnVault").disabled = true; $("setupErr").textContent = "";
|
|
const res = await hermes.initFromVault();
|
|
$("btnVault").disabled = false;
|
|
afterInit(res);
|
|
});
|
|
|
|
// ---- App events ----
|
|
$("search").addEventListener("input", renderContacts);
|
|
|
|
$("addBtn").addEventListener("click", () => {
|
|
$("addName").value = ""; $("addNick").value = ""; $("addErr").textContent = "";
|
|
$("addModal").classList.add("show"); $("addName").focus();
|
|
});
|
|
$("addCancel").addEventListener("click", () => $("addModal").classList.remove("show"));
|
|
$("addSave").addEventListener("click", async () => {
|
|
const name = $("addName").value.trim();
|
|
const nick = $("addNick").value.trim() || null;
|
|
if (!name) { $("addErr").textContent = "enter a name or key"; return; }
|
|
$("addSave").disabled = true; $("addErr").textContent = "";
|
|
const res = await hermes.contacts.add(name, nick);
|
|
$("addSave").disabled = false;
|
|
if (!res.ok) { $("addErr").textContent = res.err; return; }
|
|
$("addModal").classList.remove("show");
|
|
state.contacts = res.contacts || [];
|
|
renderContacts();
|
|
if (res.contact && res.contact.pkHex) selectThread(res.contact.pkHex);
|
|
});
|
|
|
|
// ---- vCard import flow (opt-in) ----
|
|
$("importBtn").addEventListener("click", async () => {
|
|
$("addErr").textContent = "";
|
|
const res = await hermes.contacts.importPick();
|
|
if (!res.ok) { $("addErr").textContent = "import: " + res.err; return; }
|
|
const candidates = res.candidates || [];
|
|
if (!candidates.length) { $("addErr").textContent = "nothing to import from that file"; return; }
|
|
// Switch to the import preview modal.
|
|
$("addModal").classList.remove("show");
|
|
$("importFrom").textContent = res.fromFile ? "from " + res.fromFile : "";
|
|
$("importList").innerHTML = "";
|
|
$("importErr").textContent = "";
|
|
const importable = candidates.filter((c) => c.importable);
|
|
if (!importable.length) {
|
|
$("importList").innerHTML = '<div class="empty" style="padding:20px">No contacts in that file carry a .bch name or Nostr pubkey.<br><small>You can still add them manually one at a time.</small></div>';
|
|
} else {
|
|
for (const c of importable) {
|
|
const row = document.createElement("label");
|
|
row.style.cssText = "display:flex; gap:10px; padding:8px 10px; align-items:center; cursor:pointer; border-radius:6px;";
|
|
const cb = document.createElement("input");
|
|
cb.type = "checkbox"; cb.checked = true;
|
|
cb.dataset.identifier = c.identifier;
|
|
cb.dataset.nickname = c.displayName || "";
|
|
const meta = document.createElement("div");
|
|
meta.style.cssText = "flex:1; min-width:0";
|
|
const title = document.createElement("div");
|
|
title.style.cssText = "font-weight:500; font-size:13px; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap";
|
|
title.textContent = c.displayName || c.identifier;
|
|
const sub = document.createElement("div");
|
|
sub.style.cssText = "font-size:11px; color:var(--mut); font-family:ui-monospace,monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap";
|
|
sub.textContent = c.identifier;
|
|
meta.appendChild(title); meta.appendChild(sub);
|
|
row.appendChild(cb); row.appendChild(meta);
|
|
$("importList").appendChild(row);
|
|
}
|
|
}
|
|
$("importModal").classList.add("show");
|
|
});
|
|
$("importCancel").addEventListener("click", () => $("importModal").classList.remove("show"));
|
|
$("importConfirm").addEventListener("click", async () => {
|
|
const picked = Array.from($("importList").querySelectorAll('input[type=checkbox]:checked'));
|
|
if (!picked.length) { $("importErr").textContent = "nothing selected"; return; }
|
|
$("importConfirm").disabled = true;
|
|
let added = 0, failed = 0;
|
|
for (const cb of picked) {
|
|
const res = await hermes.contacts.add(cb.dataset.identifier, cb.dataset.nickname || null);
|
|
if (res && res.ok) added++; else failed++;
|
|
}
|
|
$("importConfirm").disabled = false;
|
|
$("importErr").textContent = `added ${added}, failed ${failed}`;
|
|
if (added) await reloadAll();
|
|
if (added && !failed) {
|
|
setTimeout(() => $("importModal").classList.remove("show"), 1200);
|
|
}
|
|
});
|
|
|
|
$("btnRemoveContact").addEventListener("click", async () => {
|
|
if (!state.selectedPk) return;
|
|
if (!confirm("Remove this contact? Message history for this thread stays on disk.")) return;
|
|
await hermes.contacts.remove(state.selectedPk);
|
|
state.selectedPk = null;
|
|
await reloadAll();
|
|
});
|
|
|
|
$("composeText").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendCurrent(); }
|
|
});
|
|
$("sendBtn").addEventListener("click", sendCurrent);
|
|
|
|
async function sendCurrent() {
|
|
const text = $("composeText").value.trim();
|
|
if (!text || !state.selectedPk) return;
|
|
$("sendBtn").disabled = true;
|
|
$("sendStatus").textContent = "sending…";
|
|
// Send by pkHex (direct) — recipient is already known for an existing thread.
|
|
const res = await hermes.send(state.selectedPk, text);
|
|
$("sendBtn").disabled = false;
|
|
if (!res.ok) {
|
|
$("sendStatus").textContent = "error: " + res.err;
|
|
return;
|
|
}
|
|
$("sendStatus").textContent = `sent to ${res.accepted}/${res.total} relay(s)`;
|
|
$("composeText").value = "";
|
|
// The main-side send handler records the outgoing message via hermesRecord,
|
|
// which pushes a "hermes-message" to us — that triggers reloadAll + rerender.
|
|
setTimeout(() => { $("sendStatus").textContent = ""; }, 3000);
|
|
}
|
|
|
|
// ---- Pushes from main ----
|
|
hermes.onMessage(() => reloadAll());
|
|
hermes.onStatus((s) => {
|
|
setStatus(s.relaysConnected + "/" + s.relaysTotal + " relays",
|
|
s.relaysConnected > 0 ? "ok" : "");
|
|
});
|
|
|
|
// ---- Boot ----
|
|
hermes.status().then(async (r) => {
|
|
if (r && r.ok && r.ready) {
|
|
state.myPkHex = r.pkHex;
|
|
$("ident").innerHTML = "your npub: <b>" + r.npub.slice(0, 12) + "…" + r.npub.slice(-6) + "</b>";
|
|
setStatus(r.relaysConnected + "/" + r.relaysTotal + " relays", "ok");
|
|
setMode("app");
|
|
reloadAll();
|
|
return;
|
|
}
|
|
setStatus("locked");
|
|
const v = await hermes.canUseVault();
|
|
if (v && v.ok && v.available) $("vaultBind").style.display = "block";
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|