Skip to content

NFT component improvements #62

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Dec 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
"@emotion/core": "^11.0.0",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"classnames": "^2.2.6",
"cross-fetch": "^3.1.4",
"ethers": "^5.5.2",
"framer-motion": "^4"
},
"peerDependencies": {
Expand All @@ -42,15 +42,12 @@
"devDependencies": {
"@babel/core": "^7.12.7",
"@storybook/react": "^6.3.12",
"@types/classnames": "^2.2.11",
"@types/jest": "^26.0.15",
"@types/node": "^16.11.9",
"@types/react": "^17.0.36",
"@types/react-dom": "^16.9.10",
"@web3-ui/hooks": "^0.1.0",
"babel-loader": "^8.2.1",
"classnames": "^2.2.6",
"ethers": "^5.5.1",
"husky": "^7.0.0",
"identity-obj-proxy": "^3.0.0",
"lint-staged": "^12.1.2",
Expand Down
23 changes: 17 additions & 6 deletions packages/components/src/components/NFT/NFT.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ export default {
component: NFT,
};

export const Default = () => (
export const image = () => (
<NFT tokenId='1' contractAddress='0x25ed58c027921e14d86380ea2646e3a1b5c55a8b' />
);

export const GIF = () => (
<NFT
tokenId='1'
name='Dev #1'
imageUrl='https://storage.opensea.io/files/acef01b1f111088c40a0d86a4cd8a2bd.svg'
assetContractName='Devs for Revolution'
assetContractSymbol='DEVS'
contractAddress='0x495f947276749ce646f68ac8c248420045cb7b5e'
tokenId='107788331033457039753851660026809005506934842002275129649229957686061111967745'
/>
);

export const Video = () => (
<NFT contractAddress='0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0' tokenId='29192' />
);

export const Audio = () => (
<NFT contractAddress='0x0eede4764cfdfcd5dac0e00b3b7f4778c0cc994e' tokenId='1' />
);

export const Error = () => <NFT contractAddress='abcd' tokenId='1' />;
25 changes: 12 additions & 13 deletions packages/components/src/components/NFT/NFT.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import React from 'react';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';

import { NFT } from './NFT';
import { act } from 'react-dom/test-utils';

describe('NFT', () => {
it('displays the NFT name', () => {
const { container } = render(
<NFT
tokenId='1'
name='Dev #1'
imageUrl='https://storage.opensea.io/files/acef01b1f111088c40a0d86a4cd8a2bd.svg'
assetContractName='Devs for Revolution'
assetContractSymbol='DEVS'
/>
);

expect(container.textContent).toContain('Dev #1');
it('displays an image NFT properly', async () => {
act(() => {
render(<NFT tokenId='1' contractAddress='0x25ed58c027921e14d86380ea2646e3a1b5c55a8b' />);
});
const name = await screen.findByText('Dev #1');
const image = await screen.findByAltText('Dev #1');
expect(name).toBeInTheDocument();
expect(image).toBeInTheDocument();
});

//TODO: test for video NFT
});
156 changes: 115 additions & 41 deletions packages/components/src/components/NFT/NFT.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,129 @@
import React from 'react';
import { Box, Heading, Image, Flex, Tag, Text } from '@chakra-ui/react';
import React, { useCallback, useEffect, useRef } from 'react';
import {
Box,
Heading,
Image,
Flex,
Tag,
Text,
VStack,
Skeleton,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import fetch from 'cross-fetch';

export interface NFTProps {
/**
* The id for the NFT, unique within the contract
*/
contractAddress: string;
tokenId: string;
/**
* The name of the NFT, potentially null
*/
}

export interface NFTData {
tokenId: string;
imageUrl?: string;
name: string | null;
/**
* The image of the NFT, cached from OpenSea
*/
imageUrl: string;
/**
* The name of the NFT collection
*/
assetContractName: string;
/**
* The symbol for the NFT collection
*/
assetContractSymbol: string;
assetContractName: string;
animationUrl?: string;
}

/**
* Component to display an NFT given render params
* Component to fetch and display NFT data
*/
export const NFT = ({
tokenId,
name,
imageUrl,
assetContractName,
assetContractSymbol,
}: NFTProps) => {
const displayName = name || tokenId;
export const NFT = ({ contractAddress, tokenId }: NFTProps) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think we could take the approach i mentioned here for the NFT component:#46 (comment)

Add a wrapper around NFT (and make the current implementation private) that uses a tokenId and contract address to fetch metadata about it

We should keep the existing component around and use it for the NFTGallery component so we don't need to refetch the NFT data that already comes from the previous API

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@etr2460 missed that. That sounds fair

const _isMounted = useRef(true);
const [nftData, setNftData] = React.useState<NFTData>();
const [errorMessage, setErrorMessage] = React.useState<string>();

const fetchNFTData = useCallback(async () => {
try {
const res = await fetch(`https://api.opensea.io/api/v1/asset/${contractAddress}/${tokenId}/`);
if (!res.ok) {
throw Error(
`OpenSea request failed with status: ${res.status}. Make sure you are on mainnet.`
);
}
const data = await res.json();
if (_isMounted.current) {
setNftData({
tokenId: data.token_id,
imageUrl: data.image_url,
name: data.name,
assetContractName: data.asset_contract.name,
assetContractSymbol: data.asset_contract.symbol,
animationUrl: data.animation_url,
});
}
} catch (error: any) {
setErrorMessage(error.message);
}
}, [contractAddress, tokenId]);

useEffect(() => {
_isMounted.current = true;
fetchNFTData();
return () => {
_isMounted.current = false;
};
}, [contractAddress, tokenId]);

return <NFTCard data={nftData} errorMessage={errorMessage} />;
};

/**
* Private component to display an NFT given the data
*/
export const NFTCard = ({
data,
errorMessage = '',
}: {
data: NFTData | undefined | null;
errorMessage?: string | undefined;
}) => {
const name = data?.name;
const imageUrl = data?.imageUrl;
const assetContractName = data?.assetContractName;
const assetContractSymbol = data?.assetContractSymbol;
const animationUrl = data?.animationUrl;
const tokenId = data?.tokenId;
const displayName = name || `${assetContractSymbol} #${tokenId}`;

if (errorMessage) {
return (
<Alert status='error'>
<AlertIcon />
{errorMessage}
</Alert>
);
}

return (
<Box maxW='xs' borderRadius='lg' borderWidth='1px' overflow='hidden'>
<Image src={imageUrl} alt={displayName} borderRadius='lg' />
<Box p='6'>
<Flex alignItems='center' justifyContent='space-between' pb='2'>
<Heading as='h3' size='sm'>
{displayName}
</Heading>
<Tag size='sm'>{assetContractSymbol}</Tag>
</Flex>
<Text fontSize='xs'>
{assetContractName} #{tokenId}
</Text>
<Skeleton isLoaded={!!data} maxW='xs' h='md'>
<Box maxW='xs' borderRadius='lg' borderWidth='1px' overflow='hidden'>
{animationUrl ? (
animationUrl.endsWith('.mp3') ? (
<VStack>
<Image src={imageUrl} alt={displayName} borderRadius='lg' />
<audio src={animationUrl} controls autoPlay muted style={{ borderRadius: '7px' }} />
</VStack>
) : (
<video src={animationUrl} controls autoPlay muted />
)
) : (
<Image src={imageUrl} alt={displayName} borderRadius='lg' />
)}
<Box p='6'>
<Flex alignItems='center' justifyContent='space-between' pb='2'>
<Heading as='h3' size='sm'>
{displayName}
</Heading>
{assetContractSymbol && <Tag size='sm'>{assetContractSymbol}</Tag>}
</Flex>
<Text fontSize='xs'>
{assetContractName} #{tokenId}
</Text>
</Box>
</Box>
</Box>
</Skeleton>
);
};
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
import React from 'react';
import { ethers } from 'ethers';
import React, { useEffect, useState } from 'react';
import { NFTGallery } from '.';

export default {
title: 'Components/NFTGallery',
component: NFTGallery,
parameters: {
// TODO: Fix window.ethereum is undefined breaking chromatic
chromatic: { disableSnapshot: true },
},
};

export const Default = () => <NFTGallery address='0x1A16c87927570239FECD343ad2654fD81682725e' />;
export const nftsOwnedByAnAccount = () => (
<NFTGallery address='0x1A16c87927570239FECD343ad2654fD81682725e' />
);

export const nftsOwnedByAnENS = () => {
const [provider, setProvider] = useState<ethers.providers.Web3Provider>();

useEffect(() => {
const provider = new ethers.providers.Web3Provider(window.ethereum);
setProvider(provider);
}, []);

if (!provider) {
return <>Loading...</>;
}

return <NFTGallery address='dhaiwat.eth' web3Provider={provider} />;
};

export const WithAnError = () => <NFTGallery address='bad_address' />;
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ describe('NFTGallery', () => {
expect(container.textContent).toContain('OpenSea request failed');
});
});

//TODO: test for ENS
});
52 changes: 34 additions & 18 deletions packages/components/src/components/NFTGallery/NFTGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { useEffect } from 'react';
import fetch from 'cross-fetch';

import { ethers } from 'ethers';
import { VStack, Heading, Grid, Alert, AlertIcon } from '@chakra-ui/react';
import { NFT } from '../NFT';
import { NFTCard } from '../NFT';

export interface NFTGalleryProps {
/**
Expand All @@ -13,6 +13,7 @@ export interface NFTGalleryProps {
* The number of columns in the grid
*/
gridWidth?: number;
web3Provider?: ethers.providers.Web3Provider;
}

export interface OpenSeaAsset {
Expand All @@ -21,6 +22,7 @@ export interface OpenSeaAsset {
name: string | null;
asset_contract: {
name: string;
address: string;
symbol: string;
};
}
Expand All @@ -29,21 +31,33 @@ export interface OpenSeaAsset {
* Component to display a grid of NFTs owned by an address. It uses the OpenSea API to fetch
* the NFTs.
*/
export const NFTGallery = ({ address, gridWidth = 4 }: NFTGalleryProps) => {
export const NFTGallery = ({ address, gridWidth = 4, web3Provider }: NFTGalleryProps) => {
const [nfts, setNfts] = React.useState<OpenSeaAsset[]>([]);
const [errorMessage, setErrorMessage] = React.useState();

useEffect(() => {
fetch(`https://api.opensea.io/api/v1/assets?owner=${address}`)
.then((res) => {
if (!res.ok) {
throw Error(`OpenSea request failed with status: ${res.status}.`);
async function exec() {
let resolvedAddress: string | null = address;
if (address.endsWith('.eth')) {
if (!web3Provider) {
return console.error('Please provide a web3 provider');
}
return res.json();
})
.then((data) => setNfts(data.assets))
.catch((err) => setErrorMessage(err.message));
}, [address]);
resolvedAddress = await web3Provider.resolveName(address);
}
fetch(`https://api.opensea.io/api/v1/assets?owner=${resolvedAddress}`)
.then((res) => {
if (!res.ok) {
throw Error(
`OpenSea request failed with status: ${res.status}. Make sure you are on mainnet.`
);
}
return res.json();
})
.then((data) => setNfts(data.assets))
.catch((err) => setErrorMessage(err.message));
}
exec();
}, [address, web3Provider]);

return (
<VStack>
Expand All @@ -56,13 +70,15 @@ export const NFTGallery = ({ address, gridWidth = 4 }: NFTGalleryProps) => {
)}
<Grid templateColumns={`repeat(${gridWidth}, 1fr)`} gap={6}>
{nfts.map((nft) => (
<NFT
<NFTCard
key={`${nft.asset_contract.symbol}-${nft.token_id}`}
tokenId={nft.token_id}
name={nft.name}
imageUrl={nft.image_url}
assetContractName={nft.asset_contract.name}
assetContractSymbol={nft.asset_contract.symbol}
data={{
name: nft.name!,
imageUrl: nft.image_url,
tokenId: nft.token_id,
assetContractName: nft.asset_contract.name,
assetContractSymbol: nft.asset_contract.symbol,
}}
/>
))}
</Grid>
Expand Down
Loading