hermes: Nostr kind:0 profiles — display name, picture, about

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 <userData>/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.
This commit is contained in:
Local Dev 2026-10-21 17:18:07 +03:00
parent cee34c47e5
commit b5b3e5c1b2
4 changed files with 270 additions and 9 deletions

View file

@ -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",
];

View file

@ -194,6 +194,15 @@
<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="scheme" 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">hermes:alice</button>
@ -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; }

View file

@ -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;

126
main.js
View file

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