@b3dotfun/sdk 0.1.69-alpha.13 → 0.1.69-alpha.14

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.
@@ -16,6 +16,8 @@ interface IPFSMediaRendererProps {
16
16
  controls?: boolean;
17
17
  /** Style object */
18
18
  style?: React.CSSProperties;
19
+ /** Callback when media fails to load */
20
+ onError?: () => void;
19
21
  }
20
22
  /**
21
23
  * IPFSMediaRenderer - A wrapper around Thirdweb's MediaRenderer that configures
@@ -35,5 +37,5 @@ interface IPFSMediaRendererProps {
35
37
  * />
36
38
  * ```
37
39
  */
38
- export declare function IPFSMediaRenderer({ src, alt, className, client, width, height, controls, style, }: IPFSMediaRendererProps): import("react/jsx-runtime").JSX.Element;
40
+ export declare function IPFSMediaRenderer({ src, alt, className, client, width, height, controls, style, onError, }: IPFSMediaRendererProps): import("react/jsx-runtime").JSX.Element;
39
41
  export {};
@@ -24,7 +24,7 @@ const react_1 = require("thirdweb/react");
24
24
  * />
25
25
  * ```
26
26
  */
27
- function IPFSMediaRenderer({ src, alt = "Media", className, client = thirdweb_1.client, width, height, controls, style, }) {
27
+ function IPFSMediaRenderer({ src, alt = "Media", className, client = thirdweb_1.client, width, height, controls, style, onError, }) {
28
28
  // If no source, render fallback
29
29
  if (!src) {
30
30
  return ((0, jsx_runtime_1.jsx)("div", { className: className, style: style, "aria-label": alt, children: (0, jsx_runtime_1.jsx)("div", { className: "bg-b3-primary-wash flex h-full w-full items-center justify-center rounded-full", children: (0, jsx_runtime_1.jsx)("span", { className: "text-b3-grey font-neue-montreal-semibold text-xs", children: alt.charAt(0).toUpperCase() }) }) }));
@@ -32,5 +32,7 @@ function IPFSMediaRenderer({ src, alt = "Media", className, client = thirdweb_1.
32
32
  // Convert IPFS URLs to HTTP gateway URLs using our preferred gateway
33
33
  // This avoids Thirdweb's default cloudflare-ipfs.com which can be unreliable
34
34
  const resolvedSrc = src.startsWith("ipfs://") ? (0, ipfs_1.getIpfsUrl)(src) : src;
35
- return ((0, jsx_runtime_1.jsx)(react_1.MediaRenderer, { src: resolvedSrc, client: client, alt: alt, className: className, width: width ? width.toString() : undefined, height: height ? height.toString() : undefined, controls: controls, style: style }));
35
+ // Wrap in a span with onErrorCapture to catch img/video load errors from
36
+ // MediaRenderer, which doesn't expose an onError prop itself.
37
+ return ((0, jsx_runtime_1.jsx)("span", { onErrorCapture: onError, className: "contents", children: (0, jsx_runtime_1.jsx)(react_1.MediaRenderer, { src: resolvedSrc, client: client, alt: alt, className: className, width: width ? width.toString() : undefined, height: height ? height.toString() : undefined, controls: controls, style: style }) }));
36
38
  }
@@ -1,14 +1,20 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  const jsx_runtime_1 = require("react/jsx-runtime");
4
7
  const react_1 = require("../../../../global-account/react");
8
+ const profileDisplay_1 = require("../../../../global-account/react/utils/profileDisplay");
5
9
  const utils_1 = require("../../../../shared/utils");
6
10
  const number_1 = require("../../../../shared/utils/number");
11
+ const boring_avatars_1 = __importDefault(require("boring-avatars"));
7
12
  const lucide_react_1 = require("lucide-react");
8
13
  const react_2 = require("react");
9
14
  const react_3 = require("thirdweb/react");
10
15
  const useFirstEOA_1 = require("../../hooks/useFirstEOA");
11
16
  const IPFSMediaRenderer_1 = require("../IPFSMediaRenderer/IPFSMediaRenderer");
17
+ const AVATAR_COLORS = ["#3368ef", "#272727", "#6366f1", "#06b6d4", "#eeb0d9", "#ba3fbf", "#ff777b", "#dfbb53"];
12
18
  const ProfileSection = () => {
13
19
  const account = (0, react_3.useActiveAccount)();
14
20
  const { address: eoaAddress } = (0, useFirstEOA_1.useFirstEOA)();
@@ -22,7 +28,8 @@ const ProfileSection = () => {
22
28
  const setB3ModalContentType = (0, react_1.useModalStore)(state => state.setB3ModalContentType);
23
29
  const navigateBack = (0, react_1.useModalStore)(state => state.navigateBack);
24
30
  const { data: simBalance } = (0, react_1.useSimBalance)(smartWalletAddress);
25
- // Calculate total balance in USD
31
+ const [imgError, setImgError] = (0, react_2.useState)(false);
32
+ const handleImgError = (0, react_2.useCallback)(() => setImgError(true), []);
26
33
  const totalBalanceUsd = (0, react_2.useMemo)(() => {
27
34
  if (!simBalance?.balances)
28
35
  return 0;
@@ -33,15 +40,17 @@ const ProfileSection = () => {
33
40
  setB3ModalContentType({
34
41
  type: "avatarEditor",
35
42
  onSuccess: () => {
36
- // navigate back on success
37
43
  navigateBack();
38
44
  },
39
45
  });
40
46
  };
41
- // IPFSMediaRenderer will handle IPFS URL conversion and validation
42
- const avatarSrc = user?.avatar || profile?.avatar;
43
- // Get current username - prioritize user.username, fallback to profile data
47
+ const avatarSrc = (0, react_2.useMemo)(() => (0, profileDisplay_1.validateImageUrl)(user?.avatar) || (0, profileDisplay_1.validateImageUrl)(profile?.avatar), [user?.avatar, profile?.avatar]);
48
+ // Reset error state when avatar source changes (e.g. user uploads a new avatar)
49
+ (0, react_2.useEffect)(() => {
50
+ setImgError(false);
51
+ }, [avatarSrc]);
44
52
  const currentUsername = user?.username || profile?.displayName || (0, utils_1.formatUsername)(profile?.name || "");
45
- return ((0, jsx_runtime_1.jsx)("div", { className: "flex items-center justify-between px-5 py-6", children: (0, jsx_runtime_1.jsxs)("div", { className: "global-account-profile flex items-center gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "global-account-profile-avatar relative", children: [(0, jsx_runtime_1.jsx)(IPFSMediaRenderer_1.IPFSMediaRenderer, { src: avatarSrc, alt: "Profile Avatar", className: "border-b3-line border-1 bg-b3-primary-wash size-14 rounded-full border" }), (0, jsx_runtime_1.jsx)("button", { onClick: handleEditAvatar, className: "border-b3-background hover:bg-b3-grey/80 absolute -bottom-1 -right-1 flex size-6 items-center justify-center rounded-full border-4 bg-[#a0a0ab] transition-colors", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Pencil, { size: 10, className: "text-b3-background" }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "global-account-profile-info flex flex-col gap-1", children: [(0, jsx_runtime_1.jsxs)("h2", { className: "text-b3-grey font-neue-montreal-semibold flex h-[38px] items-center gap-1 text-xl", children: [(0, jsx_runtime_1.jsx)("div", { className: "text-b3-foreground-muted", children: " $" }), (0, jsx_runtime_1.jsx)("div", { className: "text-[30px]", children: (0, number_1.formatDisplayNumber)(totalBalanceUsd, { fractionDigits: 2 }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "b3-modal-username font-neue-montreal-semibold text-base leading-none text-[#0B57C2]", children: currentUsername })] })] }) }));
53
+ const avatarSeed = eoaAddress || account?.address || smartWalletAddress || currentUsername || "user";
54
+ return ((0, jsx_runtime_1.jsx)("div", { className: "flex items-center justify-between px-5 py-6", children: (0, jsx_runtime_1.jsxs)("div", { className: "global-account-profile flex items-center gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "global-account-profile-avatar relative", children: [(0, jsx_runtime_1.jsx)("div", { className: "border-b3-line border-1 bg-b3-primary-wash size-14 overflow-hidden rounded-full border", children: avatarSrc && !imgError ? ((0, jsx_runtime_1.jsx)(IPFSMediaRenderer_1.IPFSMediaRenderer, { src: avatarSrc, alt: "Profile Avatar", className: "h-full w-full object-cover", onError: handleImgError })) : ((0, jsx_runtime_1.jsx)(boring_avatars_1.default, { name: avatarSeed, variant: "beam", size: 56, colors: AVATAR_COLORS })) }), (0, jsx_runtime_1.jsx)("button", { onClick: handleEditAvatar, className: "border-b3-background hover:bg-b3-grey/80 absolute -bottom-1 -right-1 flex size-6 items-center justify-center rounded-full border-4 bg-[#a0a0ab] transition-colors", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Pencil, { size: 10, className: "text-b3-background" }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "global-account-profile-info flex flex-col gap-1", children: [(0, jsx_runtime_1.jsxs)("h2", { className: "text-b3-grey font-neue-montreal-semibold flex h-[38px] items-center gap-1 text-xl", children: [(0, jsx_runtime_1.jsx)("div", { className: "text-b3-foreground-muted", children: " $" }), (0, jsx_runtime_1.jsx)("div", { className: "text-[30px]", children: (0, number_1.formatDisplayNumber)(totalBalanceUsd, { fractionDigits: 2 }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "b3-modal-username font-neue-montreal-semibold text-base leading-none text-[#0B57C2]", children: currentUsername })] })] }) }));
46
55
  };
47
56
  exports.default = ProfileSection;
@@ -4,6 +4,18 @@ exports.validateImageUrl = validateImageUrl;
4
4
  exports.getProfileDisplayInfo = getProfileDisplayInfo;
5
5
  const debug_1 = require("../../../shared/utils/debug");
6
6
  const debug = (0, debug_1.debugB3React)("profileDisplay");
7
+ const ALLOWED_IPFS_GATEWAYS = [
8
+ "ipfs.io",
9
+ "gateway.pinata.cloud",
10
+ "cloudflare-ipfs.com",
11
+ "dweb.link",
12
+ "nftstorage.link",
13
+ "w3s.link",
14
+ ];
15
+ const BLOCKED_HOSTNAMES = ["models.readyplayer.me", "readyplayer.me"];
16
+ function isHostnameInList(hostname, list) {
17
+ return list.some(entry => hostname === entry || hostname.endsWith(`.${entry}`));
18
+ }
7
19
  /**
8
20
  * Validates that an image URL uses an allowed schema
9
21
  * @param url - The URL to validate
@@ -28,27 +40,14 @@ function validateImageUrl(url) {
28
40
  debug("Rejected unsafe protocol:", parsedUrl.protocol, url);
29
41
  return null;
30
42
  }
31
- // Whitelist of allowed IPFS gateway hostnames
32
- const allowedIpfsGateways = [
33
- "ipfs.io",
34
- "gateway.pinata.cloud",
35
- "cloudflare-ipfs.com",
36
- "dweb.link",
37
- "nftstorage.link",
38
- "w3s.link",
39
- ];
40
- // Check if hostname matches allowed IPFS gateways
41
43
  const hostname = parsedUrl.hostname.toLowerCase();
42
- const isAllowedIpfsGateway = allowedIpfsGateways.some(gateway => {
43
- // Exact match or subdomain of the gateway
44
- return hostname === gateway || hostname.endsWith(`.${gateway}`);
45
- });
46
- if (isAllowedIpfsGateway) {
44
+ if (isHostnameInList(hostname, ALLOWED_IPFS_GATEWAYS)) {
47
45
  return url;
48
46
  }
49
- // For standard HTTP(S) URLs from trusted sources
50
- // Add additional hostname validation here if needed
51
- // For now, allow all HTTP(S) URLs (can be restricted further if needed)
47
+ if (isHostnameInList(hostname, BLOCKED_HOSTNAMES)) {
48
+ debug("Rejected deprecated avatar service:", hostname, url);
49
+ return null;
50
+ }
52
51
  return url;
53
52
  }
54
53
  catch (error) {
@@ -16,6 +16,8 @@ interface IPFSMediaRendererProps {
16
16
  controls?: boolean;
17
17
  /** Style object */
18
18
  style?: React.CSSProperties;
19
+ /** Callback when media fails to load */
20
+ onError?: () => void;
19
21
  }
20
22
  /**
21
23
  * IPFSMediaRenderer - A wrapper around Thirdweb's MediaRenderer that configures
@@ -35,5 +37,5 @@ interface IPFSMediaRendererProps {
35
37
  * />
36
38
  * ```
37
39
  */
38
- export declare function IPFSMediaRenderer({ src, alt, className, client, width, height, controls, style, }: IPFSMediaRendererProps): import("react/jsx-runtime").JSX.Element;
40
+ export declare function IPFSMediaRenderer({ src, alt, className, client, width, height, controls, style, onError, }: IPFSMediaRendererProps): import("react/jsx-runtime").JSX.Element;
39
41
  export {};
@@ -21,7 +21,7 @@ import { MediaRenderer } from "thirdweb/react";
21
21
  * />
22
22
  * ```
23
23
  */
24
- export function IPFSMediaRenderer({ src, alt = "Media", className, client = defaultClient, width, height, controls, style, }) {
24
+ export function IPFSMediaRenderer({ src, alt = "Media", className, client = defaultClient, width, height, controls, style, onError, }) {
25
25
  // If no source, render fallback
26
26
  if (!src) {
27
27
  return (_jsx("div", { className: className, style: style, "aria-label": alt, children: _jsx("div", { className: "bg-b3-primary-wash flex h-full w-full items-center justify-center rounded-full", children: _jsx("span", { className: "text-b3-grey font-neue-montreal-semibold text-xs", children: alt.charAt(0).toUpperCase() }) }) }));
@@ -29,5 +29,7 @@ export function IPFSMediaRenderer({ src, alt = "Media", className, client = defa
29
29
  // Convert IPFS URLs to HTTP gateway URLs using our preferred gateway
30
30
  // This avoids Thirdweb's default cloudflare-ipfs.com which can be unreliable
31
31
  const resolvedSrc = src.startsWith("ipfs://") ? getIpfsUrl(src) : src;
32
- return (_jsx(MediaRenderer, { src: resolvedSrc, client: client, alt: alt, className: className, width: width ? width.toString() : undefined, height: height ? height.toString() : undefined, controls: controls, style: style }));
32
+ // Wrap in a span with onErrorCapture to catch img/video load errors from
33
+ // MediaRenderer, which doesn't expose an onError prop itself.
34
+ return (_jsx("span", { onErrorCapture: onError, className: "contents", children: _jsx(MediaRenderer, { src: resolvedSrc, client: client, alt: alt, className: className, width: width ? width.toString() : undefined, height: height ? height.toString() : undefined, controls: controls, style: style }) }));
33
35
  }
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useAccountWallet, useModalStore, useProfile, useSimBalance, useUser, } from "../../../../global-account/react/index.js";
3
+ import { validateImageUrl } from "../../../../global-account/react/utils/profileDisplay.js";
3
4
  import { formatUsername } from "../../../../shared/utils/index.js";
4
5
  import { formatDisplayNumber } from "../../../../shared/utils/number.js";
6
+ import Avatar from "boring-avatars";
5
7
  import { Pencil } from "lucide-react";
6
- import { useMemo } from "react";
8
+ import { useCallback, useEffect, useMemo, useState } from "react";
7
9
  import { useActiveAccount } from "thirdweb/react";
8
10
  import { useFirstEOA } from "../../hooks/useFirstEOA.js";
9
11
  import { IPFSMediaRenderer } from "../IPFSMediaRenderer/IPFSMediaRenderer.js";
12
+ const AVATAR_COLORS = ["#3368ef", "#272727", "#6366f1", "#06b6d4", "#eeb0d9", "#ba3fbf", "#ff777b", "#dfbb53"];
10
13
  const ProfileSection = () => {
11
14
  const account = useActiveAccount();
12
15
  const { address: eoaAddress } = useFirstEOA();
@@ -20,7 +23,8 @@ const ProfileSection = () => {
20
23
  const setB3ModalContentType = useModalStore(state => state.setB3ModalContentType);
21
24
  const navigateBack = useModalStore(state => state.navigateBack);
22
25
  const { data: simBalance } = useSimBalance(smartWalletAddress);
23
- // Calculate total balance in USD
26
+ const [imgError, setImgError] = useState(false);
27
+ const handleImgError = useCallback(() => setImgError(true), []);
24
28
  const totalBalanceUsd = useMemo(() => {
25
29
  if (!simBalance?.balances)
26
30
  return 0;
@@ -31,15 +35,17 @@ const ProfileSection = () => {
31
35
  setB3ModalContentType({
32
36
  type: "avatarEditor",
33
37
  onSuccess: () => {
34
- // navigate back on success
35
38
  navigateBack();
36
39
  },
37
40
  });
38
41
  };
39
- // IPFSMediaRenderer will handle IPFS URL conversion and validation
40
- const avatarSrc = user?.avatar || profile?.avatar;
41
- // Get current username - prioritize user.username, fallback to profile data
42
+ const avatarSrc = useMemo(() => validateImageUrl(user?.avatar) || validateImageUrl(profile?.avatar), [user?.avatar, profile?.avatar]);
43
+ // Reset error state when avatar source changes (e.g. user uploads a new avatar)
44
+ useEffect(() => {
45
+ setImgError(false);
46
+ }, [avatarSrc]);
42
47
  const currentUsername = user?.username || profile?.displayName || formatUsername(profile?.name || "");
43
- return (_jsx("div", { className: "flex items-center justify-between px-5 py-6", children: _jsxs("div", { className: "global-account-profile flex items-center gap-4", children: [_jsxs("div", { className: "global-account-profile-avatar relative", children: [_jsx(IPFSMediaRenderer, { src: avatarSrc, alt: "Profile Avatar", className: "border-b3-line border-1 bg-b3-primary-wash size-14 rounded-full border" }), _jsx("button", { onClick: handleEditAvatar, className: "border-b3-background hover:bg-b3-grey/80 absolute -bottom-1 -right-1 flex size-6 items-center justify-center rounded-full border-4 bg-[#a0a0ab] transition-colors", children: _jsx(Pencil, { size: 10, className: "text-b3-background" }) })] }), _jsxs("div", { className: "global-account-profile-info flex flex-col gap-1", children: [_jsxs("h2", { className: "text-b3-grey font-neue-montreal-semibold flex h-[38px] items-center gap-1 text-xl", children: [_jsx("div", { className: "text-b3-foreground-muted", children: " $" }), _jsx("div", { className: "text-[30px]", children: formatDisplayNumber(totalBalanceUsd, { fractionDigits: 2 }) })] }), _jsx("div", { className: "b3-modal-username font-neue-montreal-semibold text-base leading-none text-[#0B57C2]", children: currentUsername })] })] }) }));
48
+ const avatarSeed = eoaAddress || account?.address || smartWalletAddress || currentUsername || "user";
49
+ return (_jsx("div", { className: "flex items-center justify-between px-5 py-6", children: _jsxs("div", { className: "global-account-profile flex items-center gap-4", children: [_jsxs("div", { className: "global-account-profile-avatar relative", children: [_jsx("div", { className: "border-b3-line border-1 bg-b3-primary-wash size-14 overflow-hidden rounded-full border", children: avatarSrc && !imgError ? (_jsx(IPFSMediaRenderer, { src: avatarSrc, alt: "Profile Avatar", className: "h-full w-full object-cover", onError: handleImgError })) : (_jsx(Avatar, { name: avatarSeed, variant: "beam", size: 56, colors: AVATAR_COLORS })) }), _jsx("button", { onClick: handleEditAvatar, className: "border-b3-background hover:bg-b3-grey/80 absolute -bottom-1 -right-1 flex size-6 items-center justify-center rounded-full border-4 bg-[#a0a0ab] transition-colors", children: _jsx(Pencil, { size: 10, className: "text-b3-background" }) })] }), _jsxs("div", { className: "global-account-profile-info flex flex-col gap-1", children: [_jsxs("h2", { className: "text-b3-grey font-neue-montreal-semibold flex h-[38px] items-center gap-1 text-xl", children: [_jsx("div", { className: "text-b3-foreground-muted", children: " $" }), _jsx("div", { className: "text-[30px]", children: formatDisplayNumber(totalBalanceUsd, { fractionDigits: 2 }) })] }), _jsx("div", { className: "b3-modal-username font-neue-montreal-semibold text-base leading-none text-[#0B57C2]", children: currentUsername })] })] }) }));
44
50
  };
45
51
  export default ProfileSection;
@@ -1,5 +1,17 @@
1
1
  import { debugB3React } from "../../../shared/utils/debug.js";
2
2
  const debug = debugB3React("profileDisplay");
3
+ const ALLOWED_IPFS_GATEWAYS = [
4
+ "ipfs.io",
5
+ "gateway.pinata.cloud",
6
+ "cloudflare-ipfs.com",
7
+ "dweb.link",
8
+ "nftstorage.link",
9
+ "w3s.link",
10
+ ];
11
+ const BLOCKED_HOSTNAMES = ["models.readyplayer.me", "readyplayer.me"];
12
+ function isHostnameInList(hostname, list) {
13
+ return list.some(entry => hostname === entry || hostname.endsWith(`.${entry}`));
14
+ }
3
15
  /**
4
16
  * Validates that an image URL uses an allowed schema
5
17
  * @param url - The URL to validate
@@ -24,27 +36,14 @@ export function validateImageUrl(url) {
24
36
  debug("Rejected unsafe protocol:", parsedUrl.protocol, url);
25
37
  return null;
26
38
  }
27
- // Whitelist of allowed IPFS gateway hostnames
28
- const allowedIpfsGateways = [
29
- "ipfs.io",
30
- "gateway.pinata.cloud",
31
- "cloudflare-ipfs.com",
32
- "dweb.link",
33
- "nftstorage.link",
34
- "w3s.link",
35
- ];
36
- // Check if hostname matches allowed IPFS gateways
37
39
  const hostname = parsedUrl.hostname.toLowerCase();
38
- const isAllowedIpfsGateway = allowedIpfsGateways.some(gateway => {
39
- // Exact match or subdomain of the gateway
40
- return hostname === gateway || hostname.endsWith(`.${gateway}`);
41
- });
42
- if (isAllowedIpfsGateway) {
40
+ if (isHostnameInList(hostname, ALLOWED_IPFS_GATEWAYS)) {
43
41
  return url;
44
42
  }
45
- // For standard HTTP(S) URLs from trusted sources
46
- // Add additional hostname validation here if needed
47
- // For now, allow all HTTP(S) URLs (can be restricted further if needed)
43
+ if (isHostnameInList(hostname, BLOCKED_HOSTNAMES)) {
44
+ debug("Rejected deprecated avatar service:", hostname, url);
45
+ return null;
46
+ }
48
47
  return url;
49
48
  }
50
49
  catch (error) {
@@ -16,6 +16,8 @@ interface IPFSMediaRendererProps {
16
16
  controls?: boolean;
17
17
  /** Style object */
18
18
  style?: React.CSSProperties;
19
+ /** Callback when media fails to load */
20
+ onError?: () => void;
19
21
  }
20
22
  /**
21
23
  * IPFSMediaRenderer - A wrapper around Thirdweb's MediaRenderer that configures
@@ -35,5 +37,5 @@ interface IPFSMediaRendererProps {
35
37
  * />
36
38
  * ```
37
39
  */
38
- export declare function IPFSMediaRenderer({ src, alt, className, client, width, height, controls, style, }: IPFSMediaRendererProps): import("react/jsx-runtime").JSX.Element;
40
+ export declare function IPFSMediaRenderer({ src, alt, className, client, width, height, controls, style, onError, }: IPFSMediaRendererProps): import("react/jsx-runtime").JSX.Element;
39
41
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b3dotfun/sdk",
3
- "version": "0.1.69-alpha.13",
3
+ "version": "0.1.69-alpha.14",
4
4
  "source": "src/index.ts",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "react-native": "./dist/cjs/index.native.js",
@@ -330,6 +330,7 @@
330
330
  "@thirdweb-dev/wagmi-adapter": "0.2.165",
331
331
  "@web3icons/react": "3.16.0",
332
332
  "big.js": "^7.0.1",
333
+ "boring-avatars": "^2.0.4",
333
334
  "class-variance-authority": "0.7.0",
334
335
  "clsx": "2.0.0",
335
336
  "cmdk": "1.0.0",
@@ -22,6 +22,8 @@ interface IPFSMediaRendererProps {
22
22
  controls?: boolean;
23
23
  /** Style object */
24
24
  style?: React.CSSProperties;
25
+ /** Callback when media fails to load */
26
+ onError?: () => void;
25
27
  }
26
28
 
27
29
  /**
@@ -51,6 +53,7 @@ export function IPFSMediaRenderer({
51
53
  height,
52
54
  controls,
53
55
  style,
56
+ onError,
54
57
  }: IPFSMediaRendererProps) {
55
58
  // If no source, render fallback
56
59
  if (!src) {
@@ -67,16 +70,20 @@ export function IPFSMediaRenderer({
67
70
  // This avoids Thirdweb's default cloudflare-ipfs.com which can be unreliable
68
71
  const resolvedSrc = src.startsWith("ipfs://") ? getIpfsUrl(src) : src;
69
72
 
73
+ // Wrap in a span with onErrorCapture to catch img/video load errors from
74
+ // MediaRenderer, which doesn't expose an onError prop itself.
70
75
  return (
71
- <MediaRenderer
72
- src={resolvedSrc}
73
- client={client}
74
- alt={alt}
75
- className={className}
76
- width={width ? width.toString() : undefined}
77
- height={height ? height.toString() : undefined}
78
- controls={controls}
79
- style={style}
80
- />
76
+ <span onErrorCapture={onError} className="contents">
77
+ <MediaRenderer
78
+ src={resolvedSrc}
79
+ client={client}
80
+ alt={alt}
81
+ className={className}
82
+ width={width ? width.toString() : undefined}
83
+ height={height ? height.toString() : undefined}
84
+ controls={controls}
85
+ style={style}
86
+ />
87
+ </span>
81
88
  );
82
89
  }
@@ -5,14 +5,18 @@ import {
5
5
  useSimBalance,
6
6
  useUser,
7
7
  } from "@b3dotfun/sdk/global-account/react";
8
+ import { validateImageUrl } from "@b3dotfun/sdk/global-account/react/utils/profileDisplay";
8
9
  import { formatUsername } from "@b3dotfun/sdk/shared/utils";
9
10
  import { formatDisplayNumber } from "@b3dotfun/sdk/shared/utils/number";
11
+ import Avatar from "boring-avatars";
10
12
  import { Pencil } from "lucide-react";
11
- import { useMemo } from "react";
13
+ import { useCallback, useEffect, useMemo, useState } from "react";
12
14
  import { useActiveAccount } from "thirdweb/react";
13
15
  import { useFirstEOA } from "../../hooks/useFirstEOA";
14
16
  import { IPFSMediaRenderer } from "../IPFSMediaRenderer/IPFSMediaRenderer";
15
17
 
18
+ const AVATAR_COLORS = ["#3368ef", "#272727", "#6366f1", "#06b6d4", "#eeb0d9", "#ba3fbf", "#ff777b", "#dfbb53"];
19
+
16
20
  const ProfileSection = () => {
17
21
  const account = useActiveAccount();
18
22
  const { address: eoaAddress } = useFirstEOA();
@@ -27,8 +31,9 @@ const ProfileSection = () => {
27
31
  const navigateBack = useModalStore(state => state.navigateBack);
28
32
 
29
33
  const { data: simBalance } = useSimBalance(smartWalletAddress);
34
+ const [imgError, setImgError] = useState(false);
35
+ const handleImgError = useCallback(() => setImgError(true), []);
30
36
 
31
- // Calculate total balance in USD
32
37
  const totalBalanceUsd = useMemo(() => {
33
38
  if (!simBalance?.balances) return 0;
34
39
  return simBalance.balances.reduce((sum, token) => sum + (token.value_usd || 0), 0);
@@ -39,27 +44,40 @@ const ProfileSection = () => {
39
44
  setB3ModalContentType({
40
45
  type: "avatarEditor",
41
46
  onSuccess: () => {
42
- // navigate back on success
43
47
  navigateBack();
44
48
  },
45
49
  });
46
50
  };
47
51
 
48
- // IPFSMediaRenderer will handle IPFS URL conversion and validation
49
- const avatarSrc = user?.avatar || profile?.avatar;
52
+ const avatarSrc = useMemo(
53
+ () => validateImageUrl(user?.avatar) || validateImageUrl(profile?.avatar),
54
+ [user?.avatar, profile?.avatar],
55
+ );
56
+
57
+ // Reset error state when avatar source changes (e.g. user uploads a new avatar)
58
+ useEffect(() => {
59
+ setImgError(false);
60
+ }, [avatarSrc]);
50
61
 
51
- // Get current username - prioritize user.username, fallback to profile data
52
62
  const currentUsername = user?.username || profile?.displayName || formatUsername(profile?.name || "");
63
+ const avatarSeed = eoaAddress || account?.address || smartWalletAddress || currentUsername || "user";
53
64
 
54
65
  return (
55
66
  <div className="flex items-center justify-between px-5 py-6">
56
67
  <div className="global-account-profile flex items-center gap-4">
57
68
  <div className="global-account-profile-avatar relative">
58
- <IPFSMediaRenderer
59
- src={avatarSrc}
60
- alt="Profile Avatar"
61
- className="border-b3-line border-1 bg-b3-primary-wash size-14 rounded-full border"
62
- />
69
+ <div className="border-b3-line border-1 bg-b3-primary-wash size-14 overflow-hidden rounded-full border">
70
+ {avatarSrc && !imgError ? (
71
+ <IPFSMediaRenderer
72
+ src={avatarSrc}
73
+ alt="Profile Avatar"
74
+ className="h-full w-full object-cover"
75
+ onError={handleImgError}
76
+ />
77
+ ) : (
78
+ <Avatar name={avatarSeed} variant="beam" size={56} colors={AVATAR_COLORS} />
79
+ )}
80
+ </div>
63
81
 
64
82
  <button
65
83
  onClick={handleEditAvatar}
@@ -3,6 +3,21 @@ import { type Profile } from "thirdweb/wallets";
3
3
 
4
4
  const debug = debugB3React("profileDisplay");
5
5
 
6
+ const ALLOWED_IPFS_GATEWAYS = [
7
+ "ipfs.io",
8
+ "gateway.pinata.cloud",
9
+ "cloudflare-ipfs.com",
10
+ "dweb.link",
11
+ "nftstorage.link",
12
+ "w3s.link",
13
+ ];
14
+
15
+ const BLOCKED_HOSTNAMES = ["models.readyplayer.me", "readyplayer.me"];
16
+
17
+ function isHostnameInList(hostname: string, list: string[]): boolean {
18
+ return list.some(entry => hostname === entry || hostname.endsWith(`.${entry}`));
19
+ }
20
+
6
21
  /**
7
22
  * Validates that an image URL uses an allowed schema
8
23
  * @param url - The URL to validate
@@ -31,30 +46,17 @@ export function validateImageUrl(url: string | null | undefined): string | null
31
46
  return null;
32
47
  }
33
48
 
34
- // Whitelist of allowed IPFS gateway hostnames
35
- const allowedIpfsGateways = [
36
- "ipfs.io",
37
- "gateway.pinata.cloud",
38
- "cloudflare-ipfs.com",
39
- "dweb.link",
40
- "nftstorage.link",
41
- "w3s.link",
42
- ];
43
-
44
- // Check if hostname matches allowed IPFS gateways
45
49
  const hostname = parsedUrl.hostname.toLowerCase();
46
- const isAllowedIpfsGateway = allowedIpfsGateways.some(gateway => {
47
- // Exact match or subdomain of the gateway
48
- return hostname === gateway || hostname.endsWith(`.${gateway}`);
49
- });
50
50
 
51
- if (isAllowedIpfsGateway) {
51
+ if (isHostnameInList(hostname, ALLOWED_IPFS_GATEWAYS)) {
52
52
  return url;
53
53
  }
54
54
 
55
- // For standard HTTP(S) URLs from trusted sources
56
- // Add additional hostname validation here if needed
57
- // For now, allow all HTTP(S) URLs (can be restricted further if needed)
55
+ if (isHostnameInList(hostname, BLOCKED_HOSTNAMES)) {
56
+ debug("Rejected deprecated avatar service:", hostname, url);
57
+ return null;
58
+ }
59
+
58
60
  return url;
59
61
  } catch (error) {
60
62
  // Invalid URL format