@decentnetwork/beagle 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.20";
1
+ window.__DK_UI_VERSION="0.1.22";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -2956,17 +2956,110 @@ function DkEnsCard({ T, me, onRecord }) {
2956
2956
  };
2957
2957
  const field = { flex: 1, minWidth: 0, height: 34, borderRadius: 8, border: "1px solid var(--line)", background: "var(--panel-2)", color: "var(--text)", fontFamily: "var(--mono)", fontSize: 13, padding: "0 10px", outline: "none" };
2958
2958
  const row = { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid var(--line)" };
2959
+ const rowLbl = { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 };
2960
+ const SOCIALS = [
2961
+ { k: "twitter", base: "https://x.com/" },
2962
+ { k: "linkedin", base: "https://www.linkedin.com/in/" },
2963
+ { k: "github", base: "https://github.com/" }
2964
+ ];
2965
+ const [draft, setDraft] = React.useState({});
2966
+ React.useEffect(() => {
2967
+ const t = st.record && st.record.texts || {};
2968
+ setDraft({ twitter: t["com.twitter"] || "", linkedin: t["com.linkedin"] || "", github: t["com.github"] || "" });
2969
+ }, [st.record]);
2970
+ const saveSocial = (k) => post("/api/ens-social", { key: k, value: (draft[k] || "").trim() });
2971
+ const socialRow = ({ k, base }, last) => {
2972
+ var _a;
2973
+ const saved = (st.record && st.record.texts || {})["com." + k] || "";
2974
+ const cur = (_a = draft[k]) != null ? _a : "";
2975
+ return /* @__PURE__ */ React.createElement("div", { key: k, style: { ...row, borderBottom: last ? "none" : row.borderBottom } }, /* @__PURE__ */ React.createElement("span", { style: rowLbl }, k), /* @__PURE__ */ React.createElement(
2976
+ "input",
2977
+ {
2978
+ value: cur,
2979
+ onChange: (e) => setDraft({ ...draft, [k]: e.target.value }),
2980
+ disabled: busy || !st.mineOwned,
2981
+ placeholder: T.linkPlaceholder || "username or profile URL",
2982
+ onKeyDown: (e) => {
2983
+ if (e.key === "Enter")
2984
+ saveSocial(k);
2985
+ },
2986
+ style: field
2987
+ }
2988
+ ), saved && /* @__PURE__ */ React.createElement(
2989
+ "a",
2990
+ {
2991
+ href: base + saved,
2992
+ target: "_blank",
2993
+ rel: "noreferrer",
2994
+ title: base + saved,
2995
+ style: { flexShrink: 0, fontFamily: "var(--mono)", fontSize: 13, color: "var(--accent)", textDecoration: "none" }
2996
+ },
2997
+ "\u2197"
2998
+ ), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !st.mineOwned || cur.trim() === saved, onClick: () => saveSocial(k) }, saved && !cur.trim() ? T.ensUnbind || "unbind" : T.save || "save"));
2999
+ };
2959
3000
  const [picking, setPicking] = React.useState(false);
3001
+ const fileRef = React.useRef(null);
2960
3002
  const rec = st.record;
2961
3003
  const boundEth = rec && rec.addresses && rec.addresses["60"];
2962
3004
  const boundSol = rec && rec.addresses && rec.addresses["501"];
2963
3005
  const punkId = rec && rec.nft === "CryptoPunks" && rec.nftid > 0 ? rec.nftid : null;
3006
+ const upAvatar = rec && rec.texts && rec.texts.avatar || null;
2964
3007
  const pickPunk = (id) => {
2965
3008
  setPicking(false);
2966
3009
  post("/api/ens-avatar", { nftid: id });
2967
3010
  };
3011
+ const onUploadFile = (e) => {
3012
+ const f = e.target.files && e.target.files[0];
3013
+ e.target.value = "";
3014
+ if (!f)
3015
+ return;
3016
+ setBusy(true);
3017
+ setMsg(null);
3018
+ const img = new Image();
3019
+ const url = URL.createObjectURL(f);
3020
+ const fail = (text) => {
3021
+ setBusy(false);
3022
+ setMsg({ tone: "err", text });
3023
+ };
3024
+ img.onload = () => {
3025
+ URL.revokeObjectURL(url);
3026
+ try {
3027
+ const S = 256;
3028
+ const c = document.createElement("canvas");
3029
+ c.width = S;
3030
+ c.height = S;
3031
+ const ctx = c.getContext("2d");
3032
+ const side = Math.min(img.width, img.height);
3033
+ ctx.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, S, S);
3034
+ const enc = (q) => {
3035
+ let d = c.toDataURL("image/webp", q);
3036
+ if (!d.startsWith("data:image/webp"))
3037
+ d = c.toDataURL("image/jpeg", q);
3038
+ return d;
3039
+ };
3040
+ let dataUrl = null;
3041
+ for (const q of [0.9, 0.75, 0.55, 0.4]) {
3042
+ dataUrl = enc(q);
3043
+ if (dataUrl.length < 13e4)
3044
+ break;
3045
+ }
3046
+ if (!dataUrl || dataUrl.length >= 13e4) {
3047
+ fail(T.ensTooBig || "image too large even after scaling");
3048
+ return;
3049
+ }
3050
+ post("/api/ens-avatar-upload", { dataUrl });
3051
+ } catch (err) {
3052
+ fail(String(err && err.message || err));
3053
+ }
3054
+ };
3055
+ img.onerror = () => {
3056
+ URL.revokeObjectURL(url);
3057
+ fail(T.ensBadImage || "cannot read that image");
3058
+ };
3059
+ img.src = url;
3060
+ };
2968
3061
  const walletRow = (lbl, chain, bound, onBind, last) => /* @__PURE__ */ React.createElement("div", { style: { ...row, borderBottom: last ? "none" : row.borderBottom } }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, lbl), bound ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, bound), /* @__PURE__ */ React.createElement(CopyBtn, { value: bound, copiedText: T.copied, copyFailedText: T.copyFailed, copyTitle: T.copy }), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => bind(chain, "") }, T.ensUnbind || "unbind")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotBound || "not bound"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: onBind }, T.ensBind || "bind")));
2969
- return /* @__PURE__ */ React.createElement(Card, { label: T.ensCard || "Name \xB7 beagles.eth" }, st.loading ? /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.dirLoading || "loading\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensName || "name"), /* @__PURE__ */ React.createElement(
3062
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Card, { label: T.ensCard || "Name \xB7 beagles.eth" }, st.loading ? /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.dirLoading || "loading\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensName || "name"), /* @__PURE__ */ React.createElement(
2970
3063
  "input",
2971
3064
  {
2972
3065
  value: label,
@@ -2979,14 +3072,15 @@ function DkEnsCard({ T, me, onRecord }) {
2979
3072
  },
2980
3073
  style: field
2981
3074
  }
2982
- ), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), st.registered && /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensAvatar || "avatar"), punkId != null ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 34, radius: 9, fallbackSeed: me.userId }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, `CryptoPunk #${punkId}`), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotSet || "not set"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => setPicking(true) }, T.ensChoose || "choose")), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)), picking && /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) }));
3075
+ ), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), st.registered && /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensAvatar || "avatar"), upAvatar ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 34, height: 34, style: { width: 34, height: 34, borderRadius: 9, objectFit: "cover", flexShrink: 0, background: "var(--panel-2)" } }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--text)" } }, T.ensCustom || "custom image"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : punkId != null ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 34, radius: 9, fallbackSeed: me.userId }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, `CryptoPunk #${punkId}`), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotSet || "not set"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => fileRef.current && fileRef.current.click() }, T.ensUpload || "upload"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => setPicking(true) }, T.ensChoose || "choose"), /* @__PURE__ */ React.createElement("input", { ref: fileRef, type: "file", accept: "image/*", onChange: onUploadFile, style: { display: "none" } })), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)), picking && /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) })), !st.loading && st.registered && /* @__PURE__ */ React.createElement(Card, { label: T.linksCard || "Links" }, SOCIALS.map((s, i) => socialRow(s, i === SOCIALS.length - 1))));
2983
3076
  }
2984
3077
  function ProfileTab({ T, me, onEdit }) {
2985
3078
  const [qr, setQr] = React.useState(null);
2986
3079
  const [editing, setEditing] = React.useState(false);
2987
3080
  const [ensRec, setEnsRec] = React.useState(null);
2988
3081
  const punkId = ensRec && ensRec.nft === "CryptoPunks" && ensRec.nftid > 0 ? ensRec.nftid : null;
2989
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 24 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 18, padding: "20px 22px", borderRadius: 14, background: "var(--panel)", border: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, punkId != null ? /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 68, radius: 16, fallbackSeed: me.userId }) : /* @__PURE__ */ React.createElement(DkIdenticon, { seed: me.userId, size: 68, radius: 16 }), /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", right: -3, bottom: -3, width: 18, height: 18, borderRadius: 999, background: "var(--online)", border: "3px solid var(--panel)" } })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 22, fontWeight: 700, letterSpacing: -0.5, color: "var(--text)" } }, me.name), /* @__PURE__ */ React.createElement(Tag, { tone: "ok" }, "online"), me.isExit && /* @__PURE__ */ React.createElement(Tag, { tone: "warn" }, "exit", me.exitRegion ? ` \xB7 ${me.exitRegion.toUpperCase()}` : "")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13, color: "var(--dim)", marginTop: 4 } }, me.handle), me.description ? /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 3 } }, me.description) : null), /* @__PURE__ */ React.createElement(Btn, { icon: "edit", onClick: () => setEditing(true) }, T.editProfile)), /* @__PURE__ */ React.createElement(Card, { label: T.identity }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.userId, value: me.userId, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.carrierAddr, value: me.carrier, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.netKey, value: me.netKey, copy: true, last: true })), /* @__PURE__ */ React.createElement(DkEnsCard, { T, me, onRecord: setEnsRec }), typeof me.autoAccept === "boolean" && /* @__PURE__ */ React.createElement(Card, { label: T && T.friendsSettings || "Friends" }, /* @__PURE__ */ React.createElement("label", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px", cursor: "pointer" } }, /* @__PURE__ */ React.createElement(
3082
+ const upAvatar = ensRec && ensRec.texts && ensRec.texts.avatar || null;
3083
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 24 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 18, padding: "20px 22px", borderRadius: 14, background: "var(--panel)", border: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, upAvatar ? /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 68, height: 68, style: { width: 68, height: 68, borderRadius: 16, objectFit: "cover", background: "var(--panel-2)" } }) : punkId != null ? /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 68, radius: 16, fallbackSeed: me.userId }) : /* @__PURE__ */ React.createElement(DkIdenticon, { seed: me.userId, size: 68, radius: 16 }), /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", right: -3, bottom: -3, width: 18, height: 18, borderRadius: 999, background: "var(--online)", border: "3px solid var(--panel)" } })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 22, fontWeight: 700, letterSpacing: -0.5, color: "var(--text)" } }, me.name), /* @__PURE__ */ React.createElement(Tag, { tone: "ok" }, "online"), me.isExit && /* @__PURE__ */ React.createElement(Tag, { tone: "warn" }, "exit", me.exitRegion ? ` \xB7 ${me.exitRegion.toUpperCase()}` : "")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13, color: "var(--dim)", marginTop: 4 } }, me.handle), me.description ? /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 3 } }, me.description) : null), /* @__PURE__ */ React.createElement(Btn, { icon: "edit", onClick: () => setEditing(true) }, T.editProfile)), /* @__PURE__ */ React.createElement(Card, { label: T.identity }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.userId, value: me.userId, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.carrierAddr, value: me.carrier, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.netKey, value: me.netKey, copy: true, last: true })), /* @__PURE__ */ React.createElement(DkEnsCard, { T, me, onRecord: setEnsRec }), typeof me.autoAccept === "boolean" && /* @__PURE__ */ React.createElement(Card, { label: T && T.friendsSettings || "Friends" }, /* @__PURE__ */ React.createElement("label", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px", cursor: "pointer" } }, /* @__PURE__ */ React.createElement(
2990
3084
  "input",
2991
3085
  {
2992
3086
  type: "checkbox",
@@ -3007,7 +3101,7 @@ function ProfileTab({ T, me, onEdit }) {
3007
3101
  Object.assign(window, { ProfileTab, DkPunkAvatar });
3008
3102
  function DkDirAvatar({ p, size }) {
3009
3103
  const [broken, setBroken] = React.useState(false);
3010
- if (p.punkId != null) {
3104
+ if (p.punkId != null && !(p.avatar && !broken)) {
3011
3105
  return /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: p.punkId, size, radius: Math.round(size / 4), fallbackSeed: p.userid || p.name });
3012
3106
  }
3013
3107
  if (p.avatar && !broken) {
@@ -4033,6 +4127,10 @@ const STR = {
4033
4127
  ensChoose: "choose",
4034
4128
  ensClear: "clear",
4035
4129
  ensNotSet: "not set",
4130
+ ensUpload: "upload",
4131
+ ensCustom: "custom image",
4132
+ ensTooBig: "image too large even after scaling",
4133
+ ensBadImage: "cannot read that image",
4036
4134
  ensPickPunk: "choose a punk",
4037
4135
  ensShuffle: "shuffle",
4038
4136
  pt_any: "any",
@@ -4041,6 +4139,9 @@ const STR = {
4041
4139
  pt_zombie: "zombie",
4042
4140
  pt_ape: "ape",
4043
4141
  pt_alien: "alien",
4142
+ linksCard: "Links",
4143
+ linkPlaceholder: "username or profile URL",
4144
+ save: "save",
4044
4145
  ensNoEthWallet: "no Ethereum wallet extension found (MetaMask\u2026)",
4045
4146
  ensNoSolWallet: "no Solana wallet extension found (Phantom\u2026)",
4046
4147
  ensWalletOwned: "registered via your mobile wallet as",
@@ -4170,6 +4271,10 @@ const STR = {
4170
4271
  ensChoose: "\u9009\u62E9",
4171
4272
  ensClear: "\u6E05\u9664",
4172
4273
  ensNotSet: "\u672A\u8BBE\u7F6E",
4274
+ ensUpload: "\u4E0A\u4F20",
4275
+ ensCustom: "\u81EA\u5B9A\u4E49\u56FE\u7247",
4276
+ ensTooBig: "\u56FE\u7247\u538B\u7F29\u540E\u4ECD\u8D85\u8FC7 100KB",
4277
+ ensBadImage: "\u65E0\u6CD5\u8BFB\u53D6\u8BE5\u56FE\u7247",
4173
4278
  ensPickPunk: "\u9009\u4E00\u4E2A punk",
4174
4279
  ensShuffle: "\u6362\u4E00\u6279",
4175
4280
  pt_any: "\u5168\u90E8",
@@ -4178,6 +4283,9 @@ const STR = {
4178
4283
  pt_zombie: "\u50F5\u5C38",
4179
4284
  pt_ape: "\u733F",
4180
4285
  pt_alien: "\u5916\u661F\u4EBA",
4286
+ linksCard: "\u793E\u4EA4\u8D26\u53F7",
4287
+ linkPlaceholder: "\u7528\u6237\u540D\u6216\u4E3B\u9875\u94FE\u63A5",
4288
+ save: "\u4FDD\u5B58",
4181
4289
  ensNoEthWallet: "\u672A\u68C0\u6D4B\u5230\u4EE5\u592A\u574A\u94B1\u5305\u63D2\u4EF6(MetaMask \u7B49)",
4182
4290
  ensNoSolWallet: "\u672A\u68C0\u6D4B\u5230 Solana \u94B1\u5305\u63D2\u4EF6(Phantom \u7B49)",
4183
4291
  ensWalletOwned: "\u5DF2\u901A\u8FC7\u624B\u673A\u94B1\u5305\u6CE8\u518C\u4E3A",
package/dist/server.js CHANGED
@@ -102,6 +102,9 @@ async function ensSignAndSet(call, record) {
102
102
  if (!r.ok || !d?.success) {
103
103
  return { ok: false, error: typeof d?.error === "string" ? d.error : `gateway HTTP ${r.status}` };
104
104
  }
105
+ // The record changed — drop the cached /names listing so the Names tab
106
+ // reflects it immediately instead of after the 5-min TTL.
107
+ discoverCache.delete(`${ENS_GATEWAY}/names`);
105
108
  return { ok: true };
106
109
  }
107
110
  catch (e) {
@@ -132,6 +135,25 @@ async function punksFetch(path) {
132
135
  clearTimeout(timer);
133
136
  }
134
137
  }
138
+ /** Best-effort removal of the R2 avatar object when the record stops
139
+ * referencing it (punk picked / avatar cleared). Failure is harmless —
140
+ * an orphaned object just sits unused. */
141
+ async function ensAvatarDelete(call, fullName) {
142
+ try {
143
+ const name = fullName.toLowerCase();
144
+ const ts = Math.floor(Date.now() / 1000);
145
+ const signed = await call({ op: "sign", text: `beagle-avatar\ndelete\n${name}\n${ts}` });
146
+ if (!signed.ok || !signed.data?.sig)
147
+ return;
148
+ await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
149
+ method: "DELETE",
150
+ headers: { "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
151
+ });
152
+ }
153
+ catch {
154
+ // best-effort only
155
+ }
156
+ }
135
157
  /** True only when the request came from the local machine. Used to gate the
136
158
  * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
137
159
  * identity signing to other hosts. The popup always runs in the local user's
@@ -449,7 +471,7 @@ export function startBeagleServer(opts) {
449
471
  // Register/update this node's *.beagles.eth name and bind wallet
450
472
  // addresses into the record. The write routes trigger identity
451
473
  // signatures, so they're LOCALHOST-ONLY like /connect.
452
- if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar") {
474
+ if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar" || url === "/api/ens-avatar-upload" || url === "/api/ens-social") {
453
475
  if (!isLocalRequest(req)) {
454
476
  sendJson(res, 403, { ok: false, error: "identity signing is available only on this machine (localhost)" });
455
477
  return;
@@ -508,18 +530,140 @@ export function startBeagleServer(opts) {
508
530
  sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — pick the avatar there" });
509
531
  return;
510
532
  }
533
+ // One avatar at a time: picking a punk (or clearing) also drops any
534
+ // uploaded custom avatar from the record.
535
+ const texts = { ...(mine.texts ?? {}) };
536
+ const hadUpload = !!texts.avatar;
537
+ delete texts.avatar;
511
538
  const r = await ensSignAndSet(opts.call, {
512
539
  name: mine.name,
513
540
  owner: identity.userid,
514
541
  addresses: mine.addresses ?? {},
515
- texts: mine.texts ?? {},
542
+ texts,
516
543
  referee: mine.referee ?? "",
517
544
  nft: clear ? "" : "CryptoPunks",
518
545
  nftid: clear ? 0 : nftid,
519
546
  });
547
+ if (r.ok && hadUpload)
548
+ void ensAvatarDelete(opts.call, mine.name); // best-effort R2 cleanup
520
549
  sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, nftid: clear ? null : nftid } : { ok: false, error: r.error });
521
550
  return;
522
551
  }
552
+ // Bind/update/remove a social handle on the record, stored under the
553
+ // standard ENS text keys (com.twitter / com.linkedin / com.github) so
554
+ // other ENS-aware clients can read them too. Empty value = remove.
555
+ if (req.method === "POST" && url === "/api/ens-social") {
556
+ const SOCIAL_KEYS = { twitter: "com.twitter", linkedin: "com.linkedin", github: "com.github" };
557
+ const body = await readBody(req);
558
+ const key = SOCIAL_KEYS[String(body.key ?? "")];
559
+ if (!key) {
560
+ sendJson(res, 400, { ok: false, error: "key must be twitter, linkedin or github" });
561
+ return;
562
+ }
563
+ // Accept a bare handle, @handle, or a pasted profile URL — keep the handle.
564
+ const value = String(body.value ?? "")
565
+ .trim()
566
+ .replace(/^https?:\/\/(www\.)?(x\.com|twitter\.com|github\.com|linkedin\.com\/(in|company))\//i, "")
567
+ .replace(/\/+$/, "")
568
+ .replace(/^@/, "");
569
+ if (value && !/^[A-Za-z0-9_.-]{1,64}$/.test(value)) {
570
+ sendJson(res, 400, { ok: false, error: "handle may only contain letters, digits, _ . -" });
571
+ return;
572
+ }
573
+ const diag = await opts.call({ op: "diag" });
574
+ const identity = (diag.ok ? diag.data?.identity : null) ?? {};
575
+ if (!identity.userid) {
576
+ sendJson(res, 502, { ok: false, error: "identity unavailable" });
577
+ return;
578
+ }
579
+ const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
580
+ if (!mine) {
581
+ sendJson(res, 404, { ok: false, error: "register a name first" });
582
+ return;
583
+ }
584
+ if (mine.owner !== identity.userid) {
585
+ sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — edit links there" });
586
+ return;
587
+ }
588
+ const texts = { ...(mine.texts ?? {}) };
589
+ if (value)
590
+ texts[key] = value;
591
+ else
592
+ delete texts[key];
593
+ const r = await ensSignAndSet(opts.call, {
594
+ name: mine.name,
595
+ owner: identity.userid,
596
+ addresses: mine.addresses ?? {},
597
+ texts,
598
+ referee: mine.referee ?? "",
599
+ nft: mine.nft ?? "",
600
+ nftid: mine.nftid ?? 0,
601
+ });
602
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, key: body.key, value: value || null } : { ok: false, error: r.error });
603
+ return;
604
+ }
605
+ // Upload a custom avatar image: the browser sends a pre-scaled data URL,
606
+ // we push the bytes to the gateway's R2 (auth = identity signature) and
607
+ // point texts.avatar at it.
608
+ if (req.method === "POST" && url === "/api/ens-avatar-upload") {
609
+ const body = await readBody(req);
610
+ const m = /^data:(image\/(?:webp|png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(String(body.dataUrl ?? ""));
611
+ if (!m) {
612
+ sendJson(res, 400, { ok: false, error: "expected a webp/png/jpeg data URL" });
613
+ return;
614
+ }
615
+ const bytes = Buffer.from(m[2], "base64");
616
+ if (bytes.length === 0 || bytes.length > 100 * 1024) {
617
+ sendJson(res, 413, { ok: false, error: "image must be ≤100 KB after scaling" });
618
+ return;
619
+ }
620
+ const diag = await opts.call({ op: "diag" });
621
+ const identity = (diag.ok ? diag.data?.identity : null) ?? {};
622
+ if (!identity.userid) {
623
+ sendJson(res, 502, { ok: false, error: "identity unavailable" });
624
+ return;
625
+ }
626
+ const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
627
+ if (!mine) {
628
+ sendJson(res, 404, { ok: false, error: "register a name first" });
629
+ return;
630
+ }
631
+ if (mine.owner !== identity.userid) {
632
+ sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — set the avatar there" });
633
+ return;
634
+ }
635
+ const name = mine.name.toLowerCase();
636
+ const ts = Math.floor(Date.now() / 1000);
637
+ const signed = await opts.call({ op: "sign", text: `beagle-avatar\nupload\n${name}\n${ts}` });
638
+ if (!signed.ok || !signed.data?.sig) {
639
+ sendJson(res, 502, { ok: false, error: "identity sign failed" });
640
+ return;
641
+ }
642
+ const up = await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
643
+ method: "POST",
644
+ headers: { "content-type": m[1], "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
645
+ body: bytes,
646
+ });
647
+ const upBody = (await up.json().catch(() => null));
648
+ if (!up.ok || !upBody?.success) {
649
+ sendJson(res, 502, { ok: false, error: upBody?.error || `avatar upload HTTP ${up.status}` });
650
+ return;
651
+ }
652
+ // Point the record at it (?v= busts caches on re-upload) and drop any
653
+ // punk pick — one avatar at a time.
654
+ const avatarUrl = `${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}?v=${ts}`;
655
+ const r = await ensSignAndSet(opts.call, {
656
+ name: mine.name,
657
+ owner: identity.userid,
658
+ addresses: mine.addresses ?? {},
659
+ texts: { ...(mine.texts ?? {}), avatar: avatarUrl },
660
+ referee: mine.referee ?? "",
661
+ nft: "",
662
+ nftid: 0,
663
+ });
664
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, avatar: avatarUrl } : { ok: false, error: r.error });
665
+ return;
666
+ }
523
667
  if (req.method === "GET" && url === "/api/ens-name") {
524
668
  const diag = await opts.call({ op: "diag" });
525
669
  const identity = (diag.ok ? diag.data?.identity : null) ?? {};
@@ -931,6 +1075,8 @@ export function startBeagleServer(opts) {
931
1075
  name: t.displayName || ens,
932
1076
  ens,
933
1077
  description: t.description || null,
1078
+ // custom uploaded avatar (R2 URL in texts) beats the punk pick
1079
+ avatar: typeof t.avatar === "string" && /^https:\/\//.test(t.avatar) ? t.avatar : null,
934
1080
  // punk avatar convention: nft="CryptoPunks" + nftid>0 (0 = unset)
935
1081
  punkId: v?.nft === "CryptoPunks" && typeof v?.nftid === "number" && v.nftid > 0 ? v.nftid : null,
936
1082
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",