@decentnetwork/beagle 0.1.20 → 0.1.22

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.19";
1
+ window.__DK_UI_VERSION="0.1.21";
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"/>',
@@ -2800,7 +2800,97 @@ function DkEditModal({ T, me, onClose, onSave }) {
2800
2800
  };
2801
2801
  return /* @__PURE__ */ React.createElement("div", { onClick: onClose, style: { position: "fixed", inset: 0, zIndex: 90, background: "color-mix(in oklab, #000, transparent 38%)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 } }, /* @__PURE__ */ React.createElement("div", { onClick: (e) => e.stopPropagation(), style: { width: 440, maxWidth: "92vw", background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 16, padding: 22, display: "flex", flexDirection: "column", gap: 14 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 13, fontWeight: 700, color: "var(--text)" } }, T.editProfile), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { icon: "x", size: "sm", onClick: onClose })), /* @__PURE__ */ React.createElement("label", { style: { display: "flex", flexDirection: "column", gap: 6 } }, /* @__PURE__ */ React.createElement("span", { style: lbl }, "display name"), /* @__PURE__ */ React.createElement("input", { value: name, onChange: (e) => setName(e.target.value), onKeyDown: onKey, autoFocus: true, maxLength: 48, style: field })), /* @__PURE__ */ React.createElement("label", { style: { display: "flex", flexDirection: "column", gap: 6 } }, /* @__PURE__ */ React.createElement("span", { style: lbl }, "status message"), /* @__PURE__ */ React.createElement("input", { value: desc, onChange: (e) => setDesc(e.target.value), onKeyDown: onKey, maxLength: 120, placeholder: "optional \u2014 a short bio friends will see", style: field })), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, "Your userid (the unique identity) can't change \u2014 only the display name + status."), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Btn, { size: "sm", onClick: onClose }, T.cancel || "cancel"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", onClick: save }, T.save || "save"))));
2802
2802
  }
2803
- function DkEnsCard({ T, me }) {
2803
+ const dkPunkCache = /* @__PURE__ */ new Map();
2804
+ function DkPunkAvatar({ id, size, radius, fallbackSeed }) {
2805
+ const [img, setImg] = React.useState(() => (dkPunkCache.get(id) || {}).image || null);
2806
+ React.useEffect(() => {
2807
+ let dead = false;
2808
+ const hit = dkPunkCache.get(id);
2809
+ if (hit) {
2810
+ setImg(hit.image || null);
2811
+ return;
2812
+ }
2813
+ setImg(null);
2814
+ fetch(`/api/punk/${id}`).then((r) => r.json()).then((d) => {
2815
+ if (d.ok && d.punk && d.punk.image) {
2816
+ dkPunkCache.set(id, d.punk);
2817
+ if (!dead)
2818
+ setImg(d.punk.image);
2819
+ }
2820
+ }).catch(() => {
2821
+ });
2822
+ return () => {
2823
+ dead = true;
2824
+ };
2825
+ }, [id]);
2826
+ if (!img)
2827
+ return /* @__PURE__ */ React.createElement(DkIdenticon, { seed: fallbackSeed || String(id), size, radius });
2828
+ return /* @__PURE__ */ React.createElement(
2829
+ "img",
2830
+ {
2831
+ src: img,
2832
+ alt: "",
2833
+ width: size,
2834
+ height: size,
2835
+ style: { width: size, height: size, borderRadius: radius, background: "#638596", imageRendering: "pixelated", flexShrink: 0 }
2836
+ }
2837
+ );
2838
+ }
2839
+ function DkPunkPicker({ T, onPick, onClose }) {
2840
+ const TYPES = ["any", "female", "male", "zombie", "ape", "alien"];
2841
+ const tLabel = (t) => T && T["pt_" + t] || t;
2842
+ const [type, setType] = React.useState("any");
2843
+ const [batch, setBatch] = React.useState(null);
2844
+ const [err, setErr] = React.useState(null);
2845
+ const [gen, setGen] = React.useState(0);
2846
+ React.useEffect(() => {
2847
+ let dead = false;
2848
+ setBatch(null);
2849
+ setErr(null);
2850
+ fetch(`/api/punk-list?type=${type}&limit=24`).then((r) => r.json()).then((d) => {
2851
+ if (dead)
2852
+ return;
2853
+ if (d.ok)
2854
+ setBatch(d.list || []);
2855
+ else
2856
+ setErr(d.error || "load failed");
2857
+ }).catch((e) => {
2858
+ if (!dead)
2859
+ setErr(String(e && e.message || e));
2860
+ });
2861
+ return () => {
2862
+ dead = true;
2863
+ };
2864
+ }, [type, gen]);
2865
+ return /* @__PURE__ */ React.createElement("div", { onClick: onClose, style: { position: "fixed", inset: 0, zIndex: 90, background: "color-mix(in oklab, #000, transparent 38%)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 } }, /* @__PURE__ */ React.createElement("div", { onClick: (e) => e.stopPropagation(), style: { width: 480, maxWidth: "94vw", background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 16, padding: 20, display: "flex", flexDirection: "column", gap: 14 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 13, fontWeight: 700, color: "var(--text)" } }, T.ensPickPunk || "choose a punk"), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { size: "sm", onClick: () => setGen(gen + 1) }, T.ensShuffle || "shuffle"), /* @__PURE__ */ React.createElement(Btn, { icon: "x", size: "sm", onClick: onClose })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" } }, TYPES.map((t) => /* @__PURE__ */ React.createElement(
2866
+ "button",
2867
+ {
2868
+ key: t,
2869
+ onClick: () => setType(t),
2870
+ style: {
2871
+ padding: "4px 10px",
2872
+ borderRadius: 999,
2873
+ border: "1px solid var(--line)",
2874
+ cursor: "pointer",
2875
+ background: type === t ? "var(--accent)" : "transparent",
2876
+ color: type === t ? "#fff" : "var(--dim)",
2877
+ fontFamily: "var(--ui)",
2878
+ fontSize: 12
2879
+ }
2880
+ },
2881
+ tLabel(t)
2882
+ ))), /* @__PURE__ */ React.createElement("div", { style: { minHeight: 232 } }, err ? /* @__PURE__ */ React.createElement("div", { style: { padding: 16, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, err) : !batch ? /* @__PURE__ */ React.createElement("div", { style: { padding: 16, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.dirLoading || "loading\u2026") : /* @__PURE__ */ React.createElement("div", { style: { display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 8 } }, batch.map((p) => /* @__PURE__ */ React.createElement(
2883
+ "button",
2884
+ {
2885
+ key: p.id,
2886
+ title: `#${p.id} \xB7 ${p.type}`,
2887
+ onClick: () => onPick(Number(p.id)),
2888
+ style: { padding: 0, border: "1px solid var(--line)", borderRadius: 10, cursor: "pointer", background: "#638596", overflow: "hidden", aspectRatio: "1" }
2889
+ },
2890
+ /* @__PURE__ */ React.createElement("img", { src: p.image, alt: `punk #${p.id}`, style: { width: "100%", height: "100%", imageRendering: "pixelated", display: "block" } })
2891
+ ))))));
2892
+ }
2893
+ function DkEnsCard({ T, me, onRecord }) {
2804
2894
  const [st, setSt] = React.useState({ loading: true });
2805
2895
  const [label, setLabel] = React.useState("");
2806
2896
  const [busy, setBusy] = React.useState(false);
@@ -2810,6 +2900,8 @@ function DkEnsCard({ T, me }) {
2810
2900
  setSt({ loading: false, registered: !!d.registered, record: d.record || null, mineOwned: !!d.mineOwned });
2811
2901
  if (d.registered && d.record && d.record.name)
2812
2902
  setLabel(d.record.name.replace(/\.beagles\.eth$/i, ""));
2903
+ if (onRecord)
2904
+ onRecord(d.record || null);
2813
2905
  }).catch((e) => setSt({ loading: false, error: String(e && e.message || e) }));
2814
2906
  };
2815
2907
  React.useEffect(() => {
@@ -2864,9 +2956,67 @@ function DkEnsCard({ T, me }) {
2864
2956
  };
2865
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" };
2866
2958
  const row = { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid var(--line)" };
2959
+ const [picking, setPicking] = React.useState(false);
2960
+ const fileRef = React.useRef(null);
2867
2961
  const rec = st.record;
2868
2962
  const boundEth = rec && rec.addresses && rec.addresses["60"];
2869
2963
  const boundSol = rec && rec.addresses && rec.addresses["501"];
2964
+ const punkId = rec && rec.nft === "CryptoPunks" && rec.nftid > 0 ? rec.nftid : null;
2965
+ const upAvatar = rec && rec.texts && rec.texts.avatar || null;
2966
+ const pickPunk = (id) => {
2967
+ setPicking(false);
2968
+ post("/api/ens-avatar", { nftid: id });
2969
+ };
2970
+ const onUploadFile = (e) => {
2971
+ const f = e.target.files && e.target.files[0];
2972
+ e.target.value = "";
2973
+ if (!f)
2974
+ return;
2975
+ setBusy(true);
2976
+ setMsg(null);
2977
+ const img = new Image();
2978
+ const url = URL.createObjectURL(f);
2979
+ const fail = (text) => {
2980
+ setBusy(false);
2981
+ setMsg({ tone: "err", text });
2982
+ };
2983
+ img.onload = () => {
2984
+ URL.revokeObjectURL(url);
2985
+ try {
2986
+ const S = 256;
2987
+ const c = document.createElement("canvas");
2988
+ c.width = S;
2989
+ c.height = S;
2990
+ const ctx = c.getContext("2d");
2991
+ const side = Math.min(img.width, img.height);
2992
+ ctx.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, S, S);
2993
+ const enc = (q) => {
2994
+ let d = c.toDataURL("image/webp", q);
2995
+ if (!d.startsWith("data:image/webp"))
2996
+ d = c.toDataURL("image/jpeg", q);
2997
+ return d;
2998
+ };
2999
+ let dataUrl = null;
3000
+ for (const q of [0.9, 0.75, 0.55, 0.4]) {
3001
+ dataUrl = enc(q);
3002
+ if (dataUrl.length < 13e4)
3003
+ break;
3004
+ }
3005
+ if (!dataUrl || dataUrl.length >= 13e4) {
3006
+ fail(T.ensTooBig || "image too large even after scaling");
3007
+ return;
3008
+ }
3009
+ post("/api/ens-avatar-upload", { dataUrl });
3010
+ } catch (err) {
3011
+ fail(String(err && err.message || err));
3012
+ }
3013
+ };
3014
+ img.onerror = () => {
3015
+ URL.revokeObjectURL(url);
3016
+ fail(T.ensBadImage || "cannot read that image");
3017
+ };
3018
+ img.src = url;
3019
+ };
2870
3020
  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")));
2871
3021
  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(
2872
3022
  "input",
@@ -2881,12 +3031,15 @@ function DkEnsCard({ T, me }) {
2881
3031
  },
2882
3032
  style: field
2883
3033
  }
2884
- ), /* @__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)), 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)));
3034
+ ), /* @__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) }));
2885
3035
  }
2886
3036
  function ProfileTab({ T, me, onEdit }) {
2887
3037
  const [qr, setQr] = React.useState(null);
2888
3038
  const [editing, setEditing] = React.useState(false);
2889
- 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" } }, /* @__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 }), 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(
3039
+ const [ensRec, setEnsRec] = React.useState(null);
3040
+ const punkId = ensRec && ensRec.nft === "CryptoPunks" && ensRec.nftid > 0 ? ensRec.nftid : null;
3041
+ const upAvatar = ensRec && ensRec.texts && ensRec.texts.avatar || null;
3042
+ 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(
2890
3043
  "input",
2891
3044
  {
2892
3045
  type: "checkbox",
@@ -2904,9 +3057,12 @@ function ProfileTab({ T, me, onEdit }) {
2904
3057
  setEditing(false);
2905
3058
  } }));
2906
3059
  }
2907
- Object.assign(window, { ProfileTab });
3060
+ Object.assign(window, { ProfileTab, DkPunkAvatar });
2908
3061
  function DkDirAvatar({ p, size }) {
2909
3062
  const [broken, setBroken] = React.useState(false);
3063
+ if (p.punkId != null && !(p.avatar && !broken)) {
3064
+ return /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: p.punkId, size, radius: Math.round(size / 4), fallbackSeed: p.userid || p.name });
3065
+ }
2910
3066
  if (p.avatar && !broken) {
2911
3067
  return /* @__PURE__ */ React.createElement(
2912
3068
  "img",
@@ -3926,6 +4082,22 @@ const STR = {
3926
4082
  ensBind: "bind",
3927
4083
  ensUnbind: "unbind",
3928
4084
  ensNotBound: "not bound",
4085
+ ensAvatar: "avatar",
4086
+ ensChoose: "choose",
4087
+ ensClear: "clear",
4088
+ ensNotSet: "not set",
4089
+ ensUpload: "upload",
4090
+ ensCustom: "custom image",
4091
+ ensTooBig: "image too large even after scaling",
4092
+ ensBadImage: "cannot read that image",
4093
+ ensPickPunk: "choose a punk",
4094
+ ensShuffle: "shuffle",
4095
+ pt_any: "any",
4096
+ pt_female: "female",
4097
+ pt_male: "male",
4098
+ pt_zombie: "zombie",
4099
+ pt_ape: "ape",
4100
+ pt_alien: "alien",
3929
4101
  ensNoEthWallet: "no Ethereum wallet extension found (MetaMask\u2026)",
3930
4102
  ensNoSolWallet: "no Solana wallet extension found (Phantom\u2026)",
3931
4103
  ensWalletOwned: "registered via your mobile wallet as",
@@ -4051,6 +4223,22 @@ const STR = {
4051
4223
  ensBind: "\u7ED1\u5B9A",
4052
4224
  ensUnbind: "\u89E3\u7ED1",
4053
4225
  ensNotBound: "\u672A\u7ED1\u5B9A",
4226
+ ensAvatar: "\u5934\u50CF",
4227
+ ensChoose: "\u9009\u62E9",
4228
+ ensClear: "\u6E05\u9664",
4229
+ ensNotSet: "\u672A\u8BBE\u7F6E",
4230
+ ensUpload: "\u4E0A\u4F20",
4231
+ ensCustom: "\u81EA\u5B9A\u4E49\u56FE\u7247",
4232
+ ensTooBig: "\u56FE\u7247\u538B\u7F29\u540E\u4ECD\u8D85\u8FC7 100KB",
4233
+ ensBadImage: "\u65E0\u6CD5\u8BFB\u53D6\u8BE5\u56FE\u7247",
4234
+ ensPickPunk: "\u9009\u4E00\u4E2A punk",
4235
+ ensShuffle: "\u6362\u4E00\u6279",
4236
+ pt_any: "\u5168\u90E8",
4237
+ pt_female: "\u5973",
4238
+ pt_male: "\u7537",
4239
+ pt_zombie: "\u50F5\u5C38",
4240
+ pt_ape: "\u733F",
4241
+ pt_alien: "\u5916\u661F\u4EBA",
4054
4242
  ensNoEthWallet: "\u672A\u68C0\u6D4B\u5230\u4EE5\u592A\u574A\u94B1\u5305\u63D2\u4EF6(MetaMask \u7B49)",
4055
4243
  ensNoSolWallet: "\u672A\u68C0\u6D4B\u5230 Solana \u94B1\u5305\u63D2\u4EF6(Phantom \u7B49)",
4056
4244
  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) {
@@ -111,6 +114,46 @@ async function ensSignAndSet(call, record) {
111
114
  clearTimeout(timer);
112
115
  }
113
116
  }
117
+ // ── CryptoPunks avatar directory ────────────────────────────────────────────
118
+ // Self-hosted punks API serving all 10k punks as inline SVG data URIs. The
119
+ // record convention (shared with the mobile apps) is nft="CryptoPunks" +
120
+ // nftid=<punk id> on the beagles.eth name. Proxied server-side (browser can't
121
+ // reach the origin's port cross-origin reliably); NOTE the upstream's TLS cert
122
+ // must be valid — renew it when these routes start failing.
123
+ const PUNKS_API_URL = process.env.PUNKS_API_URL || "https://app.beagle.chat:1337";
124
+ const punkCache = new Map(); // punk id → JSON; immutable set
125
+ async function punksFetch(path) {
126
+ const ctl = new AbortController();
127
+ const timer = setTimeout(() => ctl.abort(), 10_000);
128
+ try {
129
+ const r = await fetch(`${PUNKS_API_URL}${path}`, { signal: ctl.signal });
130
+ if (!r.ok)
131
+ throw new Error(`punks upstream ${r.status}`);
132
+ return await r.json();
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
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
+ }
114
157
  /** True only when the request came from the local machine. Used to gate the
115
158
  * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
116
159
  * identity signing to other hosts. The popup always runs in the local user's
@@ -428,12 +471,146 @@ export function startBeagleServer(opts) {
428
471
  // Register/update this node's *.beagles.eth name and bind wallet
429
472
  // addresses into the record. The write routes trigger identity
430
473
  // signatures, so they're LOCALHOST-ONLY like /connect.
431
- if (url === "/api/ens-register" || url === "/api/ens-bind-wallet") {
474
+ if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar" || url === "/api/ens-avatar-upload") {
432
475
  if (!isLocalRequest(req)) {
433
476
  sendJson(res, 403, { ok: false, error: "identity signing is available only on this machine (localhost)" });
434
477
  return;
435
478
  }
436
479
  }
480
+ // Punk directory (proxied): a random batch by type, and one punk by id.
481
+ if (req.method === "GET" && url.startsWith("/api/punk-list")) {
482
+ try {
483
+ const q = new URL(url, "http://localhost");
484
+ const type = (q.searchParams.get("type") || "any").toLowerCase().replace(/[^a-z]/g, "") || "any";
485
+ const limit = Math.min(60, Math.max(1, Number(q.searchParams.get("limit")) || 24));
486
+ const list = await punksFetch(`/api/punks/filter/${type}/any?limit=${limit}`);
487
+ sendJson(res, 200, { ok: true, list });
488
+ }
489
+ catch (err) {
490
+ sendJson(res, 502, { ok: false, error: String(err?.message ?? err) });
491
+ }
492
+ return;
493
+ }
494
+ if (req.method === "GET" && /^\/api\/punk\/\d{1,5}$/.test(url)) {
495
+ const id = url.slice("/api/punk/".length);
496
+ try {
497
+ let punk = punkCache.get(id);
498
+ if (!punk) {
499
+ punk = await punksFetch(`/api/punks/${id}`);
500
+ punkCache.set(id, punk);
501
+ }
502
+ sendJson(res, 200, { ok: true, punk });
503
+ }
504
+ catch (err) {
505
+ sendJson(res, 502, { ok: false, error: String(err?.message ?? err) });
506
+ }
507
+ return;
508
+ }
509
+ // Set (or clear) my punk avatar on the beagles.eth record.
510
+ if (req.method === "POST" && url === "/api/ens-avatar") {
511
+ const body = await readBody(req);
512
+ const clear = body.nftid === null || body.nftid === undefined;
513
+ const nftid = Number(body.nftid);
514
+ if (!clear && (!Number.isInteger(nftid) || nftid < 0 || nftid > 9999)) {
515
+ sendJson(res, 400, { ok: false, error: "invalid punk id" });
516
+ return;
517
+ }
518
+ const diag = await opts.call({ op: "diag" });
519
+ const identity = (diag.ok ? diag.data?.identity : null) ?? {};
520
+ if (!identity.userid) {
521
+ sendJson(res, 502, { ok: false, error: "identity unavailable" });
522
+ return;
523
+ }
524
+ const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
525
+ if (!mine) {
526
+ sendJson(res, 404, { ok: false, error: "register a name first" });
527
+ return;
528
+ }
529
+ if (mine.owner !== identity.userid) {
530
+ sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — pick the avatar there" });
531
+ return;
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;
538
+ const r = await ensSignAndSet(opts.call, {
539
+ name: mine.name,
540
+ owner: identity.userid,
541
+ addresses: mine.addresses ?? {},
542
+ texts,
543
+ referee: mine.referee ?? "",
544
+ nft: clear ? "" : "CryptoPunks",
545
+ nftid: clear ? 0 : nftid,
546
+ });
547
+ if (r.ok && hadUpload)
548
+ void ensAvatarDelete(opts.call, mine.name); // best-effort R2 cleanup
549
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, nftid: clear ? null : nftid } : { ok: false, error: r.error });
550
+ return;
551
+ }
552
+ // Upload a custom avatar image: the browser sends a pre-scaled data URL,
553
+ // we push the bytes to the gateway's R2 (auth = identity signature) and
554
+ // point texts.avatar at it.
555
+ if (req.method === "POST" && url === "/api/ens-avatar-upload") {
556
+ const body = await readBody(req);
557
+ const m = /^data:(image\/(?:webp|png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(String(body.dataUrl ?? ""));
558
+ if (!m) {
559
+ sendJson(res, 400, { ok: false, error: "expected a webp/png/jpeg data URL" });
560
+ return;
561
+ }
562
+ const bytes = Buffer.from(m[2], "base64");
563
+ if (bytes.length === 0 || bytes.length > 100 * 1024) {
564
+ sendJson(res, 413, { ok: false, error: "image must be ≤100 KB after scaling" });
565
+ return;
566
+ }
567
+ const diag = await opts.call({ op: "diag" });
568
+ const identity = (diag.ok ? diag.data?.identity : null) ?? {};
569
+ if (!identity.userid) {
570
+ sendJson(res, 502, { ok: false, error: "identity unavailable" });
571
+ return;
572
+ }
573
+ const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
574
+ if (!mine) {
575
+ sendJson(res, 404, { ok: false, error: "register a name first" });
576
+ return;
577
+ }
578
+ if (mine.owner !== identity.userid) {
579
+ sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — set the avatar there" });
580
+ return;
581
+ }
582
+ const name = mine.name.toLowerCase();
583
+ const ts = Math.floor(Date.now() / 1000);
584
+ const signed = await opts.call({ op: "sign", text: `beagle-avatar\nupload\n${name}\n${ts}` });
585
+ if (!signed.ok || !signed.data?.sig) {
586
+ sendJson(res, 502, { ok: false, error: "identity sign failed" });
587
+ return;
588
+ }
589
+ const up = await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
590
+ method: "POST",
591
+ headers: { "content-type": m[1], "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
592
+ body: bytes,
593
+ });
594
+ const upBody = (await up.json().catch(() => null));
595
+ if (!up.ok || !upBody?.success) {
596
+ sendJson(res, 502, { ok: false, error: upBody?.error || `avatar upload HTTP ${up.status}` });
597
+ return;
598
+ }
599
+ // Point the record at it (?v= busts caches on re-upload) and drop any
600
+ // punk pick — one avatar at a time.
601
+ const avatarUrl = `${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}?v=${ts}`;
602
+ const r = await ensSignAndSet(opts.call, {
603
+ name: mine.name,
604
+ owner: identity.userid,
605
+ addresses: mine.addresses ?? {},
606
+ texts: { ...(mine.texts ?? {}), avatar: avatarUrl },
607
+ referee: mine.referee ?? "",
608
+ nft: "",
609
+ nftid: 0,
610
+ });
611
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, avatar: avatarUrl } : { ok: false, error: r.error });
612
+ return;
613
+ }
437
614
  if (req.method === "GET" && url === "/api/ens-name") {
438
615
  const diag = await opts.call({ op: "diag" });
439
616
  const identity = (diag.ok ? diag.data?.identity : null) ?? {};
@@ -835,7 +1012,7 @@ export function startBeagleServer(opts) {
835
1012
  }
836
1013
  if (req.method === "GET" && url === "/api/discover-registered") {
837
1014
  try {
838
- const raw = (await fetchDiscoverJson("https://ens-gateway.beaglechat.workers.dev/names"));
1015
+ const raw = (await fetchDiscoverJson(`${ENS_GATEWAY}/names`));
839
1016
  const list = Object.entries(raw && typeof raw === "object" ? raw : {})
840
1017
  .map(([ens, v]) => {
841
1018
  const t = v?.texts ?? {};
@@ -845,6 +1022,10 @@ export function startBeagleServer(opts) {
845
1022
  name: t.displayName || ens,
846
1023
  ens,
847
1024
  description: t.description || null,
1025
+ // custom uploaded avatar (R2 URL in texts) beats the punk pick
1026
+ avatar: typeof t.avatar === "string" && /^https:\/\//.test(t.avatar) ? t.avatar : null,
1027
+ // punk avatar convention: nft="CryptoPunks" + nftid>0 (0 = unset)
1028
+ punkId: v?.nft === "CryptoPunks" && typeof v?.nftid === "number" && v.nftid > 0 ? v.nftid : null,
848
1029
  };
849
1030
  })
850
1031
  .filter((e) => e.userid && e.address);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",