From b5b3e5c1b27213cebcf7f4fd60147fe6e11b7f3a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 21 Oct 2026 17:18:07 +0300 Subject: [PATCH] =?UTF-8?q?hermes:=20Nostr=20kind:0=20profiles=20=E2=80=94?= =?UTF-8?q?=20display=20name,=20picture,=20about?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peers' chat-list labels now cascade: contact nickname → kind:0 display name → BNS handle → pk preview. Avatars render as a URL picture when kind:0 carries one, with initials as fallback. Profiles are fetched on demand (background, non-blocking) when a new peer pk shows up in the inbox or contact list, and cached on disk so a cold start renders instantly. - lib/hermes.js: buildProfileEvent({sk, displayName, picture, about}) emits a signed kind:0 with `name` + `display_name` set together (clients vary on which they prefer). parseProfileContent(content) handles both. - main.js: profile cache at /hermes/profiles.json; auto-fetch from HERMES_DEFAULT_RELAYS with a 4 s per-relay cap. hermesPublishEvent centralises relay publish (used by profile save; send() retains its inlined copy for the DRY-up later). hermesRecord() kicks off a background profile fetch the first time a peer is seen; result fires on the hermes-profile bus event. - main.js: _hermesAPI.updateProfile / getProfile. Addon proxies both. - panel.html: Settings → Profile editor (display name, picture URL, about); publishes on save, pre-fills from the cache. Picture validated as http(s). labelFor() + avatarFor() centralise the display cascade. Profile state lives in state.profiles. --- bundled-addons/hermes/index.js | 1 + bundled-addons/hermes/panel.html | 112 ++++++++++++++++++++++++--- lib/hermes.js | 40 ++++++++++ main.js | 126 +++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 9 deletions(-) diff --git a/bundled-addons/hermes/index.js b/bundled-addons/hermes/index.js index 80e33e61..8236c268 100644 --- a/bundled-addons/hermes/index.js +++ b/bundled-addons/hermes/index.js @@ -19,6 +19,7 @@ const { ipcMain } = require("electron"); const PANEL_METHODS = [ "status", "canUseVault", "close", "init", "signOut", "getBackup", "hasBackup", + "getProfile", "updateProfile", "inbox", "send", "markThreadRead", "contactsList", "contactsAdd", "contactsUpdate", "contactsRemove", ]; diff --git a/bundled-addons/hermes/panel.html b/bundled-addons/hermes/panel.html index 976a9a90..951cc224 100644 --- a/bundled-addons/hermes/panel.html +++ b/bundled-addons/hermes/panel.html @@ -194,6 +194,15 @@
—
—
+
Profile
+ + + + +
+
Published to relays as Nostr metadata. Independent of your handle — anyone can see it, change anytime.
+ +
Display handles as
@@ -312,6 +321,7 @@ const state = { selected: null, // peerPkHex (lowercase) of focused thread threads: new Map(), // peerPkHex → { peerName, messages: [...] } renderMode: "scheme", // "scheme" (hermes:alice) | "dotted" | "email" + profiles: new Map(), // peerPkHex → { displayName, picture, about } }; // Must stay in sync with HERMES_TLD / HERMES_EMAIL_PARENT in lib/hermes.js — @@ -350,6 +360,47 @@ function initials(name) { 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)); @@ -393,21 +444,21 @@ function renderContacts() { const list = $("contactsList"); list.textContent = ""; const q = $("search").value.trim().toLowerCase(); - const nameOf = new Map(); - for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || renderHandle(c.name)); const items = []; for (const [pk, t] of state.threads) { - const label = (t.peerName && renderHandle(t.peerName)) || nameOf.get(pk) || pk.slice(0, 10) + "…"; + 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 = c.nickname || renderHandle(c.name) || c.pkHex.slice(0, 10) + "…"; + 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)); @@ -420,7 +471,7 @@ function renderContacts() { ? (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", text: initials(it.label) }), + 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 }), @@ -445,9 +496,7 @@ function renderThread() { return; } const t = state.threads.get(pk); - const nameOf = new Map(); - for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || renderHandle(c.name)); - const label = (t && t.peerName && renderHandle(t.peerName)) || nameOf.get(pk) || pk.slice(0, 16) + "…"; + const label = labelFor(pk, t && t.peerName); $("threadName").textContent = label; $("threadPk").textContent = pk; head.style.display = "flex"; @@ -614,6 +663,12 @@ async function initWithOpts(opts) { 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). @@ -689,7 +744,18 @@ async function openSettings() { $("settingsHandle").textContent = m.handle ? renderHandle(m.handle) : "(no handle set)"; $("settingsNpub").textContent = m.npub || m.pkHex || "—"; paintRenderModes(); - // Query the host for whether a backup blob exists on disk. + + // 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 {} + let hasBackup = false; try { const r = await bridge.invoke("hasBackup"); @@ -704,6 +770,34 @@ async function openSettings() { $("settingsModal").classList.add("show"); } +$("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; } diff --git a/lib/hermes.js b/lib/hermes.js index 64e157b5..f93f327e 100644 --- a/lib/hermes.js +++ b/lib/hermes.js @@ -200,6 +200,46 @@ export function parseHermesRecords(entry, { defaultRelays = [] } = {}) { }; } +// ---- Kind 0 profile (display metadata) ------------------------------------- +// NIP-01 defines kind:0 as "set_metadata" — a user-authored event whose +// content is a JSON blob with human-readable fields. Standard fields we +// touch: `name`, `display_name`, `picture`, `about`. Nostr clients fetch +// kind:0 by author pubkey, newest wins; the sender's `name` renders in +// chat lists next to messages. +// +// Hermes keeps the unique handle (BNS registration) and the display metadata +// (kind:0) orthogonal — the handle is for addressing, the metadata is for +// how you WANT to appear. Users can leave kind:0 empty and still receive +// messages; conversely a kind:0 with just a nickname is enough to make the +// chat list look like a normal chat list without going on-chain. +export function buildProfileEvent({ sk, displayName, picture, about, extra } = {}) { + if (!sk) throw new Error("sk required"); + const content = { ...(extra || {}) }; + if (displayName) { content.display_name = String(displayName); content.name = String(displayName); } + if (picture) content.picture = String(picture); + if (about) content.about = String(about); + return finalizeEvent({ + kind: 0, + content: JSON.stringify(content), + tags: [], + created_at: Math.floor(Date.now() / 1000), + }, sk); +} + +export function parseProfileContent(content) { + if (!content || typeof content !== "string") return null; + try { + const o = JSON.parse(content); + if (!o || typeof o !== "object") return null; + return { + displayName: typeof o.display_name === "string" ? o.display_name : + typeof o.name === "string" ? o.name : null, + picture: typeof o.picture === "string" ? o.picture : null, + about: typeof o.about === "string" ? o.about : null, + }; + } catch { return null; } +} + // ---- NIP-17 wrap / unwrap -------------------------------------------------- const KIND_CHAT = 14; const KIND_SEAL = 13; diff --git a/main.js b/main.js index 3e51450d..75736448 100644 --- a/main.js +++ b/main.js @@ -9776,6 +9776,98 @@ let hermesWin = null; // (nostr-tools expects Uint8Array; converting on demand keeps the surface easier to reason about). let hermesState = null; +// ---- Kind 0 profile cache -------------------------------------------------- +// Keyed by pkHex, lowercased. Entries are { displayName, picture, about, +// fetchedAt } — populated by hermesFetchProfile on-demand, persisted so +// contacts stay named after an offline restart. The user's own profile is +// also kept here so the Settings editor can pre-fill without a network hit. +const hermesProfilesFile = () => path.join(hermesDir(), "profiles.json"); +let hermesProfilesCache = null; +function hermesLoadProfiles() { + if (hermesProfilesCache) return hermesProfilesCache; + try { + if (fs.existsSync(hermesProfilesFile())) { + const raw = JSON.parse(fs.readFileSync(hermesProfilesFile(), "utf8")); + hermesProfilesCache = (raw && typeof raw === "object") ? raw : {}; + } else hermesProfilesCache = {}; + } catch { hermesProfilesCache = {}; } + return hermesProfilesCache; +} +function hermesSaveProfiles() { + hermesEnsureDir(hermesDir()); + try { + fs.writeFileSync(hermesProfilesFile() + ".tmp", JSON.stringify(hermesProfilesCache || {}, null, 2)); + fs.renameSync(hermesProfilesFile() + ".tmp", hermesProfilesFile()); + } catch (e) { console.error("[hermes] profiles save failed:", e.message); } +} +function hermesSetProfile(pkHex, profile) { + if (!pkHex || !profile) return; + const p = hermesLoadProfiles(); + p[pkHex.toLowerCase()] = { ...profile, fetchedAt: Math.floor(Date.now() / 1000) }; + hermesProfilesCache = p; + hermesSaveProfiles(); +} +function hermesGetProfile(pkHex) { + if (!pkHex) return null; + return hermesLoadProfiles()[pkHex.toLowerCase()] || null; +} + +// Fetch kind:0 for pkHex from the default relays, newest wins. One shot per +// relay with a short timeout — chat lists shouldn't wait on a slow relay. +// Already-cached hits short-circuit (callers pass `force: true` to refresh). +async function hermesFetchProfile(pkHex, { force = false } = {}) { + const key = String(pkHex || "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(key)) return null; + if (!force) { + const cached = hermesGetProfile(key); + if (cached) return cached; + } + const H = await loadHermesLib(); + const results = await Promise.all(HERMES_DEFAULT_RELAYS.map((url) => new Promise((resolve) => { + const ws = new WebSocket(url); + let latest = null; + const settle = () => { try { ws.close(1000); } catch {} resolve(latest); }; + const t = setTimeout(settle, 4000); + ws.on("open", () => ws.send(JSON.stringify(["REQ", "p-" + Math.random().toString(36).slice(2, 8), + { kinds: [0], authors: [key], limit: 1 }]))); + ws.on("message", (buf) => { + let msg; try { msg = JSON.parse(buf.toString()); } catch { return; } + if (msg[0] === "EVENT" && msg[2] && msg[2].kind === 0) { + if (!latest || msg[2].created_at > latest.created_at) latest = msg[2]; + } else if (msg[0] === "EOSE") { clearTimeout(t); settle(); } + }); + ws.on("error", () => { clearTimeout(t); settle(); }); + }))); + const freshest = results.filter(Boolean).sort((a, b) => b.created_at - a.created_at)[0]; + if (!freshest) return null; + const parsed = H.parseProfileContent(freshest.content); + if (!parsed) return null; + hermesSetProfile(key, parsed); + return hermesGetProfile(key); +} + +// Shared relay-publish — one event, N relays, bounded wait. Returns per-url +// {ok, detail} results. Used by send() (NIP-17 wraps) and updateProfile() +// (kind:0 metadata). Centralising this means the two paths' retry/timeout +// behaviour can't drift. +async function hermesPublishEvent(ev, relays, { timeoutMs = 8000 } = {}) { + return Promise.all(relays.map((url) => new Promise((resolve) => { + const ws = new WebSocket(url); + let settled = false; + const done = (r) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(r); }; + const t = setTimeout(() => done({ url, ok: false, detail: "timeout" }), timeoutMs); + ws.on("open", () => ws.send(JSON.stringify(["EVENT", ev]))); + ws.on("message", (buf) => { + let msg; try { msg = JSON.parse(buf.toString()); } catch { return; } + if (msg[0] === "OK" && msg[1] === ev.id) { + clearTimeout(t); + done({ url, ok: !!msg[2], detail: msg[3] || "" }); + } + }); + ws.on("error", (e) => done({ url, ok: false, detail: "ws error: " + e.message })); + }))); +} + // ---- handle resolution ----------------------------------------------------- // One shared helper for every "turn an input into a Hermes recipient" callsite // (contactsAdd, send, and their standalone-window ipcMain twins). Takes any @@ -10010,6 +10102,15 @@ function hermesRecord(msg) { // function from the send handler so threads show both sides. if (hermesState.pkHex) hermesAppendInbox(hermesState.pkHex, msg); hermesEmit("hermes-message", msg); + // First time we see a peer pk, kick off a background kind:0 fetch so their + // display name + avatar show up next render. Cached hits short-circuit; a + // relay miss just leaves the current pk-only label alone. + const peer = msg.peerPkHex; + if (peer && !hermesGetProfile(peer)) { + hermesFetchProfile(peer).then((p) => { + if (p) hermesEmit("hermes-profile", { pkHex: peer, profile: p }); + }).catch(() => {}); + } } // Pushed on every relay connect/disconnect so the pill in the panel reflects // reality without polling. Cheap; sent to the renderer whenever a socket @@ -10453,6 +10554,31 @@ global._hermesAPI = { } catch { return hErr("wrong password"); } }, hasBackup: () => hOk({ hasBackup: !!hermesLoadWrappedMnemonic() }), + // Profile (Nostr kind:0) — orthogonal to the on-chain handle. Users can + // leave this empty; a filled profile just makes incoming messages render + // with a nicer display name + picture. + getProfile: async ({ pkHex, force } = {}) => { + const key = (pkHex || (hermesState && hermesState.pkHex) || "").toLowerCase(); + if (!key) return hErr("not initialised, and no pkHex given"); + const cached = hermesGetProfile(key); + if (cached && !force) return hOk({ profile: cached, cached: true }); + const fresh = await hermesFetchProfile(key, { force: !!force }); + return hOk({ profile: fresh || null, cached: false }); + }, + updateProfile: async ({ displayName, picture, about } = {}) => { + if (!hermesState) return hErr("not initialised"); + try { + const H = await loadHermesLib(); + const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex")); + const ev = H.buildProfileEvent({ sk, displayName, picture, about }); + // Cache locally first so the UI reflects the change even if every + // relay rejects. The user's own profile is theirs regardless. + hermesSetProfile(hermesState.pkHex, { displayName: displayName || null, picture: picture || null, about: about || null }); + const results = await hermesPublishEvent(ev, HERMES_DEFAULT_RELAYS); + const accepted = results.filter((r) => r.ok).length; + return hOk({ accepted, total: results.length, results }); + } catch (e) { return hErr(e); } + }, signOut: () => { hermesForgetIdentity(); hermesTeardown();