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();