diff --git a/.changeset/early-walls-rescue.md b/.changeset/early-walls-rescue.md new file mode 100644 index 0000000000..f50be7c1c4 --- /dev/null +++ b/.changeset/early-walls-rescue.md @@ -0,0 +1,5 @@ +--- +"fuels-wallet": minor +--- + +Add a separate NFTs tab to the home screen. diff --git a/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx b/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx index fb54db5634..00ba9c7a05 100644 --- a/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx +++ b/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx @@ -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(() => { + return balances.filter( + (balance) => balance.asset && isUnknownAsset(balance.asset) + ).length; + }, [balances]); + + const balancesToShow = useMemo(() => { + return balances.filter((balance) => { + const isNft = Boolean(balance.asset?.isNft); + return ( + !isNft && + (showUnknown || (balance.asset && !isUnknownAsset(balance.asset))) + ); + }); + }, [balances, showUnknown]); if (isLoading || !balances) return ; const isEmpty = !balances || !balances.length; if (isEmpty) return ; - const balancesToShow = balances.filter( - (balance) => - showUnknown || (balance.asset && !isUnknownAsset(balance.asset)) - ); function toggle() { setShowUnknown((s) => !s); diff --git a/packages/app/src/systems/Account/components/BalanceNFTs/BalanceNFTs.tsx b/packages/app/src/systems/Account/components/BalanceNFTs/BalanceNFTs.tsx new file mode 100644 index 0000000000..59712e6250 --- /dev/null +++ b/packages/app/src/systems/Account/components/BalanceNFTs/BalanceNFTs.tsx @@ -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 ( + + ); + } + + return ( + + + {collections.map((collection) => { + return ( + + + + {collection.nfts.length} + + {collection.name} + + + + {collection.nfts.map((nft) => { + return ( +
+ + + {nft.name || shortAddress(nft.assetId)} + +
+ ); + })} +
+
+
+ ); + })} +
+
+ ); +}; + +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', + }), +}; diff --git a/packages/app/src/systems/Account/components/BalanceNFTs/NFTImage.tsx b/packages/app/src/systems/Account/components/BalanceNFTs/NFTImage.tsx new file mode 100644 index 0000000000..6ad543e539 --- /dev/null +++ b/packages/app/src/systems/Account/components/BalanceNFTs/NFTImage.tsx @@ -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(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 ( + + {isLoading && ( + + + + )} + {shortAddress(assetId)} setLoading(false)} + onError={() => { + setFallback(true); + }} + /> + + ); + } + + return ( + + + + ); +}; + +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', + }), +}; diff --git a/packages/app/src/systems/Account/components/BalanceNFTs/groupNFTsByCollection.ts b/packages/app/src/systems/Account/components/BalanceNFTs/groupNFTsByCollection.ts new file mode 100644 index 0000000000..ec8f2f9189 --- /dev/null +++ b/packages/app/src/systems/Account/components/BalanceNFTs/groupNFTsByCollection.ts @@ -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; +}; diff --git a/packages/app/src/systems/Account/services/account.ts b/packages/app/src/systems/Account/services/account.ts index f908ae9182..36965055be 100644 --- a/packages/app/src/systems/Account/services/account.ts +++ b/packages/app/src/systems/Account/services/account.ts @@ -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: { @@ -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) @@ -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; } diff --git a/packages/app/src/systems/Asset/components/AssetList/AssetListEmpty.tsx b/packages/app/src/systems/Asset/components/AssetList/AssetListEmpty.tsx index b7f5b5e424..e1d3816eba 100644 --- a/packages/app/src/systems/Asset/components/AssetList/AssetListEmpty.tsx +++ b/packages/app/src/systems/Asset/components/AssetList/AssetListEmpty.tsx @@ -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; @@ -19,7 +21,7 @@ export function AssetListEmpty({ {!!text && {text}} {!!supportText && {supportText}} - {showFund && ( + {showFund && !hideFaucet && ( /** * TODO: need to add right faucet icon on @fuel-ui */ diff --git a/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.stories.tsx b/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.stories.tsx deleted file mode 100644 index 9c3b9bc265..0000000000 --- a/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Box } from '@fuel-ui/react'; - -import { AssetsTitle } from './AssetsTitle'; - -export default { - component: AssetsTitle, - title: 'Home/Components/AssetsTitle', -}; - -export const Usage = () => ( - - - -); diff --git a/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.test.tsx b/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.test.tsx deleted file mode 100644 index c546f98456..0000000000 --- a/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { render, screen, testA11y } from '@fuel-ui/test-utils'; - -import { AssetsTitle } from './AssetsTitle'; - -describe('AssetsTitle', () => { - it('a11y', async () => { - await testA11y(); - }); - - it('should show Assets title', () => { - render(); - expect(screen.getByText('Assets')).toBeInTheDocument(); - }); -}); diff --git a/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.tsx b/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.tsx deleted file mode 100644 index 3b4334da9f..0000000000 --- a/packages/app/src/systems/Home/components/AssetsTitle/AssetsTitle.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Box, Heading, Icon } from '@fuel-ui/react'; - -export const AssetsTitle = () => { - return ( - - - - Assets - - - ); -}; diff --git a/packages/app/src/systems/Home/components/AssetsTitle/index.tsx b/packages/app/src/systems/Home/components/AssetsTitle/index.tsx deleted file mode 100644 index 2f22bb6a0e..0000000000 --- a/packages/app/src/systems/Home/components/AssetsTitle/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './AssetsTitle'; diff --git a/packages/app/src/systems/Home/components/index.tsx b/packages/app/src/systems/Home/components/index.tsx index dbf33f6a3c..4cdfa26c95 100644 --- a/packages/app/src/systems/Home/components/index.tsx +++ b/packages/app/src/systems/Home/components/index.tsx @@ -1,2 +1 @@ -export * from './AssetsTitle'; export * from './HomeActions'; diff --git a/packages/app/src/systems/Home/pages/Home/Home.tsx b/packages/app/src/systems/Home/pages/Home/Home.tsx index bc6c0e0b68..1fd283262c 100644 --- a/packages/app/src/systems/Home/pages/Home/Home.tsx +++ b/packages/app/src/systems/Home/pages/Home/Home.tsx @@ -1,12 +1,13 @@ import { cssObj } from '@fuel-ui/css'; -import { Box } from '@fuel-ui/react'; +import { Tabs } from '@fuel-ui/react'; import { useNavigate } from 'react-router-dom'; import { BalanceWidget, useAccounts } from '~/systems/Account'; import { Layout, Pages, scrollable } from '~/systems/Core'; import { useBalanceVisibility } from '~/systems/Core/hooks/useVisibility'; import { BalanceAssets } from '~/systems/Account/components/BalanceAssets/BalanceAssets'; -import { AssetsTitle, HomeActions } from '../../components'; +import { BalanceNFTs } from '~/systems/Account/components/BalanceNFTs/BalanceNFTs'; +import { HomeActions } from '../../components'; export function Home() { const { visibility, setVisibility } = useBalanceVisibility(); @@ -24,51 +25,50 @@ export function Home() { return ( - - - - - - - - - - - - - + + + + + + + Assets + + + NFT + + + + + + + + + ); } const styles = { - content: cssObj({ - flex: 1, - overflow: 'hidden', - }), assets: cssObj({ - gap: '$2', - overflow: 'hidden', - flex: 1, - }), - assetsTitle: cssObj({ - px: '$4', + paddingLeft: '$4', + + '.fuel_TabsList': { + marginBottom: '$3', + }, }), assetsList: cssObj({ - padding: '$2 $0 $4 $4', + maxHeight: 230, + paddingBottom: '$4', ...scrollable(), overflowY: 'scroll !important', }), diff --git a/packages/types/src/accounts.ts b/packages/types/src/accounts.ts index fbc5a68d08..af5541c5be 100644 --- a/packages/types/src/accounts.ts +++ b/packages/types/src/accounts.ts @@ -9,11 +9,7 @@ export type Vault = { }; export interface CoinAsset extends Coin { - asset?: AssetFuelData & { - indexed?: boolean; - suspicious?: boolean; - isNft?: boolean; - }; + asset?: AssetFuelData; } export type Account = { diff --git a/packages/types/src/asset.ts b/packages/types/src/asset.ts index 20131052d3..b0985e45c9 100644 --- a/packages/types/src/asset.ts +++ b/packages/types/src/asset.ts @@ -15,13 +15,17 @@ export type AssetAmount = AssetData & { }; export type AssetFuelData = AssetFuel & { - // override icon to don't be required icon?: string; isCustom?: boolean; indexed?: boolean; suspicious?: boolean; + collection?: string; isNft?: boolean; verified?: boolean; + metadata?: { + name?: string; + image?: string; + }; }; export type AssetFuelAmount = AssetFuelData & {