Hermes: Ermis shortcut (Ctrl+Shift+E), sidebar add-on, vCard import, brand kit
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.
This commit is contained in:
parent
2821453278
commit
8eae448dde
6 changed files with 291 additions and 6 deletions
14
bundled-addons/hermes/addon.json
Normal file
14
bundled-addons/hermes/addon.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"id": "hermes",
|
||||
"name": "Hermes",
|
||||
"version": "0.1.0",
|
||||
"description": "Message anyone by their .bch name. End-to-end encrypted DMs over Nostr (NIP-17), identity derived from your wallet seed.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZDZmZjNkIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iMjAiIHk9IjIzIiB3aWR0aD0iMjQiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PHBhdGggZD0iTTIwIDI1IEwzMiAzNSBMNDQgMjUiLz48cGF0aCBkPSJNMjAgMzAgTDcgMjciLz48cGF0aCBkPSJNMjAgMzQgTDggMzYiLz48cGF0aCBkPSJNNDQgMzAgTDU3IDI3Ii8+PHBhdGggZD0iTTQ0IDM0IEw1NiAzNiIvPjwvZz48L3N2Zz4K",
|
||||
"main": "index.js",
|
||||
"activation": "on-demand",
|
||||
"panels": [
|
||||
{ "id": "main", "title": "Hermes", "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZDZmZjNkIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iMjAiIHk9IjIzIiB3aWR0aD0iMjQiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PHBhdGggZD0iTTIwIDI1IEwzMiAzNSBMNDQgMjUiLz48cGF0aCBkPSJNMjAgMzAgTDcgMjciLz48cGF0aCBkPSJNMjAgMzQgTDggMzYiLz48cGF0aCBkPSJNNDQgMzAgTDU3IDI3Ii8+PHBhdGggZD0iTTQ0IDM0IEw1NiAzNiIvPjwvZz48L3N2Zz4K", "page": "panel.html" }
|
||||
],
|
||||
"capabilities": ["sidebar-panel"]
|
||||
}
|
||||
33
bundled-addons/hermes/index.js
Normal file
33
bundled-addons/hermes/index.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Hermes bundled add-on — thin launcher.
|
||||
//
|
||||
// Real messaging code (NIP-17 wrap/unwrap, relay subscriptions, persistent
|
||||
// inbox, contacts) lives in Theseus main.js: too much raw network + crypto to
|
||||
// run under the add-on sandbox. This add-on exists for ONE reason: register a
|
||||
// left-strip sidebar entry so the Messages panel is discoverable without
|
||||
// pressing Ctrl+Shift+E.
|
||||
//
|
||||
// The panel (panel.html) is a small chrome: mark, "Open Messages" button,
|
||||
// shortcut hint. Click → forwarded through api.onMessage → an in-process
|
||||
// ipcMain.emit that main.js listens for and resolves to openHermesWindow().
|
||||
// Keeping the forwarding tiny: nothing crypto-sensitive flows through this
|
||||
// add-on, so a compromise of this one-handler surface can at worst open an
|
||||
// otherwise-openable window.
|
||||
const { ipcMain } = require("electron");
|
||||
|
||||
module.exports = {
|
||||
activate(api) {
|
||||
api.registerSidebarPanel({
|
||||
id: "main",
|
||||
title: "Hermes",
|
||||
page: "panel.html",
|
||||
});
|
||||
api.onMessage("open-messages", () => {
|
||||
// In-process emit: main.js listens on "hermes-open-request" and calls
|
||||
// openHermesWindow(). Using emit (not .send) because this is a
|
||||
// same-process signal, not an IPC to a renderer.
|
||||
ipcMain.emit("hermes-open-request");
|
||||
return { ok: true };
|
||||
});
|
||||
api.log("registered hermes sidebar panel");
|
||||
},
|
||||
};
|
||||
69
bundled-addons/hermes/panel.html
Normal file
69
bundled-addons/hermes/panel.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||||
--amber:#f4c430; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
|
||||
--acid:#088A66; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); color: var(--ink);
|
||||
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 24px 20px; text-align: center; gap: 14px; }
|
||||
.mark { color: var(--acid); line-height: 0; }
|
||||
.mark svg { width: 72px; height: 72px; display: block; }
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: .3px; }
|
||||
.lede { color: var(--mut); font-size: 13px; margin: 0; max-width: 260px; }
|
||||
button { font: inherit; font-weight: 600; cursor: pointer;
|
||||
background: var(--acid); color: #0a0d13; border: none;
|
||||
padding: 10px 20px; border-radius: 999px; }
|
||||
button:hover { filter: brightness(1.08); }
|
||||
.shortcut { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
|
||||
.shortcut kbd { background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
|
||||
font-size: 10.5px; color: var(--ink); }
|
||||
.err { color: #f6768a; font-size: 12px; min-height: 1em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="20" y="23" width="24" height="18" rx="2"/>
|
||||
<path d="M20 25 L32 35 L44 25"/>
|
||||
<path d="M20 30 L7 27"/>
|
||||
<path d="M20 34 L8 36"/>
|
||||
<path d="M44 30 L57 27"/>
|
||||
<path d="M44 34 L56 36"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Hermes</h1>
|
||||
<p class="lede">Message anyone by their <code>.bch</code> name. Sealed-sender DMs over Nostr.</p>
|
||||
<button id="openBtn">Open Messages</button>
|
||||
<div class="shortcut">or press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>E</kbd></div>
|
||||
<div class="err" id="err"></div>
|
||||
|
||||
<script>
|
||||
const bridge = window.silentmode && typeof window.silentmode.invoke === "function" ? window.silentmode : null;
|
||||
const err = (m) => { document.getElementById("err").textContent = m || ""; };
|
||||
document.getElementById("openBtn").addEventListener("click", async () => {
|
||||
err("");
|
||||
if (!bridge) { err("sidebar bridge unavailable"); return; }
|
||||
try {
|
||||
const r = await bridge.invoke("open-messages");
|
||||
if (!r || r.ok !== true) err("could not open messages window");
|
||||
} catch (e) { err("error: " + (e && e.message || e)); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
95
main.js
95
main.js
|
|
@ -1004,6 +1004,7 @@ const HERMES_ONLY = new Set([
|
|||
"hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close",
|
||||
"hermes-inbox", "hermes-send",
|
||||
"hermes-contacts-list", "hermes-contacts-add", "hermes-contacts-update", "hermes-contacts-remove",
|
||||
"hermes-contacts-import-pick",
|
||||
"hermes-thread-mark-read",
|
||||
]);
|
||||
const SETTINGS_SHARED = new Set([
|
||||
|
|
@ -9542,7 +9543,7 @@ ipcMain.handle("switch-to-bcnr", () => {
|
|||
});
|
||||
|
||||
// ---- Hermes messages panel -------------------------------------------------
|
||||
// A separate BrowserWindow (opens on Ctrl+Shift+M anywhere in Theseus). Uses
|
||||
// A separate BrowserWindow (opens on Ctrl+Shift+E anywhere in Theseus). Uses
|
||||
// the wallet mnemonic to derive the Nostr identity in-memory only — the seed
|
||||
// and secret key are never written to disk. The panel process holds one
|
||||
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
|
||||
|
|
@ -9922,6 +9923,83 @@ ipcMain.handle("hermes-contacts-remove", (_e, { pkHex } = {}) => {
|
|||
return hOk({ removed: before - hermesContactsCache.length, contacts: hermesContactsCache });
|
||||
});
|
||||
|
||||
// Address book import — strictly opt-in.
|
||||
//
|
||||
// The user picks a .vcf file. We read it, parse out candidate contacts
|
||||
// (anything carrying X-BNS or X-NOSTR fields, or a note mentioning *.bch /
|
||||
// npub1…), and return the candidates to the renderer for a preview + confirm
|
||||
// pass. Nothing is written to the contact store until the renderer calls
|
||||
// hermes-contacts-add per candidate — so the user sees exactly who gets added.
|
||||
//
|
||||
// We never phone home: parsing is local, the file path never leaves the
|
||||
// machine. Non-matching vCards are returned too (grey'd out in the UI) so
|
||||
// the user can decide to add them manually; nothing is auto-ignored.
|
||||
ipcMain.handle("hermes-contacts-import-pick", async () => {
|
||||
try {
|
||||
const r = await dialog.showOpenDialog(hermesWin || win, {
|
||||
title: "Import contacts (vCard)",
|
||||
filters: [{ name: "vCard", extensions: ["vcf", "vcard"] }, { name: "All files", extensions: ["*"] }],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
if (r.canceled || !r.filePaths?.length) return hOk({ candidates: [] });
|
||||
const raw = fs.readFileSync(r.filePaths[0], "utf8");
|
||||
return hOk({ candidates: parseVcardContacts(raw), fromFile: path.basename(r.filePaths[0]) });
|
||||
} catch (e) { return hErr(e); }
|
||||
});
|
||||
|
||||
// Minimal RFC 6350 subset: BEGIN:VCARD / END:VCARD blocks, FN / N / NOTE,
|
||||
// plus the two custom extensions we care about:
|
||||
// X-BNS-NAME:alice.bch
|
||||
// X-NOSTR-NPUB:npub1… (or X-NOSTR-PUBKEY:<64-hex>)
|
||||
// Also surfaces anything in NOTE that looks like a .bch name or an npub so
|
||||
// a user whose vCards came from another messenger can still carry contacts.
|
||||
function parseVcardContacts(text) {
|
||||
const BNS_RE = /\b([a-z0-9-]{1,32}(?:\.[a-z0-9-]{1,16})?)\.bch\b/i;
|
||||
const NPUB_RE = /\b(npub1[0-9a-z]{58,})\b/i;
|
||||
const HEX_RE = /\b([0-9a-f]{64})\b/i;
|
||||
const out = [];
|
||||
// RFC 6350 says vCards can contain unfolded continuations starting with a
|
||||
// space; join them before splitting by line.
|
||||
const unfolded = text.replace(/\r?\n[ \t]/g, "");
|
||||
const blocks = unfolded.split(/BEGIN:VCARD/i).slice(1);
|
||||
for (const b of blocks) {
|
||||
const body = b.split(/END:VCARD/i)[0] || "";
|
||||
const fields = {};
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const m = /^([A-Z0-9-]+)(?:;[^:]*)?:(.*)$/i.exec(line.trim());
|
||||
if (!m) continue;
|
||||
const key = m[1].toUpperCase();
|
||||
// Multi-valued fields: keep the first for simplicity.
|
||||
if (!(key in fields)) fields[key] = m[2];
|
||||
}
|
||||
const fn = (fields.FN || fields.N || "").trim();
|
||||
let bnsName = (fields["X-BNS-NAME"] || fields["X-BNS"] || "").trim();
|
||||
let npub = (fields["X-NOSTR-NPUB"] || fields["X-NOSTR"] || "").trim();
|
||||
let hex = (fields["X-NOSTR-PUBKEY"] || fields["X-NOSTR-HEX"] || "").trim();
|
||||
const note = (fields.NOTE || "").trim();
|
||||
if (!bnsName && note) {
|
||||
const m = BNS_RE.exec(note); if (m) bnsName = m[0];
|
||||
}
|
||||
if (!npub && note) {
|
||||
const m = NPUB_RE.exec(note); if (m) npub = m[1];
|
||||
}
|
||||
if (!hex && note) {
|
||||
const m = HEX_RE.exec(note); if (m) hex = m[1];
|
||||
}
|
||||
const identifier = bnsName || npub || hex || null;
|
||||
if (!fn && !identifier) continue;
|
||||
out.push({
|
||||
displayName: fn || null,
|
||||
bnsName: bnsName || null,
|
||||
npub: npub || null,
|
||||
hex: hex || null,
|
||||
identifier, // what to pass to hermes-contacts-add
|
||||
importable: !!identifier,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
|
||||
// via the portable resolver (getResolver above), the `np` record parsed, then
|
||||
// wrapped + published to each relay listed in `nr` (falling back to defaults).
|
||||
|
|
@ -10117,6 +10195,13 @@ function openHermesWindow() {
|
|||
|
||||
ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; });
|
||||
|
||||
// In-process signal fired by the Hermes bundled add-on (bundled-addons/hermes).
|
||||
// Add-ons run under the sandbox and can't call openHermesWindow directly, so
|
||||
// they emit this and we resolve it here. Separate from the IPC channel so the
|
||||
// HERMES_ONLY gate doesn't need to grow a sender exception for sandboxed add-on
|
||||
// code — ipcMain.emit never carries a webContents sender.
|
||||
ipcMain.on("hermes-open-request", () => { try { openHermesWindow(); } catch {} });
|
||||
|
||||
// Dev-only auto-init: `THESEUS_HERMES_DEV_MNEMONIC=...` + `--messages` launches
|
||||
// straight into an initialised panel. Only active when THESEUS_USER_DATA is
|
||||
// also set (dev-profile isolation) — refuses to touch a production profile.
|
||||
|
|
@ -10275,8 +10360,10 @@ app.on("web-contents-created", (_event, wc) => {
|
|||
// popup belongs to one browser window).
|
||||
wc.on("before-input-event", (e, input) => withWin(winOfWebContents(wc), () => {
|
||||
if (input.type !== "keyDown") return;
|
||||
// Ctrl+Shift+M -> Messages panel
|
||||
if (input.control && input.shift && (input.key === "M" || input.key === "m")) {
|
||||
// Ctrl+Shift+E -> Messages (Hermes / Ermis, Greek Ἑρμῆς). Picked E over the
|
||||
// old M because Ctrl+Shift+M is Chrome's "Switch profile" (users muscle-
|
||||
// memory it) and because E matches the Greek spelling.
|
||||
if (input.control && input.shift && (input.key === "E" || input.key === "e")) {
|
||||
openHermesWindow();
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
|
@ -10396,7 +10483,7 @@ function handleLaunch(argv) {
|
|||
const appUrl = webapps.appUrlFromArgv(argv);
|
||||
if (appUrl) { if (webapps.launch(appUrl)) return; openInMainTab(appUrl); return; }
|
||||
// `--messages` flag opens the Messages panel on launch — same as pressing
|
||||
// Ctrl+Shift+M. Primary caller is the dev harness (focus-steal is blocked
|
||||
// Ctrl+Shift+E. Primary caller is the dev harness (focus-steal is blocked
|
||||
// when the user is actively in another window) but it's also a reasonable
|
||||
// CLI entry point for `theseus --messages`.
|
||||
const openMsg = Array.isArray(argv) && argv.some((a) => a === "--messages");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ contextBridge.exposeInMainWorld("hermes", {
|
|||
add: (name, nickname) => ipcRenderer.invoke("hermes-contacts-add", { name, nickname }),
|
||||
update: (pkHex, patch) => ipcRenderer.invoke("hermes-contacts-update", { pkHex, patch }),
|
||||
remove: (pkHex) => ipcRenderer.invoke("hermes-contacts-remove", { pkHex }),
|
||||
// Opt-in import: opens an OS file-picker for a .vcf. Returns parsed
|
||||
// candidates for the renderer to preview; nothing is added without a
|
||||
// follow-up add() call per contact. Convenience only — the full add
|
||||
// flow (manual +) is never gated behind this.
|
||||
importPick: () => ipcRenderer.invoke("hermes-contacts-import-pick"),
|
||||
},
|
||||
|
||||
// Pushes: new decrypted message / relay status change.
|
||||
|
|
|
|||
|
|
@ -235,9 +235,30 @@
|
|||
<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="addCancel">Cancel</button>
|
||||
<button id="addSave">Add</button>
|
||||
<button class="ghost" id="importCancel">Close</button>
|
||||
<button id="importConfirm">Import selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -459,6 +480,62 @@ $("addSave").addEventListener("click", async () => {
|
|||
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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue