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:
parent
cee34c47e5
commit
b5b3e5c1b2
4 changed files with 270 additions and 9 deletions
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
126
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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue