Feat: Display NFT in asset details (#1125)

* feat: add decodeTokenUri helper

* refactor: restructure of MetaMain component

* feat: add nft tooltip

* feat: add opensea link for nfts

* style: adjust nft image size in tooltip

* feat: add nft data to publish preview

* fix: readd owner to nft metadata

* refactor: conditional display of nft tooltip

* style: fix link styles in nft tooltip

* feat: add placeholder graphic as fallback if nft data does not contain one

* fix: display openSea link only on supported networks

* fix: rename ddo props to asset in metamain related components

* feat: add original publisher to asset details

* chore: remove unused imports

* fix: remove unused prop

* feat: convert publisher address to checksum address

* chore: remove console.error when decoding tokenURI

* Revert "chore: remove console.error when decoding tokenURI"

This reverts commit f387175970.

* feat: shorten nft address in tooltip preview

* fix: use Web3.utils instead of the actual web3 instance to convert wei in ether

Co-authored-by: Luca Milanese <luca.milanese90@gmail.com>
Co-authored-by: Matthias Kretschmann <m@kretschmann.io>
This commit is contained in:
Moritz Kirstein 2022-03-16 20:01:51 +01:00 committed by GitHub
parent 8b331c0a63
commit 9d1b7794a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 417 additions and 145 deletions

View File

@ -1,13 +1,13 @@
import { SvgWaves } from './SvgWaves'
import {
Asset,
LoggerInstance,
Asset,
getHash,
Nft,
ProviderInstance,
DDO,
MetadataAndTokenURI
} from '@oceanprotocol/lib'
import { SvgWaves } from './SvgWaves'
import Web3 from 'web3'
import { TransactionReceipt } from 'web3-core'
@ -60,6 +60,8 @@ export function generateNftMetadata(): NftMetadata {
return newNft
}
const tokenUriPrefix = 'data:application/json;base64,'
export function generateNftCreateData(nftMetadata: NftMetadata): any {
const nftCreateData = {
name: nftMetadata.name,
@ -71,6 +73,19 @@ export function generateNftCreateData(nftMetadata: NftMetadata): any {
return nftCreateData
}
export function decodeTokenURI(tokenURI: string): NftMetadata {
if (!tokenURI) return undefined
try {
const nftMeta = JSON.parse(
Buffer.from(tokenURI.replace(tokenUriPrefix, ''), 'base64').toString()
) as NftMetadata
return nftMeta
} catch (error) {
LoggerInstance.error(`[NFT] ${error.message}`)
}
}
export async function setNftMetadata(
asset: Asset | DDO,
accountId: string,

View File

@ -1,6 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react'
import { usePrices } from '@context/Prices'
import { useWeb3 } from '@context/Web3'
import Web3 from 'web3'
import useNftFactory from '@hooks/contracts/useNftFactory'
import { NftFactory } from '@oceanprotocol/lib'
import Conversion from '@shared/Price/Conversion'
@ -20,7 +21,7 @@ const getEstGasFee = async (
const gasPrice = await web3.eth.getGasPrice()
const gasLimit = await nftFactory?.estGasCreateNFT(address, nft)
const gasFeeEth = web3.utils.fromWei(
const gasFeeEth = Web3.utils.fromWei(
(+gasPrice * +gasLimit).toString(),
'ether'
)

View File

@ -6,7 +6,6 @@
background: none;
padding: 0;
cursor: pointer;
position: relative;
}
.icon {
@ -25,11 +24,12 @@
fill: var(--brand-alert-green);
}
.copied::after {
content: 'Copied!';
position: absolute;
top: -150%;
left: -140%;
.action {
display: flex;
gap: 5px;
}
.feedback {
font-size: var(--font-size-mini);
color: var(--brand-alert-green);
}

View File

@ -27,7 +27,10 @@ export default function Copy({ text }: { text: string }): ReactElement {
onSuccess={() => setIsCopied(true)}
className={`${styles.button} ${isCopied ? styles.copied : ''}`}
>
<IconCopy className={styles.icon} />
<div className={styles.action}>
<IconCopy className={styles.icon} />
{isCopied && <span className={styles.feedback}>Copied!</span>}
</div>
</Clipboard>
)
}

View File

@ -17,6 +17,7 @@ const getReceipts = gql`
id
nft {
address
owner
}
tx
timestamp
@ -25,7 +26,13 @@ const getReceipts = gql`
}
`
export default function EditHistory(): ReactElement {
export default function EditHistory({
receipts,
setReceipts
}: {
receipts: ReceiptData[]
setReceipts: (receipts: ReceiptData[]) => void
}): ReactElement {
const { asset } = useAsset()
function getUpdateType(type: string): string {
@ -66,13 +73,11 @@ export default function EditHistory(): ReactElement {
//
// 2. Construct display data based on fetched data.
//
const [receipts, setReceipts] = useState<ReceiptData[]>()
useEffect(() => {
if (!data || data.nftUpdates.length === 0) return
const receiptCollection = data.nftUpdates
setReceipts(receiptCollection)
}, [data])
}, [data, setReceipts])
return (
<>

View File

@ -1,51 +0,0 @@
.meta {
margin-bottom: calc(var(--spacer) / 1.5);
color: var(--color-secondary);
font-size: var(--font-size-small);
}
.asset {
margin-left: -2rem;
margin-right: -2rem;
padding-left: 2rem;
padding-right: 3rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: calc(var(--spacer) / 1.5);
padding-bottom: calc(var(--spacer) / 1.75);
}
@media (min-width: 40rem) {
.asset {
margin-top: -0.65rem;
}
}
.assetType {
display: inline-block;
border-right: 1px solid var(--border-color);
padding-right: calc(var(--spacer) / 3.5);
margin-right: calc(var(--spacer) / 4);
}
.datatoken {
white-space: pre;
margin-right: calc(var(--spacer) / 3);
}
.byline {
font-size: var(--font-size-small);
}
.updated {
font-size: var(--font-size-mini);
}
.addWrap {
padding-left: calc(var(--spacer) / 5);
border-left: 1px solid var(--border-color);
display: inline-block;
}
.add {
font-size: var(--font-size-mini);
}

View File

@ -1,75 +0,0 @@
import React, { ReactElement } from 'react'
import { useAsset } from '@context/Asset'
import { useWeb3 } from '@context/Web3'
import ExplorerLink from '@shared/ExplorerLink'
import Publisher from '@shared/Publisher'
import AddToken from '@shared/AddToken'
import Time from '@shared/atoms/Time'
import AssetType from '@shared/AssetType'
import styles from './MetaMain.module.css'
import { getServiceByName } from '@utils/ddo'
import { Asset } from '@oceanprotocol/lib'
export default function MetaMain({ ddo }: { ddo: Asset }): ReactElement {
const { isAssetNetwork } = useAsset()
const { web3ProviderInfo } = useWeb3()
const isCompute = Boolean(getServiceByName(ddo, 'compute'))
const accessType = isCompute ? 'compute' : 'access'
const blockscoutNetworks = [1287, 2021000, 2021001, 44787, 246, 1285]
const isBlockscoutExplorer = blockscoutNetworks.includes(ddo?.chainId)
const dataTokenName = ddo?.datatokens[0]?.name
const dataTokenSymbol = ddo?.datatokens[0]?.symbol
return (
<aside className={styles.meta}>
<header className={styles.asset}>
<AssetType
type={ddo?.metadata.type}
accessType={accessType}
className={styles.assetType}
/>
<ExplorerLink
className={styles.datatoken}
networkId={ddo?.chainId}
path={
isBlockscoutExplorer
? `tokens/${ddo?.services[0].datatokenAddress}`
: `token/${ddo?.services[0].datatokenAddress}`
}
>
{`${dataTokenName}${dataTokenSymbol}`}
</ExplorerLink>
{web3ProviderInfo?.name === 'MetaMask' && isAssetNetwork && (
<span className={styles.addWrap}>
<AddToken
address={ddo?.services[0].datatokenAddress}
symbol={(ddo as Asset)?.datatokens[0]?.symbol}
logo="https://raw.githubusercontent.com/oceanprotocol/art/main/logo/datatoken.png"
text={`Add ${(ddo as Asset)?.datatokens[0]?.symbol} to wallet`}
className={styles.add}
minimal
/>
</span>
)}
</header>
<div className={styles.byline}>
Published By <Publisher account={(ddo as Asset)?.nft?.owner} />
<p>
<Time date={ddo?.metadata.created} relative />
{ddo?.metadata.created !== ddo?.metadata.updated && (
<>
{' — '}
<span className={styles.updated}>
updated <Time date={ddo?.metadata.updated} relative />
</span>
</>
)}
</p>
</div>
</aside>
)
}

View File

@ -0,0 +1,26 @@
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
padding-left: calc(var(--spacer) / 2);
justify-content: center;
}
.datatoken {
white-space: pre;
margin-right: calc(var(--spacer) / 3);
}
.owner {
display: block;
}
.addWrap {
padding-left: calc(var(--spacer) / 5);
border-left: 1px solid var(--border-color);
display: inline-block;
}
.add {
font-size: var(--font-size-mini);
}

View File

@ -0,0 +1,54 @@
import { useAsset } from '@context/Asset'
import { useWeb3 } from '@context/Web3'
import { Asset } from '@oceanprotocol/lib'
import AddToken from '@shared/AddToken'
import ExplorerLink from '@shared/ExplorerLink'
import Publisher from '@shared/Publisher'
import React, { ReactElement } from 'react'
import styles from './MetaAsset.module.css'
export default function MetaAsset({
asset,
isBlockscoutExplorer
}: {
asset: Asset
isBlockscoutExplorer: boolean
}): ReactElement {
const { isAssetNetwork } = useAsset()
const { web3ProviderInfo } = useWeb3()
const dataTokenSymbol = asset?.datatokens[0]?.symbol
return (
<div className={styles.wrapper}>
<span className={styles.owner}>
Owned by <Publisher account={asset?.nft?.owner} />
</span>
<span>
<ExplorerLink
className={styles.datatoken}
networkId={asset?.chainId}
path={
isBlockscoutExplorer
? `tokens/${asset?.services[0].datatokenAddress}`
: `token/${asset?.services[0].datatokenAddress}`
}
>
{`Accessed with ${dataTokenSymbol}`}
</ExplorerLink>
{web3ProviderInfo?.name === 'MetaMask' && isAssetNetwork && (
<span className={styles.addWrap}>
<AddToken
address={asset?.services[0].datatokenAddress}
symbol={(asset as Asset)?.datatokens[0]?.symbol}
logo="https://raw.githubusercontent.com/oceanprotocol/art/main/logo/datatoken.png"
text={`Add ${(asset as Asset)?.datatokens[0]?.symbol} to wallet`}
className={styles.add}
minimal
/>
</span>
)}
</span>
</div>
)
}

View File

@ -0,0 +1,19 @@
.wrapper {
padding: calc(var(--spacer) / 2);
}
.assetType {
display: inline-block;
border-right: 1px solid var(--border-color);
padding-right: calc(var(--spacer) / 3.5);
margin-right: calc(var(--spacer) / 4);
}
.byline {
display: inline-block;
font-size: var(--font-size-small);
}
.updated {
font-size: var(--font-size-mini);
}

View File

@ -0,0 +1,47 @@
import { Asset } from '@oceanprotocol/lib'
import AssetType from '@shared/AssetType'
import Time from '@shared/atoms/Time'
import Publisher from '@shared/Publisher'
import { getServiceByName } from '@utils/ddo'
import React, { ReactElement } from 'react'
import styles from './MetaInfo.module.css'
export default function MetaInfo({
asset,
nftPublisher
}: {
asset: Asset
nftPublisher: string
}): ReactElement {
const isCompute = Boolean(getServiceByName(asset, 'compute'))
const accessType = isCompute ? 'compute' : 'access'
const nftOwner = asset?.nft?.owner
return (
<div className={styles.wrapper}>
<AssetType
type={asset?.metadata.type}
accessType={accessType}
className={styles.assetType}
/>
<div className={styles.byline}>
<p>
Published <Time date={asset?.metadata.created} relative />
{nftPublisher && nftPublisher !== nftOwner && (
<span>
{' by '} <Publisher account={nftPublisher} />
</span>
)}
{asset?.metadata.created !== asset?.metadata.updated && (
<>
{' — '}
<span className={styles.updated}>
updated <Time date={asset?.metadata.updated} relative />
</span>
</>
)}
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
.wrapper {
display: flex;
justify-content: flex-start;
align-items: center;
}
.wrapper img {
margin: 0;
width: 128px;
height: 128px;
}
.info {
padding: calc(var(--spacer) / 2);
color: var(--color-secondary);
}
.info h5 {
margin-bottom: 0;
}
.address {
word-break: break-all;
}
.address button::after {
word-break: normal;
}
.links {
margin-top: calc(var(--spacer) / 3);
}
.links a {
display: block;
}
.fallback {
padding-top: calc(var(--spacer) / 3);
font-style: italic;
}

View File

@ -0,0 +1,79 @@
import Copy from '@shared/atoms/Copy'
import External from '@images/external.svg'
import ExplorerLink from '@shared/ExplorerLink'
import { NftMetadata } from '@utils/nft'
import React, { ReactElement } from 'react'
import styles from './NftTooltip.module.css'
import explorerLinkStyles from '@shared/ExplorerLink/index.module.css'
import { accountTruncate } from '@utils/web3'
export default function NftTooltip({
nft,
address,
chainId,
isBlockscoutExplorer
}: {
nft: NftMetadata
address: string
chainId: number
isBlockscoutExplorer: boolean
}): ReactElement {
// Currently Ocean NFTs are not displayed correctly on OpenSea
// Code prepared to easily integrate this feature once this is fixed
//
// Supported OpeanSea networks:
// https://support.opensea.io/hc/en-us/articles/4404027708051-Which-blockchains-does-OpenSea-support-
const openseaNetworks = [1, 137]
const openseaTestNetworks = [4]
const openSeaSupported = openseaNetworks
.concat(openseaTestNetworks)
.includes(chainId)
const openSeaBaseUri = openSeaSupported
? openseaTestNetworks.includes(chainId)
? 'https://testnets.opensea.io'
: 'https://opensea.io'
: undefined
return (
<div className={styles.wrapper}>
{nft && <img src={nft.image_data} alt={nft?.name} />}
<div className={styles.info}>
{nft && <h5>{nft.name}</h5>}
{address && (
<span title={address} className={styles.address}>
{accountTruncate(address)} <Copy text={address} />
</span>
)}
<div className={styles.links}>
{address && (
<ExplorerLink
networkId={chainId}
path={
isBlockscoutExplorer ? `tokens/${address}` : `token/${address}`
}
>
View on explorer
</ExplorerLink>
)}
{openSeaSupported && nft && address && (
<a
href={`${openSeaBaseUri}/assets/${address}/1`}
target="_blank"
rel="noreferrer"
className={explorerLinkStyles.link}
>
View on OpeanSea <External />
</a>
)}
</div>
{!nft?.image_data && (
<p className={styles.fallback}>
This Data NFT was not created on Ocean Market
</p>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,44 @@
.meta {
margin-left: calc(var(--spacer) * -1);
margin-right: calc(var(--spacer) * -1);
margin-bottom: calc(var(--spacer) / 1.5);
color: var(--color-secondary);
font-size: var(--font-size-small);
}
.asset {
display: flex;
justify-content: flex-start;
align-items: flex-start;
height: calc(var(--spacer) * 2);
border-bottom: 1px solid var(--border-color);
}
.nftImage {
position: relative;
margin: 0;
border-right: 1px solid var(--border-color);
width: calc(var(--spacer) * 2);
height: calc(var(--spacer) * 2);
}
.nftImage img,
.nftImage > svg:first-of-type {
width: 100%;
height: 100%;
}
.nftImage > svg:first-of-type {
transform: scale(0.7);
}
.nftImage .tooltip {
position: absolute;
padding: 0;
left: 0;
bottom: 0;
}
.nftImage .tooltip svg {
fill: var(--font-color-text);
}

View File

@ -0,0 +1,53 @@
import React, { ReactElement } from 'react'
import styles from './index.module.css'
import { Asset } from '@oceanprotocol/lib'
import { decodeTokenURI } from '@utils/nft'
import MetaAsset from './MetaAsset'
import MetaInfo from './MetaInfo'
import Tooltip from '@shared/atoms/Tooltip'
import NftTooltip from './NftTooltip'
import Logo from '@shared/atoms/Logo'
export default function MetaMain({
asset,
nftPublisher
}: {
asset: Asset
nftPublisher: string
}): ReactElement {
const nftMetadata = decodeTokenURI(asset?.nft?.tokenURI)
const blockscoutNetworks = [1287, 2021000, 2021001, 44787, 246, 1285]
const isBlockscoutExplorer = blockscoutNetworks.includes(asset?.chainId)
return (
<aside className={styles.meta}>
<header className={styles.asset}>
<div className={styles.nftImage}>
{nftMetadata?.image_data ? (
<img src={nftMetadata?.image_data} alt={asset?.nft?.name} />
) : (
<Logo noWordmark />
)}
{(nftMetadata || asset?.nftAddress) && (
<Tooltip
className={styles.tooltip}
content={
<NftTooltip
nft={nftMetadata}
address={asset?.nftAddress}
chainId={asset?.chainId}
isBlockscoutExplorer={isBlockscoutExplorer}
/>
}
/>
)}
</div>
<MetaAsset asset={asset} isBlockscoutExplorer={isBlockscoutExplorer} />
</header>
<MetaInfo asset={asset} nftPublisher={nftPublisher} />
</aside>
)
}

View File

@ -16,6 +16,7 @@
.content {
composes: box from '@shared/atoms/Box.module.css';
padding-top: 0;
margin-top: var(--spacer);
position: relative;
}

View File

@ -16,16 +16,27 @@ import NetworkName from '@shared/NetworkName'
import content from '../../../../content/purgatory.json'
import { AssetExtended } from 'src/@types/AssetExtended'
import { useWeb3 } from '@context/Web3'
import Web3 from 'web3'
export default function AssetContent({
asset
}: {
asset: AssetExtended
}): ReactElement {
const { debug } = useUserPreferences()
const [isOwner, setIsOwner] = useState(false)
const { accountId } = useWeb3()
const { isInPurgatory, purgatoryData, owner, isAssetNetwork } = useAsset()
const { debug } = useUserPreferences()
const [receipts, setReceipts] = useState([])
const [nftPublisher, setNftPublisher] = useState<string>()
useEffect(() => {
setNftPublisher(
Web3.utils.toChecksumAddress(
receipts?.find((e) => e.type === 'METADATA_CREATED')?.nft?.owner
)
)
}, [receipts])
useEffect(() => {
if (!accountId || !owner) return
@ -43,11 +54,10 @@ export default function AssetContent({
<article className={styles.grid}>
<div>
<div className={styles.content}>
<MetaMain ddo={asset} />
<MetaMain asset={asset} nftPublisher={nftPublisher} />
{asset?.accessDetails?.datatoken !== null && (
<Bookmark did={asset?.id} />
)}
{isInPurgatory === true ? (
<Alert
title={content.asset.title}
@ -64,9 +74,8 @@ export default function AssetContent({
<MetaSecondary ddo={asset} />
</>
)}
<MetaFull ddo={asset} />
<EditHistory />
<EditHistory receipts={receipts} setReceipts={setReceipts} />
{debug === true && <DebugOutput title="DDO" output={asset} />}
</div>
</div>

View File

@ -181,6 +181,7 @@ export async function transformPublishFormToDdo(
}
],
nft: {
...generateNftCreateData(values?.metadata.nft),
owner: accountId
}
})