@b3dotfun/sdk 0.1.69-alpha.13 → 0.1.69-alpha.15
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.
- package/dist/cjs/global-account/react/components/IPFSMediaRenderer/IPFSMediaRenderer.d.ts +3 -1
- package/dist/cjs/global-account/react/components/IPFSMediaRenderer/IPFSMediaRenderer.js +4 -2
- package/dist/cjs/global-account/react/components/LinkAccount/LinkedAccountItem.js +2 -1
- package/dist/cjs/global-account/react/components/ManageAccount/ProfileSection.js +15 -6
- package/dist/cjs/global-account/react/components/ManageAccount/SettingsProfileCard.js +2 -2
- package/dist/cjs/global-account/react/components/Send/Send.js +5 -2
- package/dist/cjs/global-account/react/components/SingleUserSearchSelector/SingleUserSearchSelector.js +2 -1
- package/dist/cjs/global-account/react/utils/profileDisplay.js +17 -18
- package/dist/esm/global-account/react/components/IPFSMediaRenderer/IPFSMediaRenderer.d.ts +3 -1
- package/dist/esm/global-account/react/components/IPFSMediaRenderer/IPFSMediaRenderer.js +4 -2
- package/dist/esm/global-account/react/components/LinkAccount/LinkedAccountItem.js +2 -1
- package/dist/esm/global-account/react/components/ManageAccount/ProfileSection.js +13 -7
- package/dist/esm/global-account/react/components/ManageAccount/SettingsProfileCard.js +2 -2
- package/dist/esm/global-account/react/components/Send/Send.js +5 -2
- package/dist/esm/global-account/react/components/SingleUserSearchSelector/SingleUserSearchSelector.js +2 -1
- package/dist/esm/global-account/react/utils/profileDisplay.js +17 -18
- package/dist/types/global-account/react/components/IPFSMediaRenderer/IPFSMediaRenderer.d.ts +3 -1
- package/package.json +2 -1
- package/src/global-account/react/components/IPFSMediaRenderer/IPFSMediaRenderer.tsx +17 -10
- package/src/global-account/react/components/LinkAccount/LinkedAccountItem.tsx +2 -1
- package/src/global-account/react/components/ManageAccount/ProfileSection.tsx +29 -11
- package/src/global-account/react/components/ManageAccount/SettingsProfileCard.tsx +2 -2
- package/src/global-account/react/components/Send/Send.tsx +8 -5
- package/src/global-account/react/components/SingleUserSearchSelector/SingleUserSearchSelector.tsx +2 -1
- package/src/global-account/react/utils/profileDisplay.ts +21 -19
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
4
|
const react_1 = require("../../../../global-account/react");
|
|
5
|
+
const profileDisplay_1 = require("../../../../global-account/react/utils/profileDisplay");
|
|
5
6
|
const truncateAddress_1 = require("../../../../shared/utils/truncateAddress");
|
|
6
7
|
const lucide_react_1 = require("lucide-react");
|
|
7
8
|
// Helper function to check if a string is a wallet address and format it
|
|
@@ -32,7 +33,7 @@ const LinkedAccountItem = ({ profile, profileToUnlink, unlinkingAccountId, isUnl
|
|
|
32
33
|
react_1.toast.error("Failed to copy address");
|
|
33
34
|
}
|
|
34
35
|
};
|
|
35
|
-
const displayImageUrl = profileData?.avatar || profile.imageUrl;
|
|
36
|
+
const displayImageUrl = (0, profileDisplay_1.validateImageUrl)(profileData?.avatar) || (0, profileDisplay_1.validateImageUrl)(profile.imageUrl);
|
|
36
37
|
const displayName = profileData?.name || displayTitle;
|
|
37
38
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "linked-account-item hover:bg-b3-line group flex cursor-pointer items-center justify-between rounded-xl p-4 transition-colors", children: [(0, jsx_runtime_1.jsxs)("div", { className: "linked-account-info flex items-center gap-3", children: [displayImageUrl ? ((0, jsx_runtime_1.jsx)("img", { src: displayImageUrl, alt: profile.title, className: "linked-account-avatar linked-account-avatar-image size-10 rounded-full" })) : ((0, jsx_runtime_1.jsx)("div", { className: "linked-account-avatar linked-account-avatar-placeholder bg-b3-primary-wash flex h-10 w-10 items-center justify-center rounded-full", children: (0, jsx_runtime_1.jsx)("span", { className: "linked-account-initial text-b3-grey font-neue-montreal-semibold text-sm uppercase", children: profile.initial }) })), (0, jsx_runtime_1.jsxs)("div", { className: "linked-account-details", children: [(0, jsx_runtime_1.jsxs)("div", { className: "linked-account-title-row flex items-center gap-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1", children: [(0, jsx_runtime_1.jsx)("span", { className: `linked-account-title text-b3-grey font-neue-montreal-semibold ${isAddress
|
|
38
39
|
? "font-mono text-sm" // Use monospace font for addresses
|
|
@@ -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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -7,6 +7,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
7
7
|
const ens_normalize_1 = require("@adraffy/ens-normalize");
|
|
8
8
|
const app_1 = __importDefault(require("../../../../global-account/app"));
|
|
9
9
|
const react_1 = require("../../../../global-account/react");
|
|
10
|
+
const profileDisplay_1 = require("../../../../global-account/react/utils/profileDisplay");
|
|
10
11
|
const utils_1 = require("../../../../shared/utils");
|
|
11
12
|
const lucide_react_1 = require("lucide-react");
|
|
12
13
|
const react_2 = require("react");
|
|
@@ -30,8 +31,7 @@ const SettingsProfileCard = () => {
|
|
|
30
31
|
const [editedUsername, setEditedUsername] = (0, react_2.useState)("");
|
|
31
32
|
const [isSaving, setIsSaving] = (0, react_2.useState)(false);
|
|
32
33
|
const inputRef = (0, react_2.useRef)(null);
|
|
33
|
-
|
|
34
|
-
const avatarSrc = user?.avatar || profile?.avatar;
|
|
34
|
+
const avatarSrc = (0, profileDisplay_1.validateImageUrl)(user?.avatar) || (0, profileDisplay_1.validateImageUrl)(profile?.avatar);
|
|
35
35
|
// Get current username - prioritize user.username, fallback to profile data
|
|
36
36
|
const currentUsername = user?.username || profile?.displayName || (0, utils_1.formatUsername)(profile?.name || "");
|
|
37
37
|
// Focus input when entering edit mode
|
|
@@ -8,6 +8,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
8
8
|
const anyspend_1 = require("../../../../anyspend");
|
|
9
9
|
const ChainTokenIcon_1 = require("../../../../anyspend/react/components/common/ChainTokenIcon");
|
|
10
10
|
const react_1 = require("../../../../global-account/react");
|
|
11
|
+
const profileDisplay_1 = require("../../../../global-account/react/utils/profileDisplay");
|
|
11
12
|
const number_1 = require("../../../../shared/utils/number");
|
|
12
13
|
const invariant_1 = __importDefault(require("invariant"));
|
|
13
14
|
const lucide_react_1 = require("lucide-react");
|
|
@@ -20,7 +21,8 @@ const button_1 = require("../ui/button");
|
|
|
20
21
|
// Component for displaying a recent address with profile data
|
|
21
22
|
function RecentAddressItem({ address, onClick }) {
|
|
22
23
|
const { data: profileData } = (0, react_1.useProfile)({ address });
|
|
23
|
-
|
|
24
|
+
const validatedAvatar = (0, profileDisplay_1.validateImageUrl)(profileData?.avatar);
|
|
25
|
+
return ((0, jsx_runtime_1.jsxs)("button", { onClick: onClick, className: "flex items-center gap-2 rounded-xl px-3 py-2 transition-colors hover:bg-[#fafafa]", children: [validatedAvatar ? ((0, jsx_runtime_1.jsx)("img", { src: validatedAvatar, alt: profileData?.name || address, className: "h-10 w-10 rounded-full" })) : ((0, jsx_runtime_1.jsx)("div", { className: "flex h-10 w-10 items-center justify-center rounded-full border border-[#e4e4e7] bg-[#f4f4f5]", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Wallet, { className: "h-5 w-5 text-[#a0a0ab]" }) })), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col items-start", children: (0, jsx_runtime_1.jsxs)("span", { className: "font-neue-montreal-medium text-base tracking-[-0.32px] text-[#70707b]", children: [address.slice(0, 6), "...", address.slice(-4), profileData?.name && ` (${profileData.name})`] }) })] }));
|
|
24
26
|
}
|
|
25
27
|
function Send({ recipientAddress: initialRecipient, onSuccess }) {
|
|
26
28
|
const { address } = (0, react_1.useAccountWallet)();
|
|
@@ -45,6 +47,7 @@ function Send({ recipientAddress: initialRecipient, onSuccess }) {
|
|
|
45
47
|
const { data: validatedProfileData } = (0, react_1.useProfile)({
|
|
46
48
|
address: showValidatedResult && recipientAddress && (0, viem_1.isAddress)(recipientAddress) ? recipientAddress : undefined,
|
|
47
49
|
});
|
|
50
|
+
const validatedRecipientAvatar = (0, profileDisplay_1.validateImageUrl)(validatedProfileData?.avatar);
|
|
48
51
|
// Address validation
|
|
49
52
|
const handleRecipientAddressChange = (value) => {
|
|
50
53
|
setRecipientAddress(value);
|
|
@@ -185,7 +188,7 @@ function Send({ recipientAddress: initialRecipient, onSuccess }) {
|
|
|
185
188
|
return "Send";
|
|
186
189
|
}
|
|
187
190
|
};
|
|
188
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "dark:bg-b3-background flex h-[600px] w-full flex-col bg-white", children: [(0, jsx_runtime_1.jsx)(ModalHeader_1.default, { handleBack: handleBack, title: getStepTitle() }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-1 overflow-y-auto", children: [step === "recipient" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-6 p-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dark:border-b3-line dark:bg-b3-background flex h-12 w-full items-stretch overflow-hidden rounded-lg border border-[#d1d1d6] bg-white", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex w-12 items-center justify-center bg-transparent px-3 py-2", children: (0, jsx_runtime_1.jsx)("span", { className: "font-neue-montreal-medium text-base text-[#3f3f46] dark:text-white", children: "To" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-1 items-center border-l border-[#d1d1d6] px-3 py-2", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", value: recipientAddress, onChange: e => handleRecipientAddressChange(e.target.value), placeholder: "ENS or Address", className: "font-neue-montreal-medium dark:bg-b3-background flex-1 text-base text-[#70707b] outline-none placeholder:text-[#70707b] dark:text-white dark:placeholder:text-white" }), (0, jsx_runtime_1.jsx)("button", { onClick: handlePaste, className: "font-inter ml-2 rounded-md border border-[#e4e4e7] bg-[#fafafa] px-2.5 py-0.5 text-sm font-medium text-[#3f3f46] transition-colors hover:bg-[#f4f4f5]", children: "Paste" })] })] }), addressError && (0, jsx_runtime_1.jsx)("p", { className: "font-neue-montreal-medium -mt-4 text-xs text-red-500", children: addressError }), showValidatedResult && recipientAddress && (0, viem_1.isAddress)(recipientAddress) && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex items-center gap-1", children: (0, jsx_runtime_1.jsx)("span", { className: "font-sf-pro-text text-sm font-semibold leading-[1.3] tracking-[-0.41px] text-[#0b57c2]", children: "Result" }) }), (0, jsx_runtime_1.jsxs)("button", { onClick: handleSelectValidatedAddress, className: "dark:bg-b3-background dark:border-b3-line flex items-center gap-2 rounded-xl bg-[#f4f4f5] px-3 py-2 transition-colors hover:bg-[#e4e4e7]", children: [
|
|
191
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "dark:bg-b3-background flex h-[600px] w-full flex-col bg-white", children: [(0, jsx_runtime_1.jsx)(ModalHeader_1.default, { handleBack: handleBack, title: getStepTitle() }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-1 overflow-y-auto", children: [step === "recipient" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-6 p-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dark:border-b3-line dark:bg-b3-background flex h-12 w-full items-stretch overflow-hidden rounded-lg border border-[#d1d1d6] bg-white", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex w-12 items-center justify-center bg-transparent px-3 py-2", children: (0, jsx_runtime_1.jsx)("span", { className: "font-neue-montreal-medium text-base text-[#3f3f46] dark:text-white", children: "To" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-1 items-center border-l border-[#d1d1d6] px-3 py-2", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", value: recipientAddress, onChange: e => handleRecipientAddressChange(e.target.value), placeholder: "ENS or Address", className: "font-neue-montreal-medium dark:bg-b3-background flex-1 text-base text-[#70707b] outline-none placeholder:text-[#70707b] dark:text-white dark:placeholder:text-white" }), (0, jsx_runtime_1.jsx)("button", { onClick: handlePaste, className: "font-inter ml-2 rounded-md border border-[#e4e4e7] bg-[#fafafa] px-2.5 py-0.5 text-sm font-medium text-[#3f3f46] transition-colors hover:bg-[#f4f4f5]", children: "Paste" })] })] }), addressError && (0, jsx_runtime_1.jsx)("p", { className: "font-neue-montreal-medium -mt-4 text-xs text-red-500", children: addressError }), showValidatedResult && recipientAddress && (0, viem_1.isAddress)(recipientAddress) && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex items-center gap-1", children: (0, jsx_runtime_1.jsx)("span", { className: "font-sf-pro-text text-sm font-semibold leading-[1.3] tracking-[-0.41px] text-[#0b57c2]", children: "Result" }) }), (0, jsx_runtime_1.jsxs)("button", { onClick: handleSelectValidatedAddress, className: "dark:bg-b3-background dark:border-b3-line flex items-center gap-2 rounded-xl bg-[#f4f4f5] px-3 py-2 transition-colors hover:bg-[#e4e4e7]", children: [validatedRecipientAvatar ? ((0, jsx_runtime_1.jsx)("img", { src: validatedRecipientAvatar, alt: validatedProfileData?.name || recipientAddress, className: "h-10 w-10 rounded-full" })) : ((0, jsx_runtime_1.jsx)("div", { className: "dark:border-b3-line dark:bg-b3-background flex h-10 w-10 items-center justify-center rounded-full border border-[#e4e4e7] bg-[#f4f4f5]", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Wallet, { className: "h-5 w-5 text-[#a0a0ab] dark:text-white" }) })), (0, jsx_runtime_1.jsxs)("span", { className: "font-neue-montreal-medium text-base tracking-[-0.32px] text-[#70707b] dark:text-white", children: [recipientAddress.slice(0, 6), "...", recipientAddress.slice(-4), validatedProfileData?.name && ` (${validatedProfileData.name})`] })] })] })), recentAddresses.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "h-3.5 w-3.5 text-[#3f3f46]" }), (0, jsx_runtime_1.jsx)("span", { className: "font-sf-pro-text text-sm font-semibold leading-[1.3] tracking-[-0.41px] text-[#3f3f46]", children: "Recents" })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col", children: recentAddresses.map((recent, index) => ((0, jsx_runtime_1.jsx)(RecentAddressItem, { address: recent.address, onClick: () => {
|
|
189
192
|
// Just fill the input and show validation - don't auto-proceed
|
|
190
193
|
handleRecipientAddressChange(recent.address);
|
|
191
194
|
} }, index))) })] }))] })), step === "token" && ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-col p-5", children: isLoadingBalance ? ((0, jsx_runtime_1.jsx)("div", { className: "space-y-4", children: (0, jsx_runtime_1.jsx)("div", { className: "space-y-1", children: [...Array(3)].map((_, index) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between rounded-xl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsx)("div", { className: "bg-b3-line h-10 w-10 animate-pulse rounded-full" }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "bg-b3-line mb-1 h-4 w-16 animate-pulse rounded" }), (0, jsx_runtime_1.jsx)("div", { className: "bg-b3-line h-3 w-24 animate-pulse rounded" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "text-right", children: [(0, jsx_runtime_1.jsx)("div", { className: "bg-b3-line mb-1 h-4 w-20 animate-pulse rounded" }), (0, jsx_runtime_1.jsx)("div", { className: "bg-b3-line h-3 w-16 animate-pulse rounded" })] })] }, index))) }) })) : simBalance?.balances && simBalance.balances.length > 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "space-y-4", children: (0, jsx_runtime_1.jsx)("div", { className: "space-y-1", children: simBalance.balances.map(token => ((0, jsx_runtime_1.jsxs)("div", { className: "hover:bg-b3-line/60 dark:hover:bg-b3-primary-wash/40 group flex cursor-pointer items-center justify-between rounded-xl p-3 transition-all duration-200", onClick: () => {
|
|
@@ -7,6 +7,7 @@ const utils_1 = require("../../../../shared/utils");
|
|
|
7
7
|
const lucide_react_1 = require("lucide-react");
|
|
8
8
|
const react_1 = require("react");
|
|
9
9
|
const profileApi_1 = require("../../utils/profileApi");
|
|
10
|
+
const profileDisplay_1 = require("../../utils/profileDisplay");
|
|
10
11
|
const IPFSMediaRenderer_1 = require("../IPFSMediaRenderer/IPFSMediaRenderer");
|
|
11
12
|
const input_1 = require("../ui/input");
|
|
12
13
|
/**
|
|
@@ -159,5 +160,5 @@ function SingleUserSearchSelector({ onSelectUser, profileTypeFilter, placeholder
|
|
|
159
160
|
const getProfileTypeBadges = (profiles) => {
|
|
160
161
|
return profiles.map(p => p.type);
|
|
161
162
|
};
|
|
162
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)("single-user-search-selector b3-root relative w-full", className), ref: dropdownRef, children: [(0, jsx_runtime_1.jsxs)("div", { className: "single-user-search-input-wrapper relative flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { className: "single-user-search-icon pointer-events-none absolute text-gray-400", style: { left: "12px", width: "16px", height: "16px" } }), (0, jsx_runtime_1.jsx)(input_1.Input, { ref: inputRef, type: "text", value: searchQuery, onChange: e => setSearchQuery(e.target.value), placeholder: placeholder, className: (0, utils_1.cn)("single-user-search-input w-full border-gray-300 focus:border-blue-500 focus:ring-blue-500"), style: { paddingLeft: "44px", paddingRight: "44px" } }), showClearButton && searchQuery && ((0, jsx_runtime_1.jsx)("button", { onClick: handleClear, className: "single-user-search-clear-button absolute text-gray-400 transition-colors hover:text-gray-600", style: { right: "12px" }, type: "button", children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { style: { width: "16px", height: "16px" } }) }))] }), isSearching && (0, jsx_runtime_1.jsx)("div", { className: "single-user-search-loading mt-2 text-sm text-gray-500", children: "Searching..." }), error && !isSearching && (0, jsx_runtime_1.jsx)("div", { className: "single-user-search-error mt-2 text-sm text-red-500", children: error }), showDropdown && searchResult && !isSearching && ((0, jsx_runtime_1.jsx)("div", { className: "single-user-search-dropdown absolute z-50 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-lg", children: (0, jsx_runtime_1.jsx)("button", { onClick: () => handleSelectUser(searchResult), className: "single-user-search-result-button w-full px-4 py-3 text-left transition-colors hover:bg-gray-50", type: "button", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-start gap-3", children: [(0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-avatar h-11 w-11 shrink-0", children: (0, jsx_runtime_1.jsx)(IPFSMediaRenderer_1.IPFSMediaRenderer, { src: searchResult.avatar, alt: getDisplayName(searchResult), className: "h-full w-full rounded-full object-cover" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "single-user-search-result-info min-w-0 flex-1 pt-0.5", children: [(0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-name text-base font-semibold text-gray-900", children: getDisplayName(searchResult) }), searchResult.address && ((0, jsx_runtime_1.jsxs)("div", { className: "single-user-search-result-address mt-1 font-mono text-xs text-gray-500", children: [searchResult.address.slice(0, 6), "...", searchResult.address.slice(-4)] })), searchResult.bio && ((0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-bio mt-1.5 line-clamp-2 text-sm text-gray-600", children: searchResult.bio })), showBadges && ((0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-badges mt-2 flex flex-wrap gap-1.5", children: getProfileTypeBadges(searchResult.profiles).map((type, index) => ((0, jsx_runtime_1.jsx)("span", { className: "single-user-search-result-badge inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800", children: type }, `${type}-${index}`))) }))] })] }) }) }))] }));
|
|
163
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)("single-user-search-selector b3-root relative w-full", className), ref: dropdownRef, children: [(0, jsx_runtime_1.jsxs)("div", { className: "single-user-search-input-wrapper relative flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { className: "single-user-search-icon pointer-events-none absolute text-gray-400", style: { left: "12px", width: "16px", height: "16px" } }), (0, jsx_runtime_1.jsx)(input_1.Input, { ref: inputRef, type: "text", value: searchQuery, onChange: e => setSearchQuery(e.target.value), placeholder: placeholder, className: (0, utils_1.cn)("single-user-search-input w-full border-gray-300 focus:border-blue-500 focus:ring-blue-500"), style: { paddingLeft: "44px", paddingRight: "44px" } }), showClearButton && searchQuery && ((0, jsx_runtime_1.jsx)("button", { onClick: handleClear, className: "single-user-search-clear-button absolute text-gray-400 transition-colors hover:text-gray-600", style: { right: "12px" }, type: "button", children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { style: { width: "16px", height: "16px" } }) }))] }), isSearching && (0, jsx_runtime_1.jsx)("div", { className: "single-user-search-loading mt-2 text-sm text-gray-500", children: "Searching..." }), error && !isSearching && (0, jsx_runtime_1.jsx)("div", { className: "single-user-search-error mt-2 text-sm text-red-500", children: error }), showDropdown && searchResult && !isSearching && ((0, jsx_runtime_1.jsx)("div", { className: "single-user-search-dropdown absolute z-50 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-lg", children: (0, jsx_runtime_1.jsx)("button", { onClick: () => handleSelectUser(searchResult), className: "single-user-search-result-button w-full px-4 py-3 text-left transition-colors hover:bg-gray-50", type: "button", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-start gap-3", children: [(0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-avatar h-11 w-11 shrink-0", children: (0, jsx_runtime_1.jsx)(IPFSMediaRenderer_1.IPFSMediaRenderer, { src: (0, profileDisplay_1.validateImageUrl)(searchResult.avatar) ?? undefined, alt: getDisplayName(searchResult), className: "h-full w-full rounded-full object-cover" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "single-user-search-result-info min-w-0 flex-1 pt-0.5", children: [(0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-name text-base font-semibold text-gray-900", children: getDisplayName(searchResult) }), searchResult.address && ((0, jsx_runtime_1.jsxs)("div", { className: "single-user-search-result-address mt-1 font-mono text-xs text-gray-500", children: [searchResult.address.slice(0, 6), "...", searchResult.address.slice(-4)] })), searchResult.bio && ((0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-bio mt-1.5 line-clamp-2 text-sm text-gray-600", children: searchResult.bio })), showBadges && ((0, jsx_runtime_1.jsx)("div", { className: "single-user-search-result-badges mt-2 flex flex-wrap gap-1.5", children: getProfileTypeBadges(searchResult.profiles).map((type, index) => ((0, jsx_runtime_1.jsx)("span", { className: "single-user-search-result-badge inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800", children: type }, `${type}-${index}`))) }))] })] }) }) }))] }));
|
|
163
164
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, toast, useProfile } from "../../../../global-account/react/index.js";
|
|
3
|
+
import { validateImageUrl } from "../../../../global-account/react/utils/profileDisplay.js";
|
|
3
4
|
import { truncateAddress } from "../../../../shared/utils/truncateAddress.js";
|
|
4
5
|
import { Copy, Loader2, UnlinkIcon } from "lucide-react";
|
|
5
6
|
// Helper function to check if a string is a wallet address and format it
|
|
@@ -30,7 +31,7 @@ const LinkedAccountItem = ({ profile, profileToUnlink, unlinkingAccountId, isUnl
|
|
|
30
31
|
toast.error("Failed to copy address");
|
|
31
32
|
}
|
|
32
33
|
};
|
|
33
|
-
const displayImageUrl = profileData?.avatar || profile.imageUrl;
|
|
34
|
+
const displayImageUrl = validateImageUrl(profileData?.avatar) || validateImageUrl(profile.imageUrl);
|
|
34
35
|
const displayName = profileData?.name || displayTitle;
|
|
35
36
|
return (_jsxs("div", { className: "linked-account-item hover:bg-b3-line group flex cursor-pointer items-center justify-between rounded-xl p-4 transition-colors", children: [_jsxs("div", { className: "linked-account-info flex items-center gap-3", children: [displayImageUrl ? (_jsx("img", { src: displayImageUrl, alt: profile.title, className: "linked-account-avatar linked-account-avatar-image size-10 rounded-full" })) : (_jsx("div", { className: "linked-account-avatar linked-account-avatar-placeholder bg-b3-primary-wash flex h-10 w-10 items-center justify-center rounded-full", children: _jsx("span", { className: "linked-account-initial text-b3-grey font-neue-montreal-semibold text-sm uppercase", children: profile.initial }) })), _jsxs("div", { className: "linked-account-details", children: [_jsxs("div", { className: "linked-account-title-row flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: `linked-account-title text-b3-grey font-neue-montreal-semibold ${isAddress
|
|
36
37
|
? "font-mono text-sm" // Use monospace font for addresses
|
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { ens_normalize } from "@adraffy/ens-normalize";
|
|
3
3
|
import app from "../../../../global-account/app.js";
|
|
4
4
|
import { toast, useAuthentication, useB3Config, useModalStore, useProfile } from "../../../../global-account/react/index.js";
|
|
5
|
+
import { validateImageUrl } from "../../../../global-account/react/utils/profileDisplay.js";
|
|
5
6
|
import { formatUsername } from "../../../../shared/utils/index.js";
|
|
6
7
|
import { Check, Loader2, Pencil, X } from "lucide-react";
|
|
7
8
|
import { useEffect, useRef, useState } from "react";
|
|
@@ -25,8 +26,7 @@ const SettingsProfileCard = () => {
|
|
|
25
26
|
const [editedUsername, setEditedUsername] = useState("");
|
|
26
27
|
const [isSaving, setIsSaving] = useState(false);
|
|
27
28
|
const inputRef = useRef(null);
|
|
28
|
-
|
|
29
|
-
const avatarSrc = user?.avatar || profile?.avatar;
|
|
29
|
+
const avatarSrc = validateImageUrl(user?.avatar) || validateImageUrl(profile?.avatar);
|
|
30
30
|
// Get current username - prioritize user.username, fallback to profile data
|
|
31
31
|
const currentUsername = user?.username || profile?.displayName || formatUsername(profile?.name || "");
|
|
32
32
|
// Focus input when entering edit mode
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { ALL_CHAINS, getExplorerTxUrl } from "../../../../anyspend/index.js";
|
|
3
3
|
import { ChainTokenIcon } from "../../../../anyspend/react/components/common/ChainTokenIcon.js";
|
|
4
4
|
import { toast, useAccountWallet, useAnalytics, useModalStore, useProfile, useSimBalance, useUnifiedChainSwitchAndExecute, } from "../../../../global-account/react/index.js";
|
|
5
|
+
import { validateImageUrl } from "../../../../global-account/react/utils/profileDisplay.js";
|
|
5
6
|
import { formatDisplayNumber, formatTokenAmount } from "../../../../shared/utils/number.js";
|
|
6
7
|
import invariant from "invariant";
|
|
7
8
|
import { CircleHelp, Clock, Loader2, Send as SendIcon, Wallet } from "lucide-react";
|
|
@@ -14,7 +15,8 @@ import { Button } from "../ui/button.js";
|
|
|
14
15
|
// Component for displaying a recent address with profile data
|
|
15
16
|
function RecentAddressItem({ address, onClick }) {
|
|
16
17
|
const { data: profileData } = useProfile({ address });
|
|
17
|
-
|
|
18
|
+
const validatedAvatar = validateImageUrl(profileData?.avatar);
|
|
19
|
+
return (_jsxs("button", { onClick: onClick, className: "flex items-center gap-2 rounded-xl px-3 py-2 transition-colors hover:bg-[#fafafa]", children: [validatedAvatar ? (_jsx("img", { src: validatedAvatar, alt: profileData?.name || address, className: "h-10 w-10 rounded-full" })) : (_jsx("div", { className: "flex h-10 w-10 items-center justify-center rounded-full border border-[#e4e4e7] bg-[#f4f4f5]", children: _jsx(Wallet, { className: "h-5 w-5 text-[#a0a0ab]" }) })), _jsx("div", { className: "flex flex-col items-start", children: _jsxs("span", { className: "font-neue-montreal-medium text-base tracking-[-0.32px] text-[#70707b]", children: [address.slice(0, 6), "...", address.slice(-4), profileData?.name && ` (${profileData.name})`] }) })] }));
|
|
18
20
|
}
|
|
19
21
|
export function Send({ recipientAddress: initialRecipient, onSuccess }) {
|
|
20
22
|
const { address } = useAccountWallet();
|
|
@@ -39,6 +41,7 @@ export function Send({ recipientAddress: initialRecipient, onSuccess }) {
|
|
|
39
41
|
const { data: validatedProfileData } = useProfile({
|
|
40
42
|
address: showValidatedResult && recipientAddress && isAddress(recipientAddress) ? recipientAddress : undefined,
|
|
41
43
|
});
|
|
44
|
+
const validatedRecipientAvatar = validateImageUrl(validatedProfileData?.avatar);
|
|
42
45
|
// Address validation
|
|
43
46
|
const handleRecipientAddressChange = (value) => {
|
|
44
47
|
setRecipientAddress(value);
|
|
@@ -179,7 +182,7 @@ export function Send({ recipientAddress: initialRecipient, onSuccess }) {
|
|
|
179
182
|
return "Send";
|
|
180
183
|
}
|
|
181
184
|
};
|
|
182
|
-
return (_jsxs("div", { className: "dark:bg-b3-background flex h-[600px] w-full flex-col bg-white", children: [_jsx(ModalHeader, { handleBack: handleBack, title: getStepTitle() }), _jsxs("div", { className: "flex-1 overflow-y-auto", children: [step === "recipient" && (_jsxs("div", { className: "flex flex-col gap-6 p-5", children: [_jsxs("div", { className: "dark:border-b3-line dark:bg-b3-background flex h-12 w-full items-stretch overflow-hidden rounded-lg border border-[#d1d1d6] bg-white", children: [_jsx("div", { className: "flex w-12 items-center justify-center bg-transparent px-3 py-2", children: _jsx("span", { className: "font-neue-montreal-medium text-base text-[#3f3f46] dark:text-white", children: "To" }) }), _jsxs("div", { className: "flex flex-1 items-center border-l border-[#d1d1d6] px-3 py-2", children: [_jsx("input", { type: "text", value: recipientAddress, onChange: e => handleRecipientAddressChange(e.target.value), placeholder: "ENS or Address", className: "font-neue-montreal-medium dark:bg-b3-background flex-1 text-base text-[#70707b] outline-none placeholder:text-[#70707b] dark:text-white dark:placeholder:text-white" }), _jsx("button", { onClick: handlePaste, className: "font-inter ml-2 rounded-md border border-[#e4e4e7] bg-[#fafafa] px-2.5 py-0.5 text-sm font-medium text-[#3f3f46] transition-colors hover:bg-[#f4f4f5]", children: "Paste" })] })] }), addressError && _jsx("p", { className: "font-neue-montreal-medium -mt-4 text-xs text-red-500", children: addressError }), showValidatedResult && recipientAddress && isAddress(recipientAddress) && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("div", { className: "flex items-center gap-1", children: _jsx("span", { className: "font-sf-pro-text text-sm font-semibold leading-[1.3] tracking-[-0.41px] text-[#0b57c2]", children: "Result" }) }), _jsxs("button", { onClick: handleSelectValidatedAddress, className: "dark:bg-b3-background dark:border-b3-line flex items-center gap-2 rounded-xl bg-[#f4f4f5] px-3 py-2 transition-colors hover:bg-[#e4e4e7]", children: [
|
|
185
|
+
return (_jsxs("div", { className: "dark:bg-b3-background flex h-[600px] w-full flex-col bg-white", children: [_jsx(ModalHeader, { handleBack: handleBack, title: getStepTitle() }), _jsxs("div", { className: "flex-1 overflow-y-auto", children: [step === "recipient" && (_jsxs("div", { className: "flex flex-col gap-6 p-5", children: [_jsxs("div", { className: "dark:border-b3-line dark:bg-b3-background flex h-12 w-full items-stretch overflow-hidden rounded-lg border border-[#d1d1d6] bg-white", children: [_jsx("div", { className: "flex w-12 items-center justify-center bg-transparent px-3 py-2", children: _jsx("span", { className: "font-neue-montreal-medium text-base text-[#3f3f46] dark:text-white", children: "To" }) }), _jsxs("div", { className: "flex flex-1 items-center border-l border-[#d1d1d6] px-3 py-2", children: [_jsx("input", { type: "text", value: recipientAddress, onChange: e => handleRecipientAddressChange(e.target.value), placeholder: "ENS or Address", className: "font-neue-montreal-medium dark:bg-b3-background flex-1 text-base text-[#70707b] outline-none placeholder:text-[#70707b] dark:text-white dark:placeholder:text-white" }), _jsx("button", { onClick: handlePaste, className: "font-inter ml-2 rounded-md border border-[#e4e4e7] bg-[#fafafa] px-2.5 py-0.5 text-sm font-medium text-[#3f3f46] transition-colors hover:bg-[#f4f4f5]", children: "Paste" })] })] }), addressError && _jsx("p", { className: "font-neue-montreal-medium -mt-4 text-xs text-red-500", children: addressError }), showValidatedResult && recipientAddress && isAddress(recipientAddress) && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("div", { className: "flex items-center gap-1", children: _jsx("span", { className: "font-sf-pro-text text-sm font-semibold leading-[1.3] tracking-[-0.41px] text-[#0b57c2]", children: "Result" }) }), _jsxs("button", { onClick: handleSelectValidatedAddress, className: "dark:bg-b3-background dark:border-b3-line flex items-center gap-2 rounded-xl bg-[#f4f4f5] px-3 py-2 transition-colors hover:bg-[#e4e4e7]", children: [validatedRecipientAvatar ? (_jsx("img", { src: validatedRecipientAvatar, alt: validatedProfileData?.name || recipientAddress, className: "h-10 w-10 rounded-full" })) : (_jsx("div", { className: "dark:border-b3-line dark:bg-b3-background flex h-10 w-10 items-center justify-center rounded-full border border-[#e4e4e7] bg-[#f4f4f5]", children: _jsx(Wallet, { className: "h-5 w-5 text-[#a0a0ab] dark:text-white" }) })), _jsxs("span", { className: "font-neue-montreal-medium text-base tracking-[-0.32px] text-[#70707b] dark:text-white", children: [recipientAddress.slice(0, 6), "...", recipientAddress.slice(-4), validatedProfileData?.name && ` (${validatedProfileData.name})`] })] })] })), recentAddresses.length > 0 && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Clock, { className: "h-3.5 w-3.5 text-[#3f3f46]" }), _jsx("span", { className: "font-sf-pro-text text-sm font-semibold leading-[1.3] tracking-[-0.41px] text-[#3f3f46]", children: "Recents" })] }), _jsx("div", { className: "flex flex-col", children: recentAddresses.map((recent, index) => (_jsx(RecentAddressItem, { address: recent.address, onClick: () => {
|
|
183
186
|
// Just fill the input and show validation - don't auto-proceed
|
|
184
187
|
handleRecipientAddressChange(recent.address);
|
|
185
188
|
} }, index))) })] }))] })), step === "token" && (_jsx("div", { className: "flex flex-col p-5", children: isLoadingBalance ? (_jsx("div", { className: "space-y-4", children: _jsx("div", { className: "space-y-1", children: [...Array(3)].map((_, index) => (_jsxs("div", { className: "flex items-center justify-between rounded-xl p-3", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "bg-b3-line h-10 w-10 animate-pulse rounded-full" }), _jsxs("div", { children: [_jsx("div", { className: "bg-b3-line mb-1 h-4 w-16 animate-pulse rounded" }), _jsx("div", { className: "bg-b3-line h-3 w-24 animate-pulse rounded" })] })] }), _jsxs("div", { className: "text-right", children: [_jsx("div", { className: "bg-b3-line mb-1 h-4 w-20 animate-pulse rounded" }), _jsx("div", { className: "bg-b3-line h-3 w-16 animate-pulse rounded" })] })] }, index))) }) })) : simBalance?.balances && simBalance.balances.length > 0 ? (_jsx("div", { className: "space-y-4", children: _jsx("div", { className: "space-y-1", children: simBalance.balances.map(token => (_jsxs("div", { className: "hover:bg-b3-line/60 dark:hover:bg-b3-primary-wash/40 group flex cursor-pointer items-center justify-between rounded-xl p-3 transition-all duration-200", onClick: () => {
|
|
@@ -4,6 +4,7 @@ import { cn } from "../../../../shared/utils/index.js";
|
|
|
4
4
|
import { Search, X } from "lucide-react";
|
|
5
5
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { fetchProfile as fetchProfileApi } from "../../utils/profileApi.js";
|
|
7
|
+
import { validateImageUrl } from "../../utils/profileDisplay.js";
|
|
7
8
|
import { IPFSMediaRenderer } from "../IPFSMediaRenderer/IPFSMediaRenderer.js";
|
|
8
9
|
import { Input } from "../ui/input.js";
|
|
9
10
|
/**
|
|
@@ -156,5 +157,5 @@ export function SingleUserSearchSelector({ onSelectUser, profileTypeFilter, plac
|
|
|
156
157
|
const getProfileTypeBadges = (profiles) => {
|
|
157
158
|
return profiles.map(p => p.type);
|
|
158
159
|
};
|
|
159
|
-
return (_jsxs("div", { className: cn("single-user-search-selector b3-root relative w-full", className), ref: dropdownRef, children: [_jsxs("div", { className: "single-user-search-input-wrapper relative flex items-center", children: [_jsx(Search, { className: "single-user-search-icon pointer-events-none absolute text-gray-400", style: { left: "12px", width: "16px", height: "16px" } }), _jsx(Input, { ref: inputRef, type: "text", value: searchQuery, onChange: e => setSearchQuery(e.target.value), placeholder: placeholder, className: cn("single-user-search-input w-full border-gray-300 focus:border-blue-500 focus:ring-blue-500"), style: { paddingLeft: "44px", paddingRight: "44px" } }), showClearButton && searchQuery && (_jsx("button", { onClick: handleClear, className: "single-user-search-clear-button absolute text-gray-400 transition-colors hover:text-gray-600", style: { right: "12px" }, type: "button", children: _jsx(X, { style: { width: "16px", height: "16px" } }) }))] }), isSearching && _jsx("div", { className: "single-user-search-loading mt-2 text-sm text-gray-500", children: "Searching..." }), error && !isSearching && _jsx("div", { className: "single-user-search-error mt-2 text-sm text-red-500", children: error }), showDropdown && searchResult && !isSearching && (_jsx("div", { className: "single-user-search-dropdown absolute z-50 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-lg", children: _jsx("button", { onClick: () => handleSelectUser(searchResult), className: "single-user-search-result-button w-full px-4 py-3 text-left transition-colors hover:bg-gray-50", type: "button", children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("div", { className: "single-user-search-result-avatar h-11 w-11 shrink-0", children: _jsx(IPFSMediaRenderer, { src: searchResult.avatar, alt: getDisplayName(searchResult), className: "h-full w-full rounded-full object-cover" }) }), _jsxs("div", { className: "single-user-search-result-info min-w-0 flex-1 pt-0.5", children: [_jsx("div", { className: "single-user-search-result-name text-base font-semibold text-gray-900", children: getDisplayName(searchResult) }), searchResult.address && (_jsxs("div", { className: "single-user-search-result-address mt-1 font-mono text-xs text-gray-500", children: [searchResult.address.slice(0, 6), "...", searchResult.address.slice(-4)] })), searchResult.bio && (_jsx("div", { className: "single-user-search-result-bio mt-1.5 line-clamp-2 text-sm text-gray-600", children: searchResult.bio })), showBadges && (_jsx("div", { className: "single-user-search-result-badges mt-2 flex flex-wrap gap-1.5", children: getProfileTypeBadges(searchResult.profiles).map((type, index) => (_jsx("span", { className: "single-user-search-result-badge inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800", children: type }, `${type}-${index}`))) }))] })] }) }) }))] }));
|
|
160
|
+
return (_jsxs("div", { className: cn("single-user-search-selector b3-root relative w-full", className), ref: dropdownRef, children: [_jsxs("div", { className: "single-user-search-input-wrapper relative flex items-center", children: [_jsx(Search, { className: "single-user-search-icon pointer-events-none absolute text-gray-400", style: { left: "12px", width: "16px", height: "16px" } }), _jsx(Input, { ref: inputRef, type: "text", value: searchQuery, onChange: e => setSearchQuery(e.target.value), placeholder: placeholder, className: cn("single-user-search-input w-full border-gray-300 focus:border-blue-500 focus:ring-blue-500"), style: { paddingLeft: "44px", paddingRight: "44px" } }), showClearButton && searchQuery && (_jsx("button", { onClick: handleClear, className: "single-user-search-clear-button absolute text-gray-400 transition-colors hover:text-gray-600", style: { right: "12px" }, type: "button", children: _jsx(X, { style: { width: "16px", height: "16px" } }) }))] }), isSearching && _jsx("div", { className: "single-user-search-loading mt-2 text-sm text-gray-500", children: "Searching..." }), error && !isSearching && _jsx("div", { className: "single-user-search-error mt-2 text-sm text-red-500", children: error }), showDropdown && searchResult && !isSearching && (_jsx("div", { className: "single-user-search-dropdown absolute z-50 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-lg", children: _jsx("button", { onClick: () => handleSelectUser(searchResult), className: "single-user-search-result-button w-full px-4 py-3 text-left transition-colors hover:bg-gray-50", type: "button", children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("div", { className: "single-user-search-result-avatar h-11 w-11 shrink-0", children: _jsx(IPFSMediaRenderer, { src: validateImageUrl(searchResult.avatar) ?? undefined, alt: getDisplayName(searchResult), className: "h-full w-full rounded-full object-cover" }) }), _jsxs("div", { className: "single-user-search-result-info min-w-0 flex-1 pt-0.5", children: [_jsx("div", { className: "single-user-search-result-name text-base font-semibold text-gray-900", children: getDisplayName(searchResult) }), searchResult.address && (_jsxs("div", { className: "single-user-search-result-address mt-1 font-mono text-xs text-gray-500", children: [searchResult.address.slice(0, 6), "...", searchResult.address.slice(-4)] })), searchResult.bio && (_jsx("div", { className: "single-user-search-result-bio mt-1.5 line-clamp-2 text-sm text-gray-600", children: searchResult.bio })), showBadges && (_jsx("div", { className: "single-user-search-result-badges mt-2 flex flex-wrap gap-1.5", children: getProfileTypeBadges(searchResult.profiles).map((type, index) => (_jsx("span", { className: "single-user-search-result-badge inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800", children: type }, `${type}-${index}`))) }))] })] }) }) }))] }));
|
|
160
161
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.69-alpha.15",
|
|
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
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Button, toast, useProfile } from "@b3dotfun/sdk/global-account/react";
|
|
2
|
+
import { validateImageUrl } from "@b3dotfun/sdk/global-account/react/utils/profileDisplay";
|
|
2
3
|
import { truncateAddress } from "@b3dotfun/sdk/shared/utils/truncateAddress";
|
|
3
4
|
import { Copy, Loader2, UnlinkIcon } from "lucide-react";
|
|
4
5
|
|
|
@@ -55,7 +56,7 @@ const LinkedAccountItem = ({
|
|
|
55
56
|
}
|
|
56
57
|
};
|
|
57
58
|
|
|
58
|
-
const displayImageUrl = profileData?.avatar || profile.imageUrl;
|
|
59
|
+
const displayImageUrl = validateImageUrl(profileData?.avatar) || validateImageUrl(profile.imageUrl);
|
|
59
60
|
const displayName = profileData?.name || displayTitle;
|
|
60
61
|
|
|
61
62
|
return (
|
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ens_normalize } from "@adraffy/ens-normalize";
|
|
2
2
|
import app from "@b3dotfun/sdk/global-account/app";
|
|
3
3
|
import { toast, useAuthentication, useB3Config, useModalStore, useProfile } from "@b3dotfun/sdk/global-account/react";
|
|
4
|
+
import { validateImageUrl } from "@b3dotfun/sdk/global-account/react/utils/profileDisplay";
|
|
4
5
|
import { formatUsername } from "@b3dotfun/sdk/shared/utils";
|
|
5
6
|
import { Check, Loader2, Pencil, X } from "lucide-react";
|
|
6
7
|
import { useEffect, useRef, useState } from "react";
|
|
@@ -29,8 +30,7 @@ const SettingsProfileCard = () => {
|
|
|
29
30
|
const [isSaving, setIsSaving] = useState(false);
|
|
30
31
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
const avatarSrc = user?.avatar || profile?.avatar;
|
|
33
|
+
const avatarSrc = validateImageUrl(user?.avatar) || validateImageUrl(profile?.avatar);
|
|
34
34
|
|
|
35
35
|
// Get current username - prioritize user.username, fallback to profile data
|
|
36
36
|
const currentUsername = user?.username || profile?.displayName || formatUsername(profile?.name || "");
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
useSimBalance,
|
|
10
10
|
useUnifiedChainSwitchAndExecute,
|
|
11
11
|
} from "@b3dotfun/sdk/global-account/react";
|
|
12
|
+
import { validateImageUrl } from "@b3dotfun/sdk/global-account/react/utils/profileDisplay";
|
|
12
13
|
import { formatDisplayNumber, formatTokenAmount } from "@b3dotfun/sdk/shared/utils/number";
|
|
13
14
|
import invariant from "invariant";
|
|
14
15
|
import { CircleHelp, Clock, Loader2, Send as SendIcon, Wallet } from "lucide-react";
|
|
@@ -32,6 +33,7 @@ type SendStep = "recipient" | "token" | "amount" | "confirm" | "success";
|
|
|
32
33
|
// Component for displaying a recent address with profile data
|
|
33
34
|
function RecentAddressItem({ address, onClick }: { address: string; onClick: () => void }) {
|
|
34
35
|
const { data: profileData } = useProfile({ address });
|
|
36
|
+
const validatedAvatar = validateImageUrl(profileData?.avatar);
|
|
35
37
|
|
|
36
38
|
return (
|
|
37
39
|
<button
|
|
@@ -39,8 +41,8 @@ function RecentAddressItem({ address, onClick }: { address: string; onClick: ()
|
|
|
39
41
|
className="flex items-center gap-2 rounded-xl px-3 py-2 transition-colors hover:bg-[#fafafa]"
|
|
40
42
|
>
|
|
41
43
|
{/* Avatar */}
|
|
42
|
-
{
|
|
43
|
-
<img src={
|
|
44
|
+
{validatedAvatar ? (
|
|
45
|
+
<img src={validatedAvatar} alt={profileData?.name || address} className="h-10 w-10 rounded-full" />
|
|
44
46
|
) : (
|
|
45
47
|
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-[#e4e4e7] bg-[#f4f4f5]">
|
|
46
48
|
<Wallet className="h-5 w-5 text-[#a0a0ab]" />
|
|
@@ -84,6 +86,7 @@ export function Send({ recipientAddress: initialRecipient, onSuccess }: SendModa
|
|
|
84
86
|
const { data: validatedProfileData } = useProfile({
|
|
85
87
|
address: showValidatedResult && recipientAddress && isAddress(recipientAddress) ? recipientAddress : undefined,
|
|
86
88
|
});
|
|
89
|
+
const validatedRecipientAvatar = validateImageUrl(validatedProfileData?.avatar);
|
|
87
90
|
|
|
88
91
|
// Address validation
|
|
89
92
|
const handleRecipientAddressChange = (value: string) => {
|
|
@@ -290,10 +293,10 @@ export function Send({ recipientAddress: initialRecipient, onSuccess }: SendModa
|
|
|
290
293
|
className="dark:bg-b3-background dark:border-b3-line flex items-center gap-2 rounded-xl bg-[#f4f4f5] px-3 py-2 transition-colors hover:bg-[#e4e4e7]"
|
|
291
294
|
>
|
|
292
295
|
{/* Avatar */}
|
|
293
|
-
{
|
|
296
|
+
{validatedRecipientAvatar ? (
|
|
294
297
|
<img
|
|
295
|
-
src={
|
|
296
|
-
alt={validatedProfileData
|
|
298
|
+
src={validatedRecipientAvatar}
|
|
299
|
+
alt={validatedProfileData?.name || recipientAddress}
|
|
297
300
|
className="h-10 w-10 rounded-full"
|
|
298
301
|
/>
|
|
299
302
|
) : (
|
package/src/global-account/react/components/SingleUserSearchSelector/SingleUserSearchSelector.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Search, X } from "lucide-react";
|
|
|
5
5
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import type { CombinedProfile, Profile } from "../../hooks/useProfile";
|
|
7
7
|
import { fetchProfile as fetchProfileApi } from "../../utils/profileApi";
|
|
8
|
+
import { validateImageUrl } from "../../utils/profileDisplay";
|
|
8
9
|
import { IPFSMediaRenderer } from "../IPFSMediaRenderer/IPFSMediaRenderer";
|
|
9
10
|
import { Input } from "../ui/input";
|
|
10
11
|
|
|
@@ -283,7 +284,7 @@ export function SingleUserSearchSelector({
|
|
|
283
284
|
{/* Avatar */}
|
|
284
285
|
<div className="single-user-search-result-avatar h-11 w-11 shrink-0">
|
|
285
286
|
<IPFSMediaRenderer
|
|
286
|
-
src={searchResult.avatar}
|
|
287
|
+
src={validateImageUrl(searchResult.avatar) ?? undefined}
|
|
287
288
|
alt={getDisplayName(searchResult)}
|
|
288
289
|
className="h-full w-full rounded-full object-cover"
|
|
289
290
|
/>
|
|
@@ -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 (
|
|
51
|
+
if (isHostnameInList(hostname, ALLOWED_IPFS_GATEWAYS)) {
|
|
52
52
|
return url;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|