Skip to content

Commit 2460bee

Browse files
authored
feat: add NFTs tab (#1762)
- Closes #1686 - Closes `FE-1087` --- | 📷 Demo | | --- | | <video src="https://github.com/user-attachments/assets/d2771cb6-fe1a-4c83-9ab6-db2be421d1c6" /> |
1 parent ab1ed94 commit 2460bee

File tree

15 files changed

+362
-111
lines changed

15 files changed

+362
-111
lines changed

.changeset/early-walls-rescue.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"fuels-wallet": minor
3+
---
4+
5+
Add a separate NFTs tab to the home screen.

packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx

+17-12
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,33 @@ export type BalanceAssetListProp = {
1414
};
1515

1616
export const BalanceAssets = ({
17-
balances,
17+
balances = [],
1818
isLoading,
1919
emptyProps = {},
2020
onRemove,
2121
onEdit,
2222
}: BalanceAssetListProp) => {
2323
const [showUnknown, setShowUnknown] = useState(false);
24-
const unknownLength = useMemo(
25-
() =>
26-
balances?.filter(
27-
(balance) => balance.asset && isUnknownAsset(balance.asset)
28-
).length,
29-
[balances]
30-
);
24+
25+
const unknownLength = useMemo<number>(() => {
26+
return balances.filter(
27+
(balance) => balance.asset && isUnknownAsset(balance.asset)
28+
).length;
29+
}, [balances]);
30+
31+
const balancesToShow = useMemo<CoinAsset[]>(() => {
32+
return balances.filter((balance) => {
33+
const isNft = Boolean(balance.asset?.isNft);
34+
return (
35+
!isNft &&
36+
(showUnknown || (balance.asset && !isUnknownAsset(balance.asset)))
37+
);
38+
});
39+
}, [balances, showUnknown]);
3140

3241
if (isLoading || !balances) return <AssetList.Loading items={4} />;
3342
const isEmpty = !balances || !balances.length;
3443
if (isEmpty) return <AssetList.Empty {...emptyProps} />;
35-
const balancesToShow = balances.filter(
36-
(balance) =>
37-
showUnknown || (balance.asset && !isUnknownAsset(balance.asset))
38-
);
3944

4045
function toggle() {
4146
setShowUnknown((s) => !s);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { cssObj } from '@fuel-ui/css';
2+
import { Accordion, Badge, Box, Copyable, VStack } from '@fuel-ui/react';
3+
import type { CoinAsset } from '@fuel-wallet/types';
4+
import { useMemo } from 'react';
5+
import { AssetListEmpty } from '~/systems/Asset/components/AssetList/AssetListEmpty';
6+
import { shortAddress } from '~/systems/Core';
7+
import { NFTImage } from './NFTImage';
8+
import {
9+
UNKNOWN_COLLECTION_TITLE,
10+
groupNFTsByCollection,
11+
} from './groupNFTsByCollection';
12+
13+
interface BalanceNFTsProps {
14+
balances: CoinAsset[] | undefined;
15+
}
16+
17+
export const BalanceNFTs = ({ balances = [] }: BalanceNFTsProps) => {
18+
const { collections, defaultValue } = useMemo(() => {
19+
const collections = groupNFTsByCollection(balances);
20+
const defaultValue = collections
21+
.map((collection) => collection.name)
22+
.filter((collection) => collection !== UNKNOWN_COLLECTION_TITLE);
23+
24+
return {
25+
collections,
26+
defaultValue,
27+
};
28+
}, [balances]);
29+
30+
if (collections.length === 0) {
31+
return (
32+
<AssetListEmpty
33+
text="You don't have any NFTs"
34+
supportText="To add NFTs, simply send them to your Fuel address."
35+
hideFaucet
36+
/>
37+
);
38+
}
39+
40+
return (
41+
<Box css={styles.root}>
42+
<Accordion type="multiple" defaultValue={defaultValue}>
43+
{collections.map((collection) => {
44+
return (
45+
<Accordion.Item key={collection.name} value={collection.name}>
46+
<Accordion.Trigger>
47+
<Badge variant="ghost" color="gray" as="span">
48+
{collection.nfts.length}
49+
</Badge>
50+
{collection.name}
51+
</Accordion.Trigger>
52+
<Accordion.Content>
53+
<Box css={styles.grid}>
54+
{collection.nfts.map((nft) => {
55+
return (
56+
<div key={nft.assetId}>
57+
<NFTImage assetId={nft.assetId} image={nft.image} />
58+
<Copyable css={styles.name} value={nft.assetId}>
59+
{nft.name || shortAddress(nft.assetId)}
60+
</Copyable>
61+
</div>
62+
);
63+
})}
64+
</Box>
65+
</Accordion.Content>
66+
</Accordion.Item>
67+
);
68+
})}
69+
</Accordion>
70+
</Box>
71+
);
72+
};
73+
74+
const styles = {
75+
root: cssObj({
76+
'.fuel_Accordion-trigger': {
77+
fontSize: '$base',
78+
fontWeight: '$medium',
79+
backgroundColor: 'transparent',
80+
color: '$intentsBase11',
81+
padding: '$0',
82+
gap: '$2',
83+
flexDirection: 'row-reverse',
84+
justifyContent: 'flex-start',
85+
},
86+
'.fuel_Accordion-trigger:hover': {
87+
color: '$intentsBase12',
88+
},
89+
'.fuel_Accordion-trigger[data-state="open"]': {
90+
color: '$intentsBase12',
91+
},
92+
'.fuel_Accordion-trigger[data-state="closed"] .fuel_Accordion-icon': {
93+
transform: 'rotate(-45deg)',
94+
},
95+
'.fuel_Accordion-trigger[data-state="open"] .fuel_Accordion-icon': {
96+
transform: 'rotate(0deg)',
97+
},
98+
'.fuel_Accordion-item': {
99+
backgroundColor: 'transparent',
100+
borderBottom: '1px solid $border',
101+
borderRadius: '$none',
102+
103+
svg: {
104+
width: '$3',
105+
height: '$3',
106+
},
107+
},
108+
'.fuel_Accordion-content': {
109+
border: '0',
110+
padding: '$0 5px $2 20px',
111+
},
112+
'.fuel_Badge': {
113+
display: 'inline-flex',
114+
justifyContent: 'center',
115+
alignItems: 'center',
116+
fontWeight: '$normal',
117+
fontSize: '$xs',
118+
padding: '$0',
119+
height: '$5',
120+
minWidth: '$5',
121+
pointerEvents: 'none',
122+
marginLeft: 'auto',
123+
lineHeight: 'normal',
124+
},
125+
}),
126+
grid: cssObj({
127+
display: 'grid',
128+
gridTemplateColumns: 'repeat(3, 1fr)',
129+
gap: '$3',
130+
}),
131+
name: cssObj({
132+
marginTop: '$1',
133+
gap: '$0',
134+
fontSize: '$xs',
135+
lineHeight: '$none',
136+
textAlign: 'center',
137+
}),
138+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { cssObj } from '@fuel-ui/css';
2+
import { Box, ContentLoader, Icon } from '@fuel-ui/react';
3+
import { useEffect, useRef, useState } from 'react';
4+
import { shortAddress } from '~/systems/Core';
5+
6+
interface NFTImageProps {
7+
assetId: string;
8+
image: string | undefined;
9+
}
10+
11+
export const NFTImage = ({ assetId, image }: NFTImageProps) => {
12+
const imgRef = useRef<HTMLImageElement>(null);
13+
14+
const [fallback, setFallback] = useState(false);
15+
const [isLoading, setLoading] = useState(true);
16+
17+
useEffect(() => {
18+
if (imgRef.current?.complete) {
19+
if (imgRef.current.naturalWidth) {
20+
setLoading(false);
21+
return;
22+
}
23+
24+
setFallback(true);
25+
}
26+
}, []);
27+
28+
if (image && !fallback) {
29+
return (
30+
<Box css={styles.item}>
31+
{isLoading && (
32+
<ContentLoader width="100%" height="100%" viewBox="0 0 22 22">
33+
<rect x="0" y="0" rx="0" ry="0" width="22" height="22" />
34+
</ContentLoader>
35+
)}
36+
<img
37+
ref={imgRef}
38+
src={image}
39+
alt={shortAddress(assetId)}
40+
data-loading={isLoading}
41+
onLoad={() => setLoading(false)}
42+
onError={() => {
43+
setFallback(true);
44+
}}
45+
/>
46+
</Box>
47+
);
48+
}
49+
50+
return (
51+
<Box css={styles.noImage}>
52+
<Icon icon={Icon.is('FileOff')} />
53+
</Box>
54+
);
55+
};
56+
57+
const styles = {
58+
item: cssObj({
59+
aspectRatio: '1 / 1',
60+
borderRadius: '12px',
61+
overflow: 'hidden',
62+
63+
img: {
64+
width: '100%',
65+
objectFit: 'cover',
66+
},
67+
'img[data-loading="true"]': {
68+
display: 'none',
69+
},
70+
}),
71+
noImage: cssObj({
72+
width: '100%',
73+
aspectRatio: '1 / 1',
74+
borderRadius: '12px',
75+
border: '1px solid $cardBorder',
76+
backgroundColor: '$cardBg',
77+
display: 'flex',
78+
justifyContent: 'center',
79+
alignItems: 'center',
80+
}),
81+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { CoinAsset } from '@fuel-wallet/types';
2+
3+
export const UNKNOWN_COLLECTION_TITLE = 'Others';
4+
5+
interface NFT {
6+
assetId: string;
7+
name: string | undefined;
8+
image: string | undefined;
9+
}
10+
interface Collection {
11+
name: string;
12+
nfts: NFT[];
13+
}
14+
15+
export const groupNFTsByCollection = (balances: CoinAsset[]): Collection[] => {
16+
const grouped: Collection[] = balances
17+
// Filter only NFTs
18+
.filter((balance) => {
19+
return balance.asset?.contractId && Boolean(balance.asset?.isNft);
20+
})
21+
22+
// Group balances by collection name
23+
.reduce((acc, balance) => {
24+
const name = balance.asset?.collection || UNKNOWN_COLLECTION_TITLE;
25+
let collection = acc.find((item) => item.name === name);
26+
27+
if (!collection) {
28+
collection = { name, nfts: [] };
29+
acc.push(collection);
30+
}
31+
32+
const image = balance.asset?.metadata?.image?.replace(
33+
'ipfs://',
34+
'https://ipfs.io/ipfs/'
35+
);
36+
37+
collection.nfts.push({
38+
assetId: balance.assetId,
39+
name: balance?.asset?.metadata?.name,
40+
image,
41+
});
42+
43+
return acc;
44+
}, [] as Collection[])
45+
46+
// Sort NFTs by name
47+
.map((collection) => {
48+
return {
49+
name: collection.name,
50+
nfts: collection.nfts.sort((a, b) => {
51+
if (a.name && b.name) {
52+
return a.name.localeCompare(b.name, undefined, {
53+
numeric: true,
54+
sensitivity: 'base',
55+
});
56+
}
57+
58+
return 0;
59+
}),
60+
};
61+
})
62+
63+
// Move "Others" to the bottom
64+
.sort((a, b) => {
65+
if (a.name === UNKNOWN_COLLECTION_TITLE) return 1;
66+
if (b.name === UNKNOWN_COLLECTION_TITLE) return -1;
67+
return 0;
68+
});
69+
70+
return grouped;
71+
};

packages/app/src/systems/Account/services/account.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import { AssetsCache } from '~/systems/Asset/cache/AssetsCache';
1111
import { chromeStorage } from '~/systems/Core/services/chromeStorage';
1212
import type { Maybe } from '~/systems/Core/types';
1313
import { db } from '~/systems/Core/utils/database';
14+
import { readFromOPFS } from '~/systems/Core/utils/opfs';
1415
import { getUniqueString } from '~/systems/Core/utils/string';
1516
import { getTestNoDexieDbData } from '../utils/getTestNoDexieDbData';
16-
import { readFromOPFS } from '~/systems/Core/utils/opfs';
1717

1818
export type AccountInputs = {
1919
addAccount: {
@@ -106,7 +106,7 @@ export class AccountService {
106106

107107
try {
108108
const provider = await createProvider(providerUrl!);
109-
const balances = await getBalances(provider, account.publicKey);
109+
const balances = await getBalances(provider, account.address);
110110
const balanceAssets = await AssetsCache.fetchAllAssets(
111111
provider.getChainId(),
112112
balances.map((balance) => balance.assetId)
@@ -467,8 +467,7 @@ export class AccountService {
467467
// Private methods
468468
// ----------------------------------------------------------------------------
469469

470-
async function getBalances(provider: Provider, publicKey = '0x00') {
471-
const address = Address.fromPublicKey(publicKey);
470+
async function getBalances(provider: Provider, address: string) {
472471
const { balances } = await provider.getBalances(address);
473472
return balances;
474473
}

packages/app/src/systems/Asset/components/AssetList/AssetListEmpty.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { useFundWallet } from '~/systems/FundWallet';
55
export type AssetListEmptyProps = {
66
text?: string;
77
supportText?: string;
8+
hideFaucet?: boolean;
89
};
910

1011
export function AssetListEmpty({
1112
text = `You don't have any assets`,
1213
supportText = 'Start depositing some assets',
14+
hideFaucet = false,
1315
}: AssetListEmptyProps) {
1416
const { open, hasFaucet, hasBridge } = useFundWallet();
1517
const showFund = hasFaucet || hasBridge;
@@ -19,7 +21,7 @@ export function AssetListEmpty({
1921
<Card.Body>
2022
{!!text && <Heading as="h5">{text}</Heading>}
2123
{!!supportText && <Text fontSize="sm">{supportText}</Text>}
22-
{showFund && (
24+
{showFund && !hideFaucet && (
2325
/**
2426
* TODO: need to add right faucet icon on @fuel-ui
2527
*/

0 commit comments

Comments
 (0)