Skip to content

Commit

Permalink
feat: add NFTs tab (#1762)
Browse files Browse the repository at this point in the history
- Closes #1686 
- Closes `FE-1087`

---


| 📷 Demo |
| --- |
| <video
src="https://github.com/user-attachments/assets/d2771cb6-fe1a-4c83-9ab6-db2be421d1c6"
/> |
  • Loading branch information
helciofranco authored Jan 17, 2025
1 parent ab1ed94 commit 2460bee
Show file tree
Hide file tree
Showing 15 changed files with 362 additions and 111 deletions.
5 changes: 5 additions & 0 deletions .changeset/early-walls-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fuels-wallet": minor
---

Add a separate NFTs tab to the home screen.
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,33 @@ export type BalanceAssetListProp = {
};

export const BalanceAssets = ({
balances,
balances = [],
isLoading,
emptyProps = {},
onRemove,
onEdit,
}: BalanceAssetListProp) => {
const [showUnknown, setShowUnknown] = useState(false);
const unknownLength = useMemo(
() =>
balances?.filter(
(balance) => balance.asset && isUnknownAsset(balance.asset)
).length,
[balances]
);

const unknownLength = useMemo<number>(() => {
return balances.filter(
(balance) => balance.asset && isUnknownAsset(balance.asset)
).length;
}, [balances]);

const balancesToShow = useMemo<CoinAsset[]>(() => {
return balances.filter((balance) => {
const isNft = Boolean(balance.asset?.isNft);
return (
!isNft &&
(showUnknown || (balance.asset && !isUnknownAsset(balance.asset)))
);
});
}, [balances, showUnknown]);

if (isLoading || !balances) return <AssetList.Loading items={4} />;
const isEmpty = !balances || !balances.length;
if (isEmpty) return <AssetList.Empty {...emptyProps} />;
const balancesToShow = balances.filter(
(balance) =>
showUnknown || (balance.asset && !isUnknownAsset(balance.asset))
);

function toggle() {
setShowUnknown((s) => !s);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { cssObj } from '@fuel-ui/css';
import { Accordion, Badge, Box, Copyable, VStack } from '@fuel-ui/react';
import type { CoinAsset } from '@fuel-wallet/types';
import { useMemo } from 'react';
import { AssetListEmpty } from '~/systems/Asset/components/AssetList/AssetListEmpty';
import { shortAddress } from '~/systems/Core';
import { NFTImage } from './NFTImage';
import {
UNKNOWN_COLLECTION_TITLE,
groupNFTsByCollection,
} from './groupNFTsByCollection';

interface BalanceNFTsProps {
balances: CoinAsset[] | undefined;
}

export const BalanceNFTs = ({ balances = [] }: BalanceNFTsProps) => {
const { collections, defaultValue } = useMemo(() => {
const collections = groupNFTsByCollection(balances);
const defaultValue = collections
.map((collection) => collection.name)
.filter((collection) => collection !== UNKNOWN_COLLECTION_TITLE);

return {
collections,
defaultValue,
};
}, [balances]);

if (collections.length === 0) {
return (
<AssetListEmpty
text="You don't have any NFTs"
supportText="To add NFTs, simply send them to your Fuel address."
hideFaucet
/>
);
}

return (
<Box css={styles.root}>
<Accordion type="multiple" defaultValue={defaultValue}>
{collections.map((collection) => {
return (
<Accordion.Item key={collection.name} value={collection.name}>
<Accordion.Trigger>
<Badge variant="ghost" color="gray" as="span">
{collection.nfts.length}
</Badge>
{collection.name}
</Accordion.Trigger>
<Accordion.Content>
<Box css={styles.grid}>
{collection.nfts.map((nft) => {
return (
<div key={nft.assetId}>
<NFTImage assetId={nft.assetId} image={nft.image} />
<Copyable css={styles.name} value={nft.assetId}>
{nft.name || shortAddress(nft.assetId)}
</Copyable>
</div>
);
})}
</Box>
</Accordion.Content>
</Accordion.Item>
);
})}
</Accordion>
</Box>
);
};

const styles = {
root: cssObj({
'.fuel_Accordion-trigger': {
fontSize: '$base',
fontWeight: '$medium',
backgroundColor: 'transparent',
color: '$intentsBase11',
padding: '$0',
gap: '$2',
flexDirection: 'row-reverse',
justifyContent: 'flex-start',
},
'.fuel_Accordion-trigger:hover': {
color: '$intentsBase12',
},
'.fuel_Accordion-trigger[data-state="open"]': {
color: '$intentsBase12',
},
'.fuel_Accordion-trigger[data-state="closed"] .fuel_Accordion-icon': {
transform: 'rotate(-45deg)',
},
'.fuel_Accordion-trigger[data-state="open"] .fuel_Accordion-icon': {
transform: 'rotate(0deg)',
},
'.fuel_Accordion-item': {
backgroundColor: 'transparent',
borderBottom: '1px solid $border',
borderRadius: '$none',

svg: {
width: '$3',
height: '$3',
},
},
'.fuel_Accordion-content': {
border: '0',
padding: '$0 5px $2 20px',
},
'.fuel_Badge': {
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: '$normal',
fontSize: '$xs',
padding: '$0',
height: '$5',
minWidth: '$5',
pointerEvents: 'none',
marginLeft: 'auto',
lineHeight: 'normal',
},
}),
grid: cssObj({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '$3',
}),
name: cssObj({
marginTop: '$1',
gap: '$0',
fontSize: '$xs',
lineHeight: '$none',
textAlign: 'center',
}),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { cssObj } from '@fuel-ui/css';
import { Box, ContentLoader, Icon } from '@fuel-ui/react';
import { useEffect, useRef, useState } from 'react';
import { shortAddress } from '~/systems/Core';

interface NFTImageProps {
assetId: string;
image: string | undefined;
}

export const NFTImage = ({ assetId, image }: NFTImageProps) => {
const imgRef = useRef<HTMLImageElement>(null);

const [fallback, setFallback] = useState(false);
const [isLoading, setLoading] = useState(true);

useEffect(() => {
if (imgRef.current?.complete) {
if (imgRef.current.naturalWidth) {
setLoading(false);
return;
}

setFallback(true);
}
}, []);

if (image && !fallback) {
return (
<Box css={styles.item}>
{isLoading && (
<ContentLoader width="100%" height="100%" viewBox="0 0 22 22">
<rect x="0" y="0" rx="0" ry="0" width="22" height="22" />
</ContentLoader>
)}
<img
ref={imgRef}
src={image}
alt={shortAddress(assetId)}
data-loading={isLoading}
onLoad={() => setLoading(false)}
onError={() => {
setFallback(true);
}}
/>
</Box>
);
}

return (
<Box css={styles.noImage}>
<Icon icon={Icon.is('FileOff')} />
</Box>
);
};

const styles = {
item: cssObj({
aspectRatio: '1 / 1',
borderRadius: '12px',
overflow: 'hidden',

img: {
width: '100%',
objectFit: 'cover',
},
'img[data-loading="true"]': {
display: 'none',
},
}),
noImage: cssObj({
width: '100%',
aspectRatio: '1 / 1',
borderRadius: '12px',
border: '1px solid $cardBorder',
backgroundColor: '$cardBg',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { CoinAsset } from '@fuel-wallet/types';

export const UNKNOWN_COLLECTION_TITLE = 'Others';

interface NFT {
assetId: string;
name: string | undefined;
image: string | undefined;
}
interface Collection {
name: string;
nfts: NFT[];
}

export const groupNFTsByCollection = (balances: CoinAsset[]): Collection[] => {
const grouped: Collection[] = balances
// Filter only NFTs
.filter((balance) => {
return balance.asset?.contractId && Boolean(balance.asset?.isNft);
})

// Group balances by collection name
.reduce((acc, balance) => {
const name = balance.asset?.collection || UNKNOWN_COLLECTION_TITLE;
let collection = acc.find((item) => item.name === name);

if (!collection) {
collection = { name, nfts: [] };
acc.push(collection);
}

const image = balance.asset?.metadata?.image?.replace(
'ipfs://',
'https://ipfs.io/ipfs/'
);

collection.nfts.push({
assetId: balance.assetId,
name: balance?.asset?.metadata?.name,
image,
});

return acc;
}, [] as Collection[])

// Sort NFTs by name
.map((collection) => {
return {
name: collection.name,
nfts: collection.nfts.sort((a, b) => {
if (a.name && b.name) {
return a.name.localeCompare(b.name, undefined, {
numeric: true,
sensitivity: 'base',
});
}

return 0;
}),
};
})

// Move "Others" to the bottom
.sort((a, b) => {
if (a.name === UNKNOWN_COLLECTION_TITLE) return 1;
if (b.name === UNKNOWN_COLLECTION_TITLE) return -1;
return 0;
});

return grouped;
};
7 changes: 3 additions & 4 deletions packages/app/src/systems/Account/services/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { AssetsCache } from '~/systems/Asset/cache/AssetsCache';
import { chromeStorage } from '~/systems/Core/services/chromeStorage';
import type { Maybe } from '~/systems/Core/types';
import { db } from '~/systems/Core/utils/database';
import { readFromOPFS } from '~/systems/Core/utils/opfs';
import { getUniqueString } from '~/systems/Core/utils/string';
import { getTestNoDexieDbData } from '../utils/getTestNoDexieDbData';
import { readFromOPFS } from '~/systems/Core/utils/opfs';

export type AccountInputs = {
addAccount: {
Expand Down Expand Up @@ -106,7 +106,7 @@ export class AccountService {

try {
const provider = await createProvider(providerUrl!);
const balances = await getBalances(provider, account.publicKey);
const balances = await getBalances(provider, account.address);
const balanceAssets = await AssetsCache.fetchAllAssets(
provider.getChainId(),
balances.map((balance) => balance.assetId)
Expand Down Expand Up @@ -467,8 +467,7 @@ export class AccountService {
// Private methods
// ----------------------------------------------------------------------------

async function getBalances(provider: Provider, publicKey = '0x00') {
const address = Address.fromPublicKey(publicKey);
async function getBalances(provider: Provider, address: string) {
const { balances } = await provider.getBalances(address);
return balances;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { useFundWallet } from '~/systems/FundWallet';
export type AssetListEmptyProps = {
text?: string;
supportText?: string;
hideFaucet?: boolean;
};

export function AssetListEmpty({
text = `You don't have any assets`,
supportText = 'Start depositing some assets',
hideFaucet = false,
}: AssetListEmptyProps) {
const { open, hasFaucet, hasBridge } = useFundWallet();
const showFund = hasFaucet || hasBridge;
Expand All @@ -19,7 +21,7 @@ export function AssetListEmpty({
<Card.Body>
{!!text && <Heading as="h5">{text}</Heading>}
{!!supportText && <Text fontSize="sm">{supportText}</Text>}
{showFund && (
{showFund && !hideFaucet && (
/**
* TODO: need to add right faucet icon on @fuel-ui
*/
Expand Down
Loading

0 comments on commit 2460bee

Please sign in to comment.