diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx index d6b1030a..c12026fe 100644 --- a/app/address/[address]/layout.tsx +++ b/app/address/[address]/layout.tsx @@ -40,10 +40,11 @@ import { FEATURE_PROGRAM_ID } from '@utils/parseFeatureAccount'; import { useClusterPath } from '@utils/url'; import Link from 'next/link'; import { redirect, useSelectedLayoutSegment } from 'next/navigation'; -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, Suspense } from 'react'; import useSWRImmutable from 'swr/immutable'; import { Base58EncodedAddress } from 'web3js-experimental'; +import { CompressedNftAccountHeader, CompressedNftCard } from '@/app/components/account/CompressedNftCard'; import { FullTokenInfo, getFullTokenInfo } from '@/app/utils/token-info'; const IDENTICON_WIDTH = 64; @@ -192,7 +193,9 @@ function AddressLayoutInner({ children, params: { address } }: Props) { const infoParsed = info?.data?.data.parsed; const { data: fullTokenInfo, isLoading: isFullTokenInfoLoading } = useSWRImmutable( - infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && pubkey ? ['get-full-token-info', address, cluster, url] : null, + infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && pubkey + ? ['get-full-token-info', address, cluster, url] + : null, fetchFullTokenInfo ); @@ -207,13 +210,23 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
- +
{!pubkey ? ( ) : ( - + {children} )} @@ -229,7 +242,17 @@ export default function AddressLayout({ children, params }: Props) { ); } -function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { address: string; account?: Account, tokenInfo?: FullTokenInfo, isTokenInfoLoading: boolean }) { +function AccountHeader({ + address, + account, + tokenInfo, + isTokenInfoLoading, +}: { + address: string; + account?: Account; + tokenInfo?: FullTokenInfo; + isTokenInfoLoading: boolean; +}) { const mintInfo = useMintAccountInfo(address); const parsedData = account?.data.parsed; @@ -300,6 +323,10 @@ function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { ad ); } + if (account) { + return ; + } + return ( <>
Details
@@ -314,7 +341,7 @@ function DetailsSections({ tab, info, tokenInfo, - isTokenInfoLoading + isTokenInfoLoading, }: { children: React.ReactNode; pubkey: PublicKey; @@ -348,7 +375,7 @@ function DetailsSections({ ); } -function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: FullTokenInfo }) { +function InfoSection({ account, tokenInfo }: { account: Account; tokenInfo?: FullTokenInfo }) { const parsedData = account.data.parsed; const rawData = account.data.raw; @@ -392,7 +419,11 @@ function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: Ful } else if (account.owner.toBase58() === FEATURE_PROGRAM_ID) { return ; } else { - return ; + return ( + }> + + + ); } } @@ -467,12 +498,19 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] { } // Add the key for address lookup tables - if (account.data.raw && isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw)) { + if ( + account.data.raw && + isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw) + ) { tabs.push(...TABS_LOOKUP['address-lookup-table']); } // Add the key for Metaplex NFTs - if (parsedData && (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') && (parsedData as TokenProgramData).nftData) { + if ( + parsedData && + (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') && + (parsedData as TokenProgramData).nftData + ) { tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]); } diff --git a/app/components/account/CompressedNftCard.tsx b/app/components/account/CompressedNftCard.tsx new file mode 100644 index 00000000..a9bd9459 --- /dev/null +++ b/app/components/account/CompressedNftCard.tsx @@ -0,0 +1,169 @@ +import { Account } from '@providers/accounts'; +import { PublicKey } from '@solana/web3.js'; +import { createRef, Suspense } from 'react'; +import { ChevronDown, ExternalLink } from 'react-feather'; +import useAsyncEffect from 'use-async-effect'; + +import { useCluster } from '@/app/providers/cluster'; +import { CompressedNft, useCompressedNft, useMetadataJsonLink } from '@/app/providers/compressed-nft'; + +import { Address } from '../common/Address'; +import { InfoTooltip } from '../common/InfoTooltip'; +import { LoadingArtPlaceholder } from '../common/LoadingArtPlaceholder'; +import { ArtContent } from '../common/NFTArt'; +import { TableCardBody } from '../common/TableCardBody'; +import { getCreatorDropdownItems, getIsMutablePill, getVerifiedCollectionPill } from './MetaplexNFTHeader'; +import { UnknownAccountCard } from './UnknownAccountCard'; + +export function CompressedNftCard({ account }: { account: Account }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account.pubkey.toString(), url }); + if (!compressedNft) return ; + + const collectionGroup = compressedNft.grouping.find(group => group.group_key === 'collection'); + const updateAuthority = compressedNft.authorities.find(authority => authority.scopes.includes('full'))?.address; + + return ( +
+
+

Overview

+
+ + + Address + +
+ + + + Verified Collection Address + + {collectionGroup ? ( +
+ ) : ( + 'None' + )} + + + + Update Authority + + {updateAuthority ?
: 'None'} + + + + Website + + + {compressedNft.content.links.external_url} + + + + + + Seller Fee + {`${compressedNft.royalty.basis_points / 100}%`} + + +
+ ); +} + +function NoHeader() { + return ( + <> +
Details
+

Account

+ + ); +} + +export function CompressedNftAccountHeader({ account }: { account: Account }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account.pubkey.toString(), url }); + if (!compressedNft) return NoHeader(); + + // LoadingCard + if (compressedNft) { + return ( + }> + + + ); + } + + return NoHeader(); +} +export function CompressedNFTHeader({ compressedNft }: { compressedNft: CompressedNft }) { + const metadataJson = useMetadataJsonLink(compressedNft.content.json_uri); + const dropdownRef = createRef(); + + useAsyncEffect( + async isMounted => { + if (!dropdownRef.current) { + return; + } + const Dropdown = (await import('bootstrap/js/dist/dropdown')).default; + if (!isMounted || !dropdownRef.current) { + return; + } + return new Dropdown(dropdownRef.current); + }, + dropdown => { + if (dropdown) { + dropdown.dispose(); + } + }, + [dropdownRef] + ); + + return ( +
+
+ +
+
+ {
Metaplex Compressed NFT
} +
+

+ {compressedNft.content.metadata.name !== '' + ? compressedNft.content.metadata.name + : 'No NFT name was found'} +

+ {getVerifiedCollectionPill()} +
+

+ {compressedNft.content.metadata.symbol !== '' + ? compressedNft.content.metadata.symbol + : 'No Symbol was found'} +

+
{getCompressedNftPill()}
+
{getIsMutablePill(compressedNft.mutable)}
+
+ +
{getCreatorDropdownItems(compressedNft.creators)}
+
+
+
+ ); +} + +function getCompressedNftPill() { + const onchainVerifiedToolTip = + 'This NFT does not have on-chain data, but can be transferred and traded on-chain normally. This tag guarantees that this compressed NFT has the correct format.'; + return ( +
+ {'Compressed'} + +
+ ); +} diff --git a/app/components/account/MetaplexNFTHeader.tsx b/app/components/account/MetaplexNFTHeader.tsx index c1e8e27b..aa6350c4 100644 --- a/app/components/account/MetaplexNFTHeader.tsx +++ b/app/components/account/MetaplexNFTHeader.tsx @@ -82,7 +82,7 @@ export function MetaplexNFTHeader({ nftData, address }: { nftData: NFTData; addr } type Creator = programs.metadata.Creator; -function getCreatorDropdownItems(creators: Creator[] | null) { +export function getCreatorDropdownItems(creators: Creator[] | null) { const CreatorHeader = () => { const creatorTooltip = 'Verified creators signed the metadata associated with this NFT when it was created.'; @@ -170,11 +170,11 @@ function getSaleTypePill(hasPrimarySaleHappened: boolean) { ); } -function getIsMutablePill(isMutable: boolean) { +export function getIsMutablePill(isMutable: boolean) { return {`${isMutable ? 'Mutable' : 'Immutable'}`}; } -function getVerifiedCollectionPill() { +export function getVerifiedCollectionPill() { const onchainVerifiedToolTip = 'This NFT has been verified as a member of an on-chain collection. This tag guarantees authenticity.'; return ( diff --git a/app/providers/accounts/index.tsx b/app/providers/accounts/index.tsx index 4fa4e639..c85fdeef 100644 --- a/app/providers/accounts/index.tsx +++ b/app/providers/accounts/index.tsx @@ -62,7 +62,7 @@ export function isTokenProgramData(data: { program: string }): data is TokenProg try { assertIsTokenProgram(data.program); return true; - } catch(e) { + } catch (e) { return false; } } diff --git a/app/providers/compressed-nft.tsx b/app/providers/compressed-nft.tsx new file mode 100644 index 00000000..ecfc2d0e --- /dev/null +++ b/app/providers/compressed-nft.tsx @@ -0,0 +1,209 @@ +type CacheType

= Record< + string, + void | { __type: 'promise'; promise: Promise } | { __type: 'result'; result: P } +>; + +const cachedNftPromises: CacheType = {}; + +const cachedPromises = { + compressedNft: {} as CacheType, + nftMetadataJson: {} as CacheType, +}; + +function makeCache( + cacheName: keyof typeof cachedPromises, + keygen: (params: Params) => string, + action: (params: Params) => Promise +): (params: Params) => CacheValueType { + return (params: Params) => { + const key = keygen(params); + const cacheEntry = cachedPromises[cacheName][key]; + + if (cacheEntry === undefined) { + const promise = action(params) + .then((value: CacheValueType) => { + cachedPromises[cacheName][key] = { + __type: 'result', + result: value, + }; + }) + .catch(_ => { + cachedNftPromises[key] = { __type: 'result', result: null }; + }); + cachedPromises[cacheName][key] = { + __type: 'promise', + promise, + }; + throw promise; + } else if (cacheEntry.__type === 'promise') { + throw cacheEntry.promise; + } + return cacheEntry.result; + }; +} + +export const useMetadataJsonLink = makeCache( + 'nftMetadataJson', + (url: string) => url, + async (url: string) => { + return fetch(url).then(response => response.json()); + } +); + +export const useCompressedNft = makeCache<{ address: string; url: string }, CompressedNft | null>( + 'compressedNft', + ({ address, url }) => `${address}-${url}`, + async ({ address, url }) => { + return fetch(`${url}`, { + body: JSON.stringify({ + id: address, + jsonrpc: '2.0', + method: 'getAsset', + params: { + id: address, + }, + }), + method: 'POST', + }) + .then(response => response.json()) + .then((response: DasApiResponse) => { + if ('error' in response) { + throw new Error(response.error.message); + } + + return response.result as CompressedNft; + }); + } +); + +// export function useCompressedNft(address: string, url: string): CompressedNft | null { +// const key = `${address}-${url}`; +// const cacheEntry = cachedNftPromises[key]; + +// if (cacheEntry === undefined) { +// const promise = fetch(`${url}`, { +// body: JSON.stringify({ +// id: address, +// jsonrpc: '2.0', +// method: 'getAsset', +// params: { +// id: address, +// }, +// }), +// method: 'POST', +// }) +// .then(response => response.json()) +// .then((response: DasApiResponse) => { +// if ('error' in response) { +// throw new Error(response.error.message); +// } + +// const assetInfo: CompressedNft = response.result; +// cachedNftPromises[key] = { +// __type: 'result', +// result: assetInfo, +// }; +// }) +// .catch(_ => { +// cachedNftPromises[key] = { __type: 'result', result: null }; +// }); +// cachedNftPromises[key] = { +// __type: 'promise', +// promise, +// }; +// throw promise; +// } else if (cacheEntry.__type === 'promise') { +// throw cacheEntry.promise; +// } +// return cacheEntry.result; +// } + +export type DasApiResponse = + | { + jsonrpc: string; + id: string; + result: CompressedNft; + } + | { + jsonrpc: string; + id: string; + error: { + code: number; + message: string; + }; + }; + +export type CompressedNft = { + interface: string; + id: string; + content: { + $schema: string; + json_uri: string; + files: { + uri: string; + cdn_uri: string; + mime: string; + }[]; + metadata: { + attributes: { + value: string; + trait_type: string; + }[]; + description: string; + name: string; + symbol: string; + token_standard: string; + }; + links: { + external_url: string; + image: string; + }; + }; + authorities: { + address: string; + scopes: string[]; + }[]; + compression: { + eligible: boolean; + compressed: boolean; + data_hash: string; + creator_hash: string; + asset_hash: string; + tree: string; + seq: number; + leaf_id: number; + }; + grouping: { + group_key: string; + group_value: string; + }[]; + royalty: { + royalty_model: string; + target: null; + percent: number; + basis_points: number; + primary_sale_happened: boolean; + locked: boolean; + }; + creators: [ + { + address: string; + share: number; + verified: boolean; + } + ]; + ownership: { + frozen: boolean; + delegated: boolean; + delegate: string | null; + ownership_model: string; + owner: string; + }; + supply: { + print_max_supply: number; + print_current_supply: number; + edition_nonce: number | null; + }; + mutable: boolean; + burnt: boolean; +};