diff --git a/packages/constants/src/payload/payload.types.ts b/packages/constants/src/payload/payload.types.ts index 9ee9c6349..6d184f29a 100644 --- a/packages/constants/src/payload/payload.types.ts +++ b/packages/constants/src/payload/payload.types.ts @@ -12,7 +12,7 @@ import { SetAccountFlags, CreateOfferFlags } from '../xrpl/basic.types'; -import { AccountNFToken } from './../xrpl/nft.types'; +import { AccountNFToken, AccountNFTokenResponse, NFTokenIDResponse } from './../xrpl/nft.types'; /* * Request Payloads @@ -323,18 +323,13 @@ export interface SetTrustlineResponseDeprecated { hash: string | null | undefined; } -export interface GetNFTResponse - extends BaseResponse<{ account_nfts: AccountNFToken[]; marker?: unknown }> {} +export interface GetNFTResponse extends BaseResponse {} export interface GetNFTResponseDeprecated { nfts: AccountNFToken[] | null | undefined; } -export interface MintNFTResponse - extends BaseResponse<{ - NFTokenID: string; - hash: string; - }> {} +export interface MintNFTResponse extends BaseResponse {} export interface CreateNFTOfferResponse extends BaseResponse<{ diff --git a/packages/constants/src/xrpl/nft.constant.ts b/packages/constants/src/xrpl/nft.constant.ts new file mode 100644 index 000000000..7beb48ec9 --- /dev/null +++ b/packages/constants/src/xrpl/nft.constant.ts @@ -0,0 +1 @@ +export const IPFSResolverPrefix = 'https://ipfs.io/ipfs/'; diff --git a/packages/constants/src/xrpl/nft.types.ts b/packages/constants/src/xrpl/nft.types.ts index cc92fdcae..87a43808b 100644 --- a/packages/constants/src/xrpl/nft.types.ts +++ b/packages/constants/src/xrpl/nft.types.ts @@ -6,3 +6,28 @@ export interface AccountNFToken { URI?: string; nft_serial: number; } + +export interface AccountNFTokenResponse { + account_nfts: AccountNFToken[]; + marker?: unknown; +} + +export interface NFTData { + NFTokenID: string; + + // XLS-24 standard fields + nftType?: string; + schema?: string; + name?: string; + description?: string; + image?: string; + collection?: { + name?: string; + family?: string; + }; +} + +export interface NFTokenIDResponse { + hash: string; + NFTokenID: string; +} diff --git a/packages/extension/cypress/e2e/NFT.cy.ts b/packages/extension/cypress/e2e/NFT.cy.ts index a469277a6..e0621912f 100644 --- a/packages/extension/cypress/e2e/NFT.cy.ts +++ b/packages/extension/cypress/e2e/NFT.cy.ts @@ -7,7 +7,7 @@ import { formatFlags } from '../../src/utils'; describe('Mint', () => { // deepcode ignore NoHardcodedPasswords: password used for testing purposes const PASSWORD = 'SECRET_PASSWORD'; - const MINT_NFT_URL = `http://localhost:3000?mint-nft?URI=4d696e746564207468726f7567682047656d57616c6c657421&flags=%7B%22tfOnlyXRP%22%3Afalse%2C%22tfTransferable%22%3Atrue%7D&fee=199&transferFee=3000&NFTokenTaxon=0&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210324818&requestMessage=undefined&transaction=mintNFT`; + const MINT_NFT_URL = `http://localhost:3000?URI=4d696e746564207468726f7567682047656d57616c6c657421&flags=%7B%22tfOnlyXRP%22%3Afalse%2C%22tfTransferable%22%3Atrue%7D&fee=199&transferFee=3000&NFTokenTaxon=0&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210324818&requestMessage=undefined&transaction=mintNFT`; beforeEach(() => { // Mock the localStorage with a wallet already loaded @@ -54,7 +54,25 @@ describe('Mint', () => { cy.get('p[data-testid="transaction-subtitle"]').should('have.text', 'Transaction Successful'); }); - it('Read the minted NFT Token ID', () => { + it('View a NFT in the NFT Viewer', () => { + navigate('localhost:3000', PASSWORD); + + // Wait for the wallet to load + cy.get('[data-testid="token-loader"]').should('not.exist'); + + // Go to NFT Viewer + cy.contains('button', 'NFTs').click(); + + // Find a NFT and open the details + cy.get('[data-testid="OpenInNewOutlinedIcon"]').parent('button').first().click(); + + // Check that the details view is open + cy.contains('NFT Details'); + cy.contains('Token ID'); + cy.contains('Description'); + }); + + it('Read the minted NFT Token ID from the transaction history', () => { navigate('localhost:3000', PASSWORD); // Wait for the wallet to load @@ -76,7 +94,7 @@ describe('Mint', () => { }); it('Create NFT Offer (50 XRP)', function () { - const url = `http://localhost:3000?create-nft-offer&amount=50000000&NFTokenID=${this.NFTokenID}&fee=199&flags=%7B%22tfSellNFToken%22%3Atrue%7D&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325582&requestMessage=undefined&transaction=createNFTOffer`; + const url = `http://localhost:3000?amount=50000000&NFTokenID=${this.NFTokenID}&fee=199&flags=%7B%22tfSellNFToken%22%3Atrue%7D&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325582&requestMessage=undefined&transaction=createNFTOffer`; navigate(url, PASSWORD); // Confirm @@ -196,7 +214,7 @@ describe('Mint', () => { }); it('Accept NFT Offer', function () { - const url = `http://localhost:3000?accept-nft-offer&NFTokenSellOffer=${this.OfferID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=acceptNFTOffer`; + const url = `http://localhost:3000?NFTokenSellOffer=${this.OfferID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=acceptNFTOffer`; navigate(url, PASSWORD); @@ -227,7 +245,7 @@ describe('Mint', () => { issuer: 'rHZwvHEs56GCmHupwjA4RY7oPA3EoAJWuN', value: '0.1' }); - const url = `http://localhost:3000?accept-nft-offer&NFTokenBrokerFee=${amount}&NFTokenSellOffer=${this.OfferID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=acceptNFTOffer`; + const url = `http://localhost:3000?NFTokenBrokerFee=${amount}&NFTokenSellOffer=${this.OfferID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=acceptNFTOffer`; navigate(url, PASSWORD); @@ -254,7 +272,7 @@ describe('Mint', () => { issuer: 'rHZwvHEs56GCmHupwjA4RY7oPA3EoAJWuN', value: '0.1' }); - const url = `http://localhost:3000?accept-nft-offer&NFTokenBrokerFee=${amount}&NFTokenSellOffer=${this.OfferID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=acceptNFTOffer`; + const url = `http://localhost:3000?NFTokenBrokerFee=${amount}&NFTokenSellOffer=${this.OfferID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=acceptNFTOffer`; navigate(url, PASSWORD); @@ -276,7 +294,7 @@ describe('Mint', () => { }); it('Cancel NFT Offer', function () { - const url = `http://localhost:3000?cancel-nft-offer&NFTokenOffers=%5B%22${this.OfferID}%22%5D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=cancelNFTOffer`; + const url = `http://localhost:3000?NFTokenOffers=%5B%22${this.OfferID}%22%5D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=cancelNFTOffer`; navigate(url, PASSWORD); // Confirm @@ -300,7 +318,7 @@ describe('Mint', () => { }); it('Burn NFT', function () { - const url = `http://localhost:3000?burn-nft&NFTokenID=${this.NFTokenID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=burnNFT`; + const url = `http://localhost:3000?NFTokenID=${this.NFTokenID}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210325959&requestMessage=undefined&transaction=burnNFT`; navigate(url, PASSWORD); // Confirm diff --git a/packages/extension/cypress/e2e/offers.cy.ts b/packages/extension/cypress/e2e/offers.cy.ts index 6e28d3c65..92f43ad3f 100644 --- a/packages/extension/cypress/e2e/offers.cy.ts +++ b/packages/extension/cypress/e2e/offers.cy.ts @@ -21,7 +21,7 @@ describe('Offers', () => { }); it('Create offer (XRP to ETH)', () => { - const url = `http://localhost:3000?create-offer&takerGets=10000000&takerPays=%7B%22currency%22%3A%22ETH%22%2C%22issuer%22%3A%22rnm76Qgz4G9G4gZBJVuXVvkbt7gVD7szey%22%2C%22value%22%3A%220.1%22%7D&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; + const url = `http://localhost:3000?takerGets=10000000&takerPays=%7B%22currency%22%3A%22ETH%22%2C%22issuer%22%3A%22rnm76Qgz4G9G4gZBJVuXVvkbt7gVD7szey%22%2C%22value%22%3A%220.1%22%7D&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; navigate(url, PASSWORD); cy.get('h1[data-testid="page-title"]').should('have.text', 'Create Offer'); @@ -54,7 +54,7 @@ describe('Offers', () => { value: '0.1' }); - const url = `http://localhost:3000?create-offer&takerGets=10000000&takerPays=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; + const url = `http://localhost:3000?takerGets=10000000&takerPays=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; navigate(url, PASSWORD); cy.get('h1[data-testid="page-title"]').should('have.text', 'Create Offer'); @@ -87,7 +87,7 @@ describe('Offers', () => { value: '0.1' }); - const url = `http://localhost:3000?create-offer&takerPays=10000000&takerGets=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; + const url = `http://localhost:3000?takerPays=10000000&takerGets=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; navigate(url, PASSWORD); cy.get('h1[data-testid="page-title"]').should('have.text', 'Create Offer'); @@ -120,7 +120,7 @@ describe('Offers', () => { value: '0.1' }); - const url = `http://localhost:3000?create-offer&takerGets=10000000&takerPays=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; + const url = `http://localhost:3000?takerGets=10000000&takerPays=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; navigate(url, PASSWORD); cy.get('h1[data-testid="page-title"]').should('have.text', 'Create Offer'); @@ -153,7 +153,7 @@ describe('Offers', () => { value: '0.1' }); - const url = `http://localhost:3000?create-offer&takerPays=10000000&takerGets=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; + const url = `http://localhost:3000?takerPays=10000000&takerGets=${amount}&flags=%7B%22tfPassive%22%3Atrue%7D&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328024&requestMessage=undefined&transaction=createOffer`; navigate(url, PASSWORD); cy.get('h1[data-testid="page-title"]').should('have.text', 'Create Offer'); @@ -202,7 +202,7 @@ describe('Offers', () => { it('Cancel offer', function () { navigate( - `http://localhost:3000?cancel-offer&offerSequence=${this.sequence}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328126&requestMessage=undefined&transaction=cancelOffer`, + `http://localhost:3000?offerSequence=${this.sequence}&fee=199&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210328126&requestMessage=undefined&transaction=cancelOffer`, PASSWORD ); diff --git a/packages/extension/cypress/e2e/set_account.cy.ts b/packages/extension/cypress/e2e/set_account.cy.ts index a00cd7f88..993cee940 100644 --- a/packages/extension/cypress/e2e/set_account.cy.ts +++ b/packages/extension/cypress/e2e/set_account.cy.ts @@ -22,7 +22,7 @@ describe('Set Account', () => { it('Set Account', () => { cy.visit( - `http://localhost:3000?set-account&emailHash=1D1382344586ECFF844DACFF698C2EFB&fee=199&flags=%7B%22tfAllowXRP%22%3Atrue%7D&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210327555&requestMessage=undefined&transaction=setAccount`, + `http://localhost:3000?emailHash=1D1382344586ECFF844DACFF698C2EFB&fee=199&flags=%7B%22tfAllowXRP%22%3Atrue%7D&memos=%5B%7B%22memo%22%3A%7B%22memoType%22%3A%224465736372697074696f6e%22%2C%22memoData%22%3A%2254657374206d656d6f%22%7D%7D%5D&id=210327555&requestMessage=undefined&transaction=setAccount`, { onBeforeLoad(win) { (win as any).chrome = (win as any).chrome || {}; diff --git a/packages/extension/cypress/e2e/submit_transaction.cy.ts b/packages/extension/cypress/e2e/submit_transaction.cy.ts index 3e37ab6b4..e4c1299cd 100644 --- a/packages/extension/cypress/e2e/submit_transaction.cy.ts +++ b/packages/extension/cypress/e2e/submit_transaction.cy.ts @@ -21,7 +21,7 @@ describe('Submit Transaction', () => { }); it('Submit Transaction', () => { - const url = `http://localhost:3000?submit-transaction&transaction=%7B%22TransactionType%22%3A%22Payment%22%2C%22Destination%22%3A%22rhikRdkFw28csKw9z7fVoBjWncz1HSoQij%22%2C%22Amount%22%3A%22100000%22%7D&id=210329246&requestMessage=undefined&submit=transaction`; + const url = `http://localhost:3000?transaction=%7B%22TransactionType%22%3A%22Payment%22%2C%22Destination%22%3A%22rhikRdkFw28csKw9z7fVoBjWncz1HSoQij%22%2C%22Amount%22%3A%22100000%22%7D&id=210329246&requestMessage=undefined&submit=transaction`; navigate(url, PASSWORD); cy.get('h1[data-testid="page-title"]').should('have.text', 'Confirm Transaction'); @@ -66,7 +66,7 @@ describe('Submit Transaction', () => { ], Fee: '199' }); - const url = `http://localhost:3000?submit-transaction&transaction=${transaction}&requestMessage=undefined&submit=transaction`; + const url = `http://localhost:3000?transaction=${transaction}&requestMessage=undefined&submit=transaction`; navigate(url, PASSWORD); cy.get('h1[data-testid="page-title"]').should('have.text', 'Confirm Transaction'); diff --git a/packages/extension/cypress/e2e/wallet_management.cy.ts b/packages/extension/cypress/e2e/wallet_management.cy.ts index ab8da98cf..050fe33a2 100644 --- a/packages/extension/cypress/e2e/wallet_management.cy.ts +++ b/packages/extension/cypress/e2e/wallet_management.cy.ts @@ -477,7 +477,7 @@ describe('Edit wallet', () => { // Lock the extension cy.get('button[aria-label="close"]').click(); - cy.contains('button', 'Wallets').parent().children().eq(2).click(); + cy.contains('button', 'Wallets').parent().children().eq(3).click(); cy.contains('button', 'Lock').click(); // Check if the wallets are properly loaded with the name changed @@ -600,7 +600,7 @@ describe('Switch wallet', () => { cy.contains('Wallet 3').should('be.visible'); // Lock the extension - cy.contains('button', 'Wallets').parent().children().eq(2).click(); + cy.contains('button', 'Wallets').parent().children().eq(3).click(); cy.contains('button', 'Lock').click(); // Check if the default wallet is properly loaded diff --git a/packages/extension/package.json b/packages/extension/package.json index 259523ff6..2bff34868 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -19,7 +19,9 @@ "react": "^17.0.2", "react-content-loader": "^6.2.0", "react-dom": "^17.0.2", + "react-infinite-scroll-component": "^6.1.0", "react-json-view": "^1.21.3", + "react-lazy-load-image-component": "^1.6.0", "react-router-dom": "6", "react-scripts": "4.0.3", "ripple-keypairs": "^1.1.5", @@ -41,6 +43,7 @@ "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/react-lazy-load-image-component": "^1.5.2", "cypress": "^12.9.0", "jest-canvas-mock": "^2.4.0", "ts-loader": "~8.2.0" diff --git a/packages/extension/src/components/atoms/NFTImage/NFTImage.tsx b/packages/extension/src/components/atoms/NFTImage/NFTImage.tsx new file mode 100644 index 000000000..2742eb105 --- /dev/null +++ b/packages/extension/src/components/atoms/NFTImage/NFTImage.tsx @@ -0,0 +1,45 @@ +import { CSSProperties, FC } from 'react'; + +import { CircularProgress } from '@mui/material'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; + +import { GemWallet } from '../index'; + +interface NFTImageProps { + imageURL?: string; + height?: number; + width?: number; + style?: CSSProperties; +} + +export const NFTImage: FC = ({ imageURL, height = 250, width = 250, style }) => { + return imageURL ? ( +
+ } + effect="blur" + src={imageURL} + width={width} + /> +
+ ) : ( +
+ +
+ ); +}; diff --git a/packages/extension/src/components/atoms/NFTImage/index.ts b/packages/extension/src/components/atoms/NFTImage/index.ts new file mode 100644 index 000000000..2e98427a0 --- /dev/null +++ b/packages/extension/src/components/atoms/NFTImage/index.ts @@ -0,0 +1 @@ +export * from './NFTImage'; diff --git a/packages/extension/src/components/atoms/Tokens/GemWallet/GemWallet.tsx b/packages/extension/src/components/atoms/Tokens/GemWallet/GemWallet.tsx index 9492f81ec..86e06520e 100644 --- a/packages/extension/src/components/atoms/Tokens/GemWallet/GemWallet.tsx +++ b/packages/extension/src/components/atoms/Tokens/GemWallet/GemWallet.tsx @@ -1,6 +1,6 @@ -import { FC } from 'react'; +import { FC, SVGProps } from 'react'; -export const GemWallet: FC = (props) => ( +export const GemWallet: FC> = (props) => ( diff --git a/packages/extension/src/components/atoms/TruncatedText/TruncatedText.tsx b/packages/extension/src/components/atoms/TruncatedText/TruncatedText.tsx new file mode 100644 index 000000000..ab1232d50 --- /dev/null +++ b/packages/extension/src/components/atoms/TruncatedText/TruncatedText.tsx @@ -0,0 +1,48 @@ +import { ComponentProps, FC, useMemo } from 'react'; + +import { SxProps, Theme, Typography } from '@mui/material'; + +type TruncatedTextProps = ComponentProps & { + isMultiline?: boolean; + maxLines?: string; +}; + +export const TruncatedText: FC = ({ + isMultiline = false, + maxLines = '4', + sx, + children, + ...rest +}) => { + const styleProps: SxProps | undefined = useMemo( + () => + isMultiline + ? { + overflow: 'hidden', + display: '-webkit-box', + width: '100%', // if the string is very long without spaces, it will not overflow the view + WebkitBoxOrient: 'vertical', + WebkitLineClamp: maxLines, // start showing ellipsis when X line is reached + whiteSpace: 'pre-wrap' // let the text wrap preserving spaces + } + : { + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + width: '100%' + }, + [isMultiline, maxLines] + ); + + return ( + + {children} + + ); +}; diff --git a/packages/extension/src/components/atoms/TruncatedText/index.ts b/packages/extension/src/components/atoms/TruncatedText/index.ts new file mode 100644 index 000000000..499f12bd7 --- /dev/null +++ b/packages/extension/src/components/atoms/TruncatedText/index.ts @@ -0,0 +1 @@ +export * from './TruncatedText'; diff --git a/packages/extension/src/components/atoms/index.ts b/packages/extension/src/components/atoms/index.ts index 7dcdd78f9..3005b574c 100644 --- a/packages/extension/src/components/atoms/index.ts +++ b/packages/extension/src/components/atoms/index.ts @@ -1,8 +1,10 @@ export * from './ButtonOption'; export * from './Logo'; +export * from './NFTImage'; export * from './NumericInput'; export * from './PrivateRoute'; export * from './TileLoader'; export * from './TokenLoader'; export * from './Tokens'; +export * from './TruncatedText'; export * from './WalletIcon'; diff --git a/packages/extension/src/components/molecules/NFTCard/NFTCard.test.tsx b/packages/extension/src/components/molecules/NFTCard/NFTCard.test.tsx new file mode 100644 index 000000000..dd16bd407 --- /dev/null +++ b/packages/extension/src/components/molecules/NFTCard/NFTCard.test.tsx @@ -0,0 +1,82 @@ +import { FC, ReactNode } from 'react'; + +import { render, screen, waitFor, act } from '@testing-library/react'; + +import { LedgerContext } from '../../../contexts'; +import { valueLedgerContext } from '../../../mocks'; +import { NFTCard, NFTCardProps } from './NFTCard'; + +const mockNFT = { + Flags: 11, + Issuer: 'rDGh681kc6V1GKkQB378XhiM1tzkYrnFwQ', + NFTokenID: '000B0000867AD7165A812436FBFA175555413C26162BCF380000099A00000000', + NFTokenTaxon: 1, + URI: '697066733A2F2F6261667962656965356A626736336A6164676979736337796B6B6A64737170777732746A6D786D7935377077716F6A697666356562366B6D6D79612F312E6A736F6E', // Hex: ipfs://bafybeie5jbg63jadgiysc7ykkjdsqpww2tjmxmy57pwqojivf5eb6kmmya/1.json + nft_serial: 0 +}; + +const mockNFTData = { + NFTokenID: 'fake', + schema: 'ipfs://QmNpi8rcXEkohca8iXu7zysKKSJYqCvBJn3xJwga8jXqWU', + nftType: 'art.v0', + name: "Ekiserrepe's Oniric Lo-Fi Rooms Vol.1 NFT #1", + description: "Room #1 of Ekiserrepe's Oniric Lo-Fi Rooms Vol.1", + image: 'ipfs://bafybeie6pmuddco552t4u7oc7anryqohuj6vl42ngct6ve3q4bjet5piam/1.png', + collection: { + name: "Ekiserrepe's Oniric Lo-Fi Rooms Vol.1", + family: 'Oniric Lo-Fi Rooms' + } +}; + +const mockGetNFTData = jest.fn(); +const mockContext = { + ...valueLedgerContext, + getNFTData: mockGetNFTData +}; + +interface LedgerContextMockProviderProps { + children: ReactNode; +} + +const LedgerContextMockProvider: FC = ({ children }) => ( + {children} +); + +const renderNFTCard = (props: NFTCardProps) => { + act(() => { + render( + + + + ); + }); +}; + +describe('NFTCard', () => { + test('renders NFTCard component correctly', async () => { + mockGetNFTData.mockReturnValueOnce({ + ...mockNFTData + }); + + renderNFTCard({ NFT: mockNFT }); + + await waitFor(() => expect(mockGetNFTData).toHaveBeenCalled()); + expect(screen.getByTestId('OpenInNewOutlinedIcon')).toBeInTheDocument(); + }); + + test('handles error when fetching NFT data', async () => { + // Temporarily change the behavior of getNFTData to throw an error + mockGetNFTData.mockImplementationOnce(() => { + throw new Error('Failed to fetch NFT data'); + }); + + renderNFTCard({ NFT: mockNFT }); + + await waitFor(() => expect(mockGetNFTData).toHaveBeenCalled()); + + // Check that the NFT data description is set to convertHexToString(URI) when an error occurs + expect( + screen.getByText('ipfs://bafybeie5jbg63jadgiysc7ykkjdsqpww2tjmxmy57pwqojivf5eb6kmmya/1.json') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/extension/src/components/molecules/NFTCard/NFTCard.tsx b/packages/extension/src/components/molecules/NFTCard/NFTCard.tsx new file mode 100644 index 000000000..c4ea6b36d --- /dev/null +++ b/packages/extension/src/components/molecules/NFTCard/NFTCard.tsx @@ -0,0 +1,129 @@ +import { FC, forwardRef, ReactElement, Ref, useCallback, useEffect, useState } from 'react'; + +import { OpenInNewOutlined } from '@mui/icons-material'; +import { Button, CircularProgress, Dialog, ListItem, Paper, Slide, Tooltip } from '@mui/material'; +import { TransitionProps } from '@mui/material/transitions'; +import * as Sentry from '@sentry/react'; +import { convertHexToString } from 'xrpl'; + +import { AccountNFToken, NFTData } from '@gemwallet/constants'; + +import { useLedger } from '../../../contexts'; +import { NFTImage, TruncatedText } from '../../atoms'; +import { NFTDetails } from '../../organisms'; + +export interface NFTCardProps { + NFT: AccountNFToken; +} + +const Transition = forwardRef(function Transition( + props: TransitionProps & { + children: ReactElement; + }, + ref: Ref +) { + return ; +}); + +export const NFTCard: FC = ({ NFT }) => { + const { getNFTData } = useLedger(); + const [NFTData, setNFTData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + + useEffect(() => { + const fetchNFTImg = async () => { + try { + setIsLoading(true); + const nftData = await getNFTData({ NFT }); + setNFTData(nftData); + } catch (error) { + setNFTData({ + NFTokenID: NFT.NFTokenID, + description: NFT.URI ? convertHexToString(NFT.URI) : 'No data' + }); + Sentry.captureException(error); + } finally { + setIsLoading(false); + } + }; + fetchNFTImg(); + }, [getNFTData, NFT]); + + const handleViewNFTClick = useCallback(() => { + setDialogOpen(true); + }, []); + + const handleCloseDialog = useCallback(() => { + setDialogOpen(false); + }, []); + + const handleTokenIdClick = useCallback((tokenId: string) => { + navigator.clipboard.writeText(tokenId); + }, []); + + if (!NFTData) return null; + + return ( + <> + + + + + + {isLoading ? ( + + ) : ( + + )} + + handleTokenIdClick(NFTData.NFTokenID)} + sx={{ fontSize: '14px', color: 'grey', marginTop: '10px', cursor: 'pointer' }} + > + {NFTData.NFTokenID} + + + + {NFTData.name} + + + {NFTData.description} + + + + + + ); +}; diff --git a/packages/extension/src/components/molecules/NFTCard/index.ts b/packages/extension/src/components/molecules/NFTCard/index.ts new file mode 100644 index 000000000..afae19d45 --- /dev/null +++ b/packages/extension/src/components/molecules/NFTCard/index.ts @@ -0,0 +1 @@ +export * from './NFTCard'; diff --git a/packages/extension/src/components/molecules/NetworkIndicator/AddCustomNetworkDialog.tsx b/packages/extension/src/components/molecules/NetworkIndicator/AddCustomNetworkDialog.tsx index fb7dd31aa..57101995d 100644 --- a/packages/extension/src/components/molecules/NetworkIndicator/AddCustomNetworkDialog.tsx +++ b/packages/extension/src/components/molecules/NetworkIndicator/AddCustomNetworkDialog.tsx @@ -97,7 +97,7 @@ export const AddCustomNetworkDialog: FC = ({ - + Add custom network diff --git a/packages/extension/src/components/molecules/NetworkIndicator/NetworkIndicator.tsx b/packages/extension/src/components/molecules/NetworkIndicator/NetworkIndicator.tsx index 9010345eb..bf95e47db 100644 --- a/packages/extension/src/components/molecules/NetworkIndicator/NetworkIndicator.tsx +++ b/packages/extension/src/components/molecules/NetworkIndicator/NetworkIndicator.tsx @@ -227,7 +227,7 @@ export const NetworkIndicator: FC = () => { - + Change Network diff --git a/packages/extension/src/components/molecules/index.ts b/packages/extension/src/components/molecules/index.ts index 185f55e10..0fe96b4d6 100644 --- a/packages/extension/src/components/molecules/index.ts +++ b/packages/extension/src/components/molecules/index.ts @@ -1,4 +1,5 @@ export * from './InformationMessage'; export * from './NetworkIndicator'; +export * from './NFTCard'; export * from './TextCopy'; export * from './TokenDisplay'; diff --git a/packages/extension/src/components/organisms/NFTDetails/NFTDetails.tsx b/packages/extension/src/components/organisms/NFTDetails/NFTDetails.tsx new file mode 100644 index 000000000..e53df308e --- /dev/null +++ b/packages/extension/src/components/organisms/NFTDetails/NFTDetails.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react'; + +import CloseIcon from '@mui/icons-material/Close'; +import { + AppBar, + IconButton, + List, + ListItem, + ListItemText, + Toolbar, + Typography +} from '@mui/material'; + +import { NFTData } from '@gemwallet/constants'; + +import { NFTImage } from '../../atoms'; + +interface NFTDetailsProps { + NFTData: NFTData; + handleClose: () => void; +} + +const listItemStyle = { padding: '8px 24px' }; + +export const NFTDetails: FC = ({ NFTData, handleClose }) => { + return ( + <> + + + + + + + NFT Details + + + +
+ +
+ + + + + + + + + + + + + ); +}; diff --git a/packages/extension/src/components/organisms/NFTDetails/index.ts b/packages/extension/src/components/organisms/NFTDetails/index.ts new file mode 100644 index 000000000..f73711124 --- /dev/null +++ b/packages/extension/src/components/organisms/NFTDetails/index.ts @@ -0,0 +1 @@ +export * from './NFTDetails'; diff --git a/packages/extension/src/components/organisms/NFTListing/NFTListing.tsx b/packages/extension/src/components/organisms/NFTListing/NFTListing.tsx new file mode 100644 index 000000000..6e0abe664 --- /dev/null +++ b/packages/extension/src/components/organisms/NFTListing/NFTListing.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; + +import { List } from '@mui/material'; +import InfiniteScroll from 'react-infinite-scroll-component'; + +import { AccountNFTokenResponse } from '@gemwallet/constants'; + +import { InformationMessage } from '../../molecules'; +import { NFTCard } from '../../molecules'; +import { MAX_FETCHED_NFTS } from '../../pages'; + +export interface NFTListingProps extends AccountNFTokenResponse { + onLoadMoreClick: () => void; + isLoading: boolean; +} + +export const NFTListing: FC = ({ isLoading, account_nfts, onLoadMoreClick }) => { + if (account_nfts.length === 0 && !isLoading) { + return ( + +
There are no NFTs found in this wallet.
+
+ ); + } + + return ( + = MAX_FETCHED_NFTS} + height={450} + loader={

Loading...

} + > + + {account_nfts.map((NFT) => ( + + ))} + +
+ ); +}; diff --git a/packages/extension/src/components/organisms/NFTListing/index.ts b/packages/extension/src/components/organisms/NFTListing/index.ts new file mode 100644 index 000000000..6bfc4783b --- /dev/null +++ b/packages/extension/src/components/organisms/NFTListing/index.ts @@ -0,0 +1 @@ +export * from './NFTListing'; diff --git a/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx b/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx index 2dbb45757..8faf95aae 100644 --- a/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx +++ b/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx @@ -266,7 +266,7 @@ export const TokenListing: FC = ({ address }) => { - + Account balance diff --git a/packages/extension/src/components/organisms/TransactionListing/TransactionListing.tsx b/packages/extension/src/components/organisms/TransactionListing/TransactionListing.tsx index f3765007f..71f3b3d8a 100644 --- a/packages/extension/src/components/organisms/TransactionListing/TransactionListing.tsx +++ b/packages/extension/src/components/organisms/TransactionListing/TransactionListing.tsx @@ -165,7 +165,7 @@ export const TransactionListing: FC = ({ transactions } } return ( - + {tx.map((transaction, index) => (
= ({ transactions } > - + Transaction Details diff --git a/packages/extension/src/components/organisms/index.ts b/packages/extension/src/components/organisms/index.ts index 829259c41..49dbc69df 100644 --- a/packages/extension/src/components/organisms/index.ts +++ b/packages/extension/src/components/organisms/index.ts @@ -2,5 +2,7 @@ export * from './BaseTransaction'; export * from './DisplayXRPLTransaction'; export * from './Header'; export * from './NavMenu'; +export * from './NFTDetails'; +export * from './NFTListing'; export * from './TokenListing'; export * from './TransactionListing'; diff --git a/packages/extension/src/components/pages/AddNewTrustline/StepConfirm.tsx b/packages/extension/src/components/pages/AddNewTrustline/StepConfirm.tsx index 7ec6f2ce4..3c283a356 100644 --- a/packages/extension/src/components/pages/AddNewTrustline/StepConfirm.tsx +++ b/packages/extension/src/components/pages/AddNewTrustline/StepConfirm.tsx @@ -1,4 +1,4 @@ -import React, { FC, useMemo } from 'react'; +import { FC, useMemo } from 'react'; import ErrorIcon from '@mui/icons-material/Error'; import { Button, Container, IconButton, Paper, Tooltip, Typography } from '@mui/material'; diff --git a/packages/extension/src/components/pages/NFTViewer/NFTViewer.tsx b/packages/extension/src/components/pages/NFTViewer/NFTViewer.tsx new file mode 100644 index 000000000..78acc9f23 --- /dev/null +++ b/packages/extension/src/components/pages/NFTViewer/NFTViewer.tsx @@ -0,0 +1,64 @@ +import { FC, useCallback, useEffect, useState } from 'react'; + +import * as Sentry from '@sentry/react'; + +import { AccountNFTokenResponse } from '@gemwallet/constants'; + +import { useLedger } from '../../../contexts'; +import { NFTListing } from '../../organisms'; +import { PageWithHeader } from '../../templates'; + +export const MAX_FETCHED_NFTS = 20; + +const initalState = { + account_nfts: [], + marker: null, + isLoading: false +}; + +interface NFTsProps extends AccountNFTokenResponse { + isLoading: boolean; +} + +export const NFTViewer: FC = () => { + const { getNFTs } = useLedger(); + const [NFTs, setNFTs] = useState(initalState); + + const fetchNFTs = useCallback(async () => { + try { + const payload = { + limit: MAX_FETCHED_NFTS, + marker: NFTs.marker ?? undefined + }; + + setNFTs({ ...NFTs, isLoading: true }); + + const response = await getNFTs(payload); + + setNFTs({ + marker: response.marker, + account_nfts: NFTs.account_nfts.concat(response.account_nfts), + isLoading: false + }); + } catch (error) { + setNFTs(initalState); + Sentry.captureException(error); + } + }, [NFTs, getNFTs]); + + useEffect(() => { + fetchNFTs(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to fetch once + }, []); + + return ( + + + + ); +}; diff --git a/packages/extension/src/components/pages/NFTViewer/index.ts b/packages/extension/src/components/pages/NFTViewer/index.ts new file mode 100644 index 000000000..e91a2b8f1 --- /dev/null +++ b/packages/extension/src/components/pages/NFTViewer/index.ts @@ -0,0 +1 @@ +export * from './NFTViewer'; diff --git a/packages/extension/src/components/pages/index.ts b/packages/extension/src/components/pages/index.ts index ce8b68d8f..ee77d7764 100644 --- a/packages/extension/src/components/pages/index.ts +++ b/packages/extension/src/components/pages/index.ts @@ -18,6 +18,7 @@ export * from './ImportWallet'; export * from './ListWallets'; export * from './Login'; export * from './MintNFT'; +export * from './NFTViewer'; export * from './ResetPassword'; export * from './SendPayment'; export * from './ReceivePayment'; diff --git a/packages/extension/src/components/pages/routes/private.routes.ts b/packages/extension/src/components/pages/routes/private.routes.ts index 3289ace05..e23796607 100644 --- a/packages/extension/src/components/pages/routes/private.routes.ts +++ b/packages/extension/src/components/pages/routes/private.routes.ts @@ -15,6 +15,7 @@ import { HOME_PATH, LIST_WALLETS_PATH, MINT_NFT_PATH, + NFT_VIEWER_PATH, RECEIVE_PATH, SEND_PATH, SETTINGS_PATH, @@ -41,6 +42,7 @@ import { History } from '../History'; import { Home } from '../Home'; import { ListWallets } from '../ListWallets'; import { MintNFT } from '../MintNFT'; +import { NFTViewer } from '../NFTViewer'; import { ReceivePayment } from '../ReceivePayment'; import { SendPayment } from '../SendPayment'; import { SetAccount } from '../SetAccount'; @@ -72,6 +74,7 @@ export const privateRoutes: PrivateRouteConfig[] = [ { path: HOME_PATH, element: Home }, { path: LIST_WALLETS_PATH, element: ListWallets }, { path: MINT_NFT_PATH, element: MintNFT }, + { path: NFT_VIEWER_PATH, element: NFTViewer }, { path: RECEIVE_PATH, element: ReceivePayment }, { path: SEND_PATH, element: SendPayment }, { path: SET_ACCOUNT_PATH, element: SetAccount }, diff --git a/packages/extension/src/constants/navigation.tsx b/packages/extension/src/constants/navigation.tsx index 9fba01c14..80d7dd115 100644 --- a/packages/extension/src/constants/navigation.tsx +++ b/packages/extension/src/constants/navigation.tsx @@ -1,8 +1,9 @@ import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import HistoryIcon from '@mui/icons-material/History'; +import PhotoCameraBackIcon from '@mui/icons-material/PhotoCameraBack'; import SettingsIcon from '@mui/icons-material/Settings'; -import { HISTORY_PATH, HOME_PATH, SETTINGS_PATH } from './paths'; +import { HISTORY_PATH, HOME_PATH, NFT_VIEWER_PATH, SETTINGS_PATH } from './paths'; export const navigation = [ { @@ -15,6 +16,11 @@ export const navigation = [ pathname: HISTORY_PATH, icon: }, + { + label: 'NFTs', + pathname: NFT_VIEWER_PATH, + icon: + }, { label: 'Settings', pathname: SETTINGS_PATH, diff --git a/packages/extension/src/constants/paths.ts b/packages/extension/src/constants/paths.ts index 5456d4969..46c4e2576 100644 --- a/packages/extension/src/constants/paths.ts +++ b/packages/extension/src/constants/paths.ts @@ -31,3 +31,4 @@ export const SHARE_PUBLIC_KEY_PATH = '/share-public-key'; export const TRANSACTION_PATH = '/transaction'; export const TRUSTED_APPS_PATH = '/trusted-apps'; export const WELCOME_PATH = '/welcome'; +export const NFT_VIEWER_PATH = '/nft-viewer'; diff --git a/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx b/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx index adc921a5e..58cb50397 100644 --- a/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx +++ b/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx @@ -24,6 +24,7 @@ import { BaseTransaction } from 'xrpl/dist/npm/models/transactions/common'; import { AcceptNFTOfferRequest, AccountNFToken, + AccountNFTokenResponse, BaseTransactionRequest, BurnNFTRequest, CancelNFTOfferRequest, @@ -32,6 +33,8 @@ import { CreateOfferRequest, GetNFTRequest, MintNFTRequest, + NFTData, + NFTokenIDResponse, SendPaymentRequest, SetAccountRequest, SetTrustlineRequest, @@ -40,19 +43,10 @@ import { import { AccountTransaction, WalletLedger } from '../../types'; import { toXRPLMemos, toXRPLSigners } from '../../utils'; +import { resolveNFTData } from '../../utils/NFTDataResolver'; import { useNetwork } from '../NetworkContext'; import { useWallet } from '../WalletContext'; -interface GetNFTsResponse { - account_nfts: AccountNFToken[]; - marker?: unknown; -} - -interface MintNFTResponse { - hash: string; - NFTokenID: string; -} - interface FundWalletResponse { wallet: Wallet; balance: number; @@ -90,6 +84,10 @@ interface SubmitTransactionResponse { hash: string; } +interface NFTImageRequest { + NFT: AccountNFToken; +} + export const LEDGER_CONNECTION_ERROR = 'You need to be connected to a ledger to make a transaction'; export interface LedgerContextType { @@ -98,10 +96,10 @@ export interface LedgerContextType { setTrustline: (payload: SetTrustlineRequest) => Promise; signMessage: (message: string) => string | undefined; estimateNetworkFees: (payload: Transaction) => Promise; - getNFTs: (payload?: GetNFTRequest) => Promise; + getNFTs: (payload?: GetNFTRequest) => Promise; getTransactions: () => Promise; fundWallet: () => Promise; - mintNFT: (payload: MintNFTRequest) => Promise; + mintNFT: (payload: MintNFTRequest) => Promise; createNFTOffer: (payload: CreateNFTOfferRequest) => Promise; cancelNFTOffer: (payload: CancelNFTOfferRequest) => Promise; acceptNFTOffer: (payload: AcceptNFTOfferRequest) => Promise; @@ -111,6 +109,7 @@ export interface LedgerContextType { cancelOffer: (payload: CancelOfferRequest) => Promise; submitTransaction: (payload: SubmitTransactionRequest) => Promise; getAccountInfo: () => Promise; + getNFTData: (payload: NFTImageRequest) => Promise; } const LedgerContext = createContext({ @@ -139,7 +138,8 @@ const LedgerContext = createContext({ createOffer: () => new Promise(() => {}), cancelOffer: () => new Promise(() => {}), submitTransaction: () => new Promise(() => {}), - getAccountInfo: () => new Promise(() => {}) + getAccountInfo: () => new Promise(() => {}), + getNFTData: () => new Promise(() => {}) }); const LedgerProvider: FC = ({ children }) => { @@ -172,27 +172,29 @@ const LedgerProvider: FC = ({ children }) => { ); const getNFTs = useCallback( - async (payload?: GetNFTRequest): Promise => { + async (payload?: GetNFTRequest): Promise => { const wallet = getCurrentWallet(); if (!client) { throw new Error('You need to be connected to a ledger to get the NFTs'); - } else if (!wallet) { + } + if (!wallet) { throw new Error('You need to have a wallet connected to get the NFTs'); - } else { - // Prepare the transaction - const prepared = await client.request({ - command: 'account_nfts', - account: wallet.publicAddress, - limit: payload?.limit, - marker: payload?.marker, - ledger_index: 'validated' - }); - if (!prepared.result?.account_nfts) { - throw new Error("Couldn't get the NFTs"); - } else { - return { account_nfts: prepared.result.account_nfts, marker: prepared.result.marker }; - } } + + // Prepare the transaction + const prepared = await client.request({ + command: 'account_nfts', + account: wallet.publicAddress, + limit: payload?.limit, + marker: payload?.marker, + ledger_index: 'validated' + }); + + if (!prepared.result?.account_nfts) { + throw new Error("Couldn't get the NFTs"); + } + + return { account_nfts: prepared.result.account_nfts, marker: prepared.result.marker }; }, [client, getCurrentWallet] ); @@ -795,6 +797,15 @@ const LedgerProvider: FC = ({ children }) => { ...(payload.txnSignature && { TxnSignature: payload.txnSignature }) }); + const getNFTData = useCallback(async ({ NFT }: NFTImageRequest) => { + try { + return resolveNFTData(NFT); + } catch (e) { + Sentry.captureException(e); + throw e; + } + }, []); + const value: LedgerContextType = { sendPayment, setTrustline, @@ -812,7 +823,8 @@ const LedgerProvider: FC = ({ children }) => { createOffer, cancelOffer, submitTransaction, - getAccountInfo + getAccountInfo, + getNFTData }; return {children}; diff --git a/packages/extension/src/mocks/ledgerContext.ts b/packages/extension/src/mocks/ledgerContext.ts index fb35f52ce..8f5934546 100644 --- a/packages/extension/src/mocks/ledgerContext.ts +++ b/packages/extension/src/mocks/ledgerContext.ts @@ -17,5 +17,6 @@ export const valueLedgerContext: LedgerContextType = { createOffer: jest.fn(), cancelOffer: jest.fn(), submitTransaction: jest.fn(), - getAccountInfo: jest.fn() + getAccountInfo: jest.fn(), + getNFTData: jest.fn() }; diff --git a/packages/extension/src/utils/NFTDataResolver.test.ts b/packages/extension/src/utils/NFTDataResolver.test.ts new file mode 100644 index 000000000..0e72ee1cf --- /dev/null +++ b/packages/extension/src/utils/NFTDataResolver.test.ts @@ -0,0 +1,107 @@ +import { convertStringToHex } from 'xrpl'; + +import { IPFSResolverPrefix } from '@gemwallet/constants/src/xrpl/nft.constant'; + +import { resolveNFTData } from './NFTDataResolver'; + +const mockNFT = { + Flags: 0, + Issuer: '', + NFTokenTaxon: 0, + nft_serial: 0 +}; + +describe('resolveNFTData', () => { + let mockFetch: jest.SpyInstance; + + beforeEach(() => { + mockFetch = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + mockFetch.mockRestore(); + }); + + it('should return NFTokenID and description if URI is empty', async () => { + const result = await resolveNFTData({ ...mockNFT, NFTokenID: '1234' }); + expect(result).toEqual({ + NFTokenID: '1234', + description: 'No data' + }); + }); + + it('should return NFTokenID and image URL if URL is a PNG image', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + const result = await resolveNFTData({ + ...mockNFT, + NFTokenID: '1234', + URI: convertStringToHex('https://test.com/image.png') + }); + expect(result).toEqual({ + NFTokenID: '1234', + image: 'https://test.com/image.png' + }); + }); + + it('should return NFTokenID and image URL if URL is a JPG image', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + const result = await resolveNFTData({ + ...mockNFT, + NFTokenID: '1234', + URI: convertStringToHex('https://test.com/image.jpg') + }); + expect(result).toEqual({ + NFTokenID: '1234', + image: 'https://test.com/image.jpg' + }); + }); + + it('should return parsed JSON if URL is a JSON', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + (global.fetch as jest.Mock).mockResolvedValue( + new Response(JSON.stringify({ description: 'Test JSON' })) + ); + const result = await resolveNFTData({ + ...mockNFT, + NFTokenID: '1234', + URI: convertStringToHex('https://test.com/data.json') + }); + expect(result).toEqual({ + NFTokenID: '1234', + description: 'Test JSON', + image: 'https://test.com/data.png' + }); + }); + + it('should return raw NFT attributes if URL fetch fails', async () => { + const testJsonUrl = 'https://test.com/data.json'; + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Fetch failed')); + + const result = await resolveNFTData({ + ...mockNFT, + NFTokenID: '1234', + URI: convertStringToHex(testJsonUrl) + }); + + expect(result).toEqual({ + NFTokenID: '1234', + description: testJsonUrl.replace(IPFSResolverPrefix, 'ipfs://') + }); + }); + + it('should return raw NFT attributes if URL fetch from IPFS fails', async () => { + const testIpfsUrl = `${IPFSResolverPrefix}someHash`; + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Fetch failed')); + + const result = await resolveNFTData({ + ...mockNFT, + NFTokenID: '1234', + URI: convertStringToHex(testIpfsUrl) + }); + + expect(result).toEqual({ + NFTokenID: '1234', + description: `ipfs://someHash` + }); + }); +}); diff --git a/packages/extension/src/utils/NFTDataResolver.ts b/packages/extension/src/utils/NFTDataResolver.ts new file mode 100644 index 000000000..c8f2fb1f8 --- /dev/null +++ b/packages/extension/src/utils/NFTDataResolver.ts @@ -0,0 +1,67 @@ +import { convertHexToString } from 'xrpl'; + +import { AccountNFToken, NFTData } from '@gemwallet/constants'; +import { IPFSResolverPrefix } from '@gemwallet/constants/src/xrpl/nft.constant'; + +import { parseJSON } from './NFTViewer'; + +import { isImageUrl } from '.'; + +export const resolveNFTData = async (NFT: AccountNFToken): Promise => { + const { NFTokenID, URI } = NFT; + let URL = URI ? convertHexToString(URI) : ''; + + if (!URL.length) { + return { + NFTokenID, + description: 'No data' + }; + } + + URL = URL.replace('ipfs://', IPFSResolverPrefix); + + // Case 1 - Image URL + if (isImageUrl(URL)) { + try { + // Case 1.1 - The URL is directly an image + await fetch(URL); + return { + NFTokenID, + image: URL + }; + } catch (e) { + // Case 1.2 - The URL is an IPFS hash + if (!URL.startsWith(IPFSResolverPrefix) && !URL.startsWith('http')) { + URL = `${IPFSResolverPrefix}${URL}`; + } + try { + await fetch(URL); + return { + NFTokenID, + image: URL + }; + } catch (e) {} + } + } else { + // Case 2 - JSON URL + try { + await fetch(URL); + // Case 2.1 - The URL is directly a JSON + // If it follows the XLS-24 standard, it will be automatically parsed + return parseJSON(URL, NFTokenID); + } catch (e) {} + // Case 2.2 - The URL is an IPFS hash + if (!URL.startsWith(IPFSResolverPrefix) && !URL.startsWith('http')) { + try { + await fetch(`${IPFSResolverPrefix}${URL}`); + // If it follows the XLS-24 standard, it will be automatically parsed + return parseJSON(`${IPFSResolverPrefix}${URL}`, NFTokenID); + } catch (e) {} + } + } + // Case 3 - Return the raw NFT attributes + return { + NFTokenID, + description: URL.replace(IPFSResolverPrefix, 'ipfs://') + }; +}; diff --git a/packages/extension/src/utils/NFTViewer.test.ts b/packages/extension/src/utils/NFTViewer.test.ts new file mode 100644 index 000000000..7e8aed866 --- /dev/null +++ b/packages/extension/src/utils/NFTViewer.test.ts @@ -0,0 +1,72 @@ +import { parseImage, parseJSON } from './NFTViewer'; + +// Mock fetch +global.fetch = jest.fn(); + +jest.doMock('./NFTViewer', () => ({ + parseImage: jest.fn() +})); + +describe('parseImage function', () => { + it('returns replaced NFTData.image when it exists', () => { + const mockNFTData = { image: 'ipfs://someImageHash' }; + const mockURL = 'https://someUrl.json'; + const result = parseImage(mockNFTData, mockURL); + expect(result).toBe('https://ipfs.io/ipfs/someImageHash'); + }); + + it('returns replaced NFTData.image_url when image does not exist but image_url does', () => { + const mockNFTData = { image_url: 'ipfs://someImageHash' }; + const mockURL = 'https://someUrl.json'; + const result = parseImage(mockNFTData, mockURL); + expect(result).toBe('https://ipfs.io/ipfs/someImageHash'); + }); + + it('returns replaced URL when neither image nor image_url exist in NFTData', () => { + const mockNFTData = {}; + const mockURL = 'https://someUrl.json'; + const result = parseImage(mockNFTData, mockURL); + expect(result).toBe('https://someUrl.png'); + }); +}); + +describe('parseJSON function', () => { + beforeEach(() => { + // Mock the global fetch function + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ name: 'Mock NFT' }) + }) + ) as any; + }); + + afterEach(() => { + // Clear all instances and calls to the fetch mock + jest.clearAllMocks(); + }); + + it('returns NFT data with added properties', async () => { + const mockUrl = 'https://someUrl.json'; + const mockNFTokenID = '123'; + const expectedResult = { + name: 'Mock NFT', + NFTokenID: '123', + image: 'https://someUrl.png' + }; + + const result = await parseJSON(mockUrl, mockNFTokenID); + expect(result).toEqual(expectedResult); + expect(global.fetch).toHaveBeenCalledWith(mockUrl); + }); + + it('throws an error when the fetch promise rejects', async () => { + // Mock the fetch function to reject with an error + global.fetch = jest.fn(() => Promise.reject('Error')) as any; + + const mockUrl = 'https://someUrl.json'; + const mockNFTokenID = '123'; + + await expect(parseJSON(mockUrl, mockNFTokenID)).rejects.toThrow('Error fetching NFT data'); + expect(global.fetch).toHaveBeenCalledWith(mockUrl); + }); +}); diff --git a/packages/extension/src/utils/NFTViewer.ts b/packages/extension/src/utils/NFTViewer.ts new file mode 100644 index 000000000..89663fdda --- /dev/null +++ b/packages/extension/src/utils/NFTViewer.ts @@ -0,0 +1,40 @@ +import { NFTData } from '@gemwallet/constants'; +import { IPFSResolverPrefix } from '@gemwallet/constants/src/xrpl/nft.constant'; + +export const parseImage = (NFTData: any, URL: string): string => { + if (NFTData.image) { + return replaceIPFS(NFTData.image); + } + + if (NFTData.image_url) { + return replaceIPFS(NFTData.image_url); + } + + return URL.replace('.json', '.png'); +}; + +export const parseJSON = async (URL: any, NFTokenID: string): Promise => { + const NFTData = await fetch(URL) + .then((res) => res.json()) + .catch(() => { + throw new Error('Error fetching NFT data'); + }); + + const image = parseImage(NFTData, URL); + + return { + ...NFTData, + NFTokenID, + image + }; +}; + +const replaceIPFS = (inputStr: string): string => { + if (!inputStr.startsWith('ipfs://' || IPFSResolverPrefix) && !inputStr.startsWith('http')) { + return `${IPFSResolverPrefix}${inputStr}`; + } + + return inputStr + .replace('ipfs://ipfs/', IPFSResolverPrefix) + .replace('ipfs://', IPFSResolverPrefix); +}; diff --git a/packages/extension/src/utils/image.test.ts b/packages/extension/src/utils/image.test.ts new file mode 100644 index 000000000..d26a81021 --- /dev/null +++ b/packages/extension/src/utils/image.test.ts @@ -0,0 +1,48 @@ +import { isImageUrl } from './image'; + +describe('isImageUrl', () => { + it('returns true for png image URLs', () => { + const url = 'http://example.com/image.png'; + expect(isImageUrl(url)).toBeTruthy(); + }); + + it('returns true for jpg image URLs', () => { + const url = 'http://example.com/image.jpg'; + expect(isImageUrl(url)).toBeTruthy(); + }); + + it('returns true for jpeg image URLs', () => { + const url = 'http://example.com/image.jpeg'; + expect(isImageUrl(url)).toBeTruthy(); + }); + + it('returns true for gif image URLs', () => { + const url = 'http://example.com/image.gif'; + expect(isImageUrl(url)).toBeTruthy(); + }); + + it('returns true for tif image URLs', () => { + const url = 'http://example.com/image.tif'; + expect(isImageUrl(url)).toBeTruthy(); + }); + + it('returns true for tiff image URLs', () => { + const url = 'http://example.com/image.tiff'; + expect(isImageUrl(url)).toBeTruthy(); + }); + + it('returns true for bmp image URLs', () => { + const url = 'http://example.com/image.bmp'; + expect(isImageUrl(url)).toBeTruthy(); + }); + + it('returns false for non-image URLs', () => { + const url = 'http://example.com/image.txt'; + expect(isImageUrl(url)).toBeFalsy(); + }); + + it('returns false for URLs without an extension', () => { + const url = 'http://example.com/image'; + expect(isImageUrl(url)).toBeFalsy(); + }); +}); diff --git a/packages/extension/src/utils/image.ts b/packages/extension/src/utils/image.ts new file mode 100644 index 000000000..8e1fd947d --- /dev/null +++ b/packages/extension/src/utils/image.ts @@ -0,0 +1,3 @@ +export const isImageUrl = (url: string): boolean => { + return /\.(png|jpg|jpeg|gif|tif|tiff|bmp)$/i.test(url); +}; diff --git a/packages/extension/src/utils/index.ts b/packages/extension/src/utils/index.ts index 75c17b339..e7aa0ada0 100644 --- a/packages/extension/src/utils/index.ts +++ b/packages/extension/src/utils/index.ts @@ -4,6 +4,7 @@ export * from './crypto'; export * from './format'; export * from './getLastItemFromArray'; export * from './hexConverter'; +export * from './image'; export * from './link'; export * from './network'; export * from './numbersToSeed'; diff --git a/yarn.lock b/yarn.lock index 000ab4e66..1f8bda6d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2436,6 +2436,14 @@ dependencies: "@types/react" "*" +"@types/react-lazy-load-image-component@^1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@types/react-lazy-load-image-component/-/react-lazy-load-image-component-1.5.2.tgz#b87e814b6b91853b802f04465364ff1e913dce6a" + integrity sha512-4NLJsMJVrMv18FuMIkUUBVj/PH9A+BvLKrZC75EWiEFn1IsMrZHgL1tVKw5QBfoa0Qjz6SkWIzEvwcyZ8PgnIg== + dependencies: + "@types/react" "*" + csstype "^3.0.2" + "@types/react-transition-group@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" @@ -4174,9 +4182,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001286: - version "1.0.30001299" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001299.tgz#d753bf6444ed401eb503cbbe17aa3e1451b5a68c" - integrity sha512-iujN4+x7QzqA2NCSrS5VUy+4gLmRd4xv6vbBBsmfVqTx8bLAD8097euLqQgKxSVLvxjSDcvF1T/i9ocgnUFexw== + version "1.0.30001478" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001478.tgz" + integrity sha512-gMhDyXGItTHipJj2ApIvR+iVB5hd0KP3svMWWXDvZOmjzJJassGLMfxRkQCSYgGd2gtdL/ReeiyvMSFD1Ss6Mw== capture-exit@^2.0.0: version "2.0.0" @@ -8793,6 +8801,11 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== + lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" @@ -11028,6 +11041,13 @@ react-error-overlay@^6.0.9: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6" integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA== +react-infinite-scroll-component@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f" + integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ== + dependencies: + throttle-debounce "^2.1.0" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -11053,6 +11073,14 @@ react-json-view@^1.21.3: react-lifecycles-compat "^3.0.4" react-textarea-autosize "^8.3.2" +react-lazy-load-image-component@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.0.tgz#f262c2f163052d71011e282031fd60aafa6494ac" + integrity sha512-8KFkDTgjh+0+PVbH+cx0AgxLGbdTsxWMnxXzU5HEUztqewk9ufQAu8cstjZhyvtMIPsdMcPZfA0WAa7HtjQbBQ== + dependencies: + lodash.debounce "^4.0.8" + lodash.throttle "^4.1.1" + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -12740,6 +12768,11 @@ throat@^5.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== +throttle-debounce@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" + integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== + throttleit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"