feat(NFT): Lazy-load NFT token icons

This commit is contained in:
Daniel Gonzalez
2025-03-03 18:02:07 -04:00
parent 2d9fb823cd
commit ab38407686
5 changed files with 267 additions and 157 deletions
+9 -9
View File
@@ -1,18 +1,18 @@
const webpack = require('webpack'); const webpack = require('webpack')
module.exports = function override(config) { module.exports = function override (config) {
config.resolve.fallback = { config.resolve.fallback = {
...config.resolve.fallback, ...config.resolve.fallback,
crypto: require.resolve('crypto-browserify'), crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'), stream: require.resolve('stream-browserify'),
vm: require.resolve('vm-browserify'), vm: require.resolve('vm-browserify')
}; }
config.plugins = (config.plugins || []).concat([ config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({ new webpack.ProvidePlugin({
process: 'process/browser', process: 'process/browser'
}), })
]); ])
return config; return config
}; }
+215 -99
View File
@@ -1,16 +1,21 @@
/* /*
Shows NFTs for sale on the DEX. Shows NFTs for sale on the DEX.
workflow:
1. Load offers
2. display token cards with current offers data
3. Load tokens data
4. add data to offers and update card with new data
5. Load tokens icons
6. add icons to offers and update card with new icons
*/ */
// Global npm libraries // Global npm libraries
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Card, Col, Button, Spinner } from 'react-bootstrap' import { Container, Row, Col, Button, Spinner } from 'react-bootstrap'
import axios from 'axios' import axios from 'axios'
import Jdenticon from '@chris.troutner/react-jdenticon'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedo } from '@fortawesome/free-solid-svg-icons' import { faRedo } from '@fortawesome/free-solid-svg-icons'
// import BchDexLib from 'bch-dex-lib'
// import RetryQueue from '@chris.troutner/retry-queue'
// Local libraries // Local libraries
import config from '../../../config' import config from '../../../config'
@@ -21,111 +26,188 @@ const SERVER = config.dexServer
function NftsForSale (props) { function NftsForSale (props) {
// Dependency injection through props // Dependency injection through props
const appData = props.appData const appData = props.appData
console.log('NftsForSale() appData: ', appData)
// State
const [offers, setOffers] = useState([]) const [offers, setOffers] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [offersAreLoaded, setOffersAreLoaded] = useState(false)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
async function getNftOffers(page = 0) { // Handler for refresh button
const handleRefresh = () => {
console.log('handling refresh')
loadNftOffers()
}
// Function to process token data
const processTokenData = useCallback(async (offer) => {
try { try {
const options = { const { wallet, bchWalletState } = appData
method: 'GET', const { bchjs } = wallet
url: `${SERVER}/offer/list/nft/${page}`,
data: {}
}
const result = await axios.request(options)
console.log('result.data: ', result.data)
// Calculate USD price
const rateInSats = parseInt(offer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * bchWalletState.bchUsdPrice * offer.numTokens
offer.usdPrice = `$${usdPrice.toFixed(3)}`
return offer
} catch (err) {
console.error('Error processing token:', err)
return offer
}
}, [appData])
// Fetch offers
const getNftOffers = useCallback(async (page = 0) => {
try {
setOffersAreLoaded(false)
const result = await axios.get(`${SERVER}/offer/list/nft/${page}`)
const rawOffers = result.data const rawOffers = result.data
console.log('rawOffers: ', rawOffers)
const bchjs = appData.wallet.bchjs // Process each offer
const wallet = appData.wallet const processedOffers = []
// Instantiate the BchDexLib object.
// const dexLib = new BchDexLib({bchWallet: wallet, p2wdbRead: {}, p2wdbWrite: {}})
const offerDataCallback = (offer) => {
console.log('offerDataCallback() offer: ', offer)
}
// Add a default icon.
for (let i = 0; i < rawOffers.length; i++) { for (let i = 0; i < rawOffers.length; i++) {
const thisOffer = rawOffers[i] const offer = rawOffers[i]
const processedOffer = await processTokenData(offer)
thisOffer.icon = (<Jdenticon size='100' value={thisOffer.tokenId} />) processedOffers.push(processedOffer)
thisOffer.iconDownloaded = false
// Convert sats to BCH, and then calculate cost in USD.
const rateInSats = parseInt(thisOffer.rateInBaseUnit)
// console.log('rateInSats: ', rateInSats)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
// console.log('bchCost: ', bchCost)
// console.log('bchUsdPrice: ', this.state.appData.bchWalletState.bchUsdPrice)
const usdPrice = bchCost * appData.bchWalletState.bchUsdPrice * thisOffer.numTokens
// usdPrice = bchjs.Util.floor2(usdPrice)
// console.log(`usdPrice: ${usdPrice}`)
const priceStr = `$${usdPrice.toFixed(3)}`
thisOffer.usdPrice = priceStr
// Download token data
// await dexLib.tokenData.getTokenData(thisOffer, offerDataCallback)
const tokenData = await wallet.getTokenData(thisOffer.tokenId)
console.log('complete tokenData: ', tokenData)
// const mutableCid = tokenData.mutableData.slice(7)
// const url1 = `https://free-bch.fullstack.cash/ipfs/file-info/${mutableCid}`
// console.log('url1: ', url1)
// const resp1 = await axios.get(url1)
// console.log('resp1.data: ', resp1.data)
try {
const mutableCid = tokenData.mutableData.slice(7)
console.log(`mutable CID for token ${thisOffer.tokenId}: ${mutableCid}`)
let mutableData = await wallet.cid2json({ cid: mutableCid })
console.log('mutableData: ', mutableData)
mutableData = mutableData.json
} catch(err) {
console.error(`Could not download mutable data for token ${thisOffer.tokenId}: ${err.message}`)
}
} }
return rawOffers setOffersAreLoaded(true)
} catch(err) {
console.error('NftsForSale() getNftOffers() error: ', err) return processedOffers
return [] } catch (err) {
console.error('getNftOffers error:', err)
setOffersAreLoaded(true)
throw err
} }
} }, [processTokenData])
async function handleOffers() { // This function loads the token data .
let offers = await getNftOffers() const lazyLoadTokenData = useCallback(async (tokens) => {
console.log('offers: ', offers)
return offers
}
async function handleStartProcessingTokens() {
return await handleOffers()
}
// This is an async startup function that is called by the useEffect hook.
const asyncStartup = async () => {
try { try {
const offers = await handleStartProcessingTokens() setDataAreLoaded(false)
setOffers(offers) // map each token and fetch the token data
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
}
// Mark token to prevent fetch token data again.
thisToken.dataAlreadyDownloaded = true
}
setDataAreLoaded(true)
} catch (error) { } catch (error) {
console.error('NftsForSale() asyncStartup() error: ', error) setDataAreLoaded(true)
} }
} }, [appData])
// Load NFT data when the page loads from the server or from the cache. // Fetch mutable data if it exist and get the token icon url
const fetchTokenInfo = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
console.log('json: ', json)
if (!json) return false
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
// Return icon url
return iconUrl
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const iconUrl = await fetchTokenInfo(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenInfo])
// Main function to load NFT offers
const loadNftOffers = useCallback(async () => {
try {
setIsLoading(true)
// Get tokens in offers
const offers = await getNftOffers()
console.log('offers: ', offers)
setOffers(offers)
// Load tokens data
await lazyLoadTokenData(offers)
// Load tokens icons
await lazyLoadTokenIcons(offers)
setIsLoading(false)
} catch (err) {
setIsLoading(false)
console.error('Error loading NFT offers:', err)
}
}, [lazyLoadTokenData, lazyLoadTokenIcons, getNftOffers])
// Effect to load NFTs on component mount
useEffect(() => { useEffect(() => {
asyncStartup() console.log('loading nfts for sale')
}, []) loadNftOffers()
}, [loadNftOffers])
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// This function generates a Token Card for each token in the wallet. // This function generates a Token Card for each token in the wallet.
function generateCards () { function generateCards (offers) {
// console.log('generateCards() offerData: ', offerData) console.log('generateCards() offerData: ', offers)
const tokens = offers const tokens = offers
@@ -133,13 +215,12 @@ function NftsForSale (props) {
for (let i = 0; i < tokens.length; i++) { for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i] const thisToken = tokens[i]
// console.log(`thisToken: ${JSON.stringify(thisToken, null, 2)}`)
const thisTokenCard = ( const thisTokenCard = (
<TokenCard <TokenCard
appData={appData} appData={appData}
token={thisToken} token={thisToken}
key={`${thisToken.tokenId}`} key={`${thisToken.tokenId + i}`}
/> />
) )
tokenCards.push(thisTokenCard) tokenCards.push(thisTokenCard)
@@ -155,18 +236,53 @@ function NftsForSale (props) {
<h1>NFTs for Sale</h1> <h1>NFTs for Sale</h1>
</Col> </Col>
</Row> </Row>
<Row> <Row>
<Col xs={6}> <Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Button variant='success' > {/** Show spinner info if tokens are loaded but data is not loaded */
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh offersAreLoaded && !dataAreLoaded && (
</Button> <div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Tokens Data </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but icons are not loaded */
dataAreLoaded && !iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Tokens Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col> </Col>
</Row> </Row>
{!isLoading && (
<Row>
<Col xs={6}>
<Button variant='success' onClick={handleRefresh}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
</Col>
</Row>
)}
{!offersAreLoaded && (
<Row className='d-block text-center'>
<Spinner animation='border' variant='primary' style={{ maegin: '0 auto' }} />
</Row>
)}
<Row> <Row>
{generateCards()} {offersAreLoaded && generateCards(offers)}
</Row> </Row>
{/** Display a message if no tokens are found */}
{offersAreLoaded && offers.length === 0 && (
<Row className='text-center'>
<span> No Offers found. </span>
</Row>
)}
</Container> </Container>
) )
} }
@@ -23,8 +23,6 @@ function InfoButton (props) {
setShow(true) setShow(true)
} }
console.log('props.token: ', props.token)
// Replace with dummy button until token data is loaded. // Replace with dummy button until token data is loaded.
if (!props.token.tokenData) { if (!props.token.tokenData) {
return ( return (
@@ -34,15 +32,9 @@ function InfoButton (props) {
) )
} }
// Convert the url property of the token to a link, if it matches common patterns.
// let url = props.token.tokenurl
// url = linkIfUrl(props.token.url)
// console.log('props.token: ', props.token)
return ( return (
<> <>
<Button variant='info' onClick={handleOpen}>Info</Button> <Button variant='info' disabled={props.disabled} onClick={handleOpen}>Info</Button>
<Modal show={show} onHide={handleClose}> <Modal show={show} onHide={handleClose}>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Token Information</Modal.Title> <Modal.Title>Token Information</Modal.Title>
@@ -56,7 +48,7 @@ function InfoButton (props) {
<Row style={{ backgroundColor: '#eee' }}> <Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Name</b>:</Col> <Col xs={4}><b>Name</b>:</Col>
<Col xs={8}>{props.token.tokenData.tokenStats.name}</Col> <Col xs={8}>{props.token.tokenData.genesisData.name}</Col>
</Row> </Row>
<Row> <Row>
@@ -70,19 +62,14 @@ function InfoButton (props) {
<Row style={{ backgroundColor: '#eee' }}> <Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Decimals</b>:</Col> <Col xs={4}><b>Decimals</b>:</Col>
<Col xs={8}>{props.token.tokenData.tokenStats.decimals}</Col> <Col xs={8}>{props.token.tokenData.genesisData.decimals}</Col>
</Row> </Row>
<Row> <Row>
<Col xs={4}><b>Token Type</b>:</Col> <Col xs={4}><b>Token Type</b>:</Col>
<Col xs={8}>{props.token.tokenType}</Col> <Col xs={8}>{props.token.tokenType}</Col>
</Row> </Row>
{/*
<Row style={{ backgroundColor: '#eee', wordBreak: 'break-all' }}>
<Col xs={4}><b>URL</b>:</Col>
<Col xs={8}>{url}</Col>
</Row>
*/}
</Container> </Container>
</Modal.Body> </Modal.Body>
<Modal.Footer /> <Modal.Footer />
@@ -3,63 +3,70 @@
*/ */
// Global npm libraries // Global npm libraries
import React from 'react' import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap' import { Container, Row, Col, Card } from 'react-bootstrap'
// import BuyNftButton from './buy-button'
import InfoButton from './info-button' import InfoButton from './info-button'
// import FlagButton from './flag-button' import Jdenticon from '@chris.troutner/react-jdenticon'
// Local libraries
// import InfoButton from './info-button'
// import SendTokenButton from './send-token-button'
// import SellButton from './sell-button'
function TokenCard (props) { function TokenCard (props) {
let imageLink = '' const { token } = props
if (props.token.mutableData) { const [icon, setIcon] = useState(token.icon)
imageLink = props.token.mutableData.tokenIcon const [tokenData, setTokenData] = useState(token.tokenData)
if (props.token.mutableData.fullSizedUrl && props.token.mutableData.fullSizedUrl.includes('http')) { // Update icon state every token.icon and token.tokenData changes
imageLink = props.token.mutableData.fullSizedUrl useEffect(() => {
} setIcon(token.icon)
} setTokenData(token.tokenData)
}, [token.icon, token.tokenData])
console.log('TokenCard props.token: ', props.token)
return ( return (
<> <>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}> <Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card> <Card>
<Card.Body style={{ textAlign: 'center' }}> <Card.Body style={{ textAlign: 'center' }}>
<a href={imageLink} target='_blank' rel='noreferrer'> {/** If the icon is loaded, display it */
{props.token.icon} icon && (
</a> <Card.Img
<Card.Title style={{ textAlign: 'center' }}> src={icon}
<h4>{props.token.ticker}</h4> style={{ height: '100px', width: 'auto' }}
onError={(e) => {
setIcon(null) // Set the icon to null if it fails to load the image url.
}}
/>
)
}
{/** If the icon is not loaded, display the Jdenticon */
!icon && (
<Jdenticon size='100' value={token.tokenId} />
)
}
<Card.Title style={{ textAlign: 'center', marginTop: '10px' }}>
<h4>{token.ticker}</h4>
</Card.Title> </Card.Title>
<Container> <Container>
<Row> <Row>
<Col> <Col>
{props.token.tokenData ? props.token.tokenData.tokenStats.name : null} {tokenData && tokenData.genesisData ? tokenData.genesisData.name : null}
</Col> </Col>
</Row> </Row>
<br /> <br />
<Row> <Row>
<Col>Price:</Col> <Col>Price:</Col>
<Col>{props.token.usdPrice}</Col> <Col>{token.usdPrice}</Col>
</Row> </Row>
<br /> <br />
<Row> <Row>
<Col> <Col>
<InfoButton token={props.token} /> <InfoButton token={token} disabled={!token.tokenData} />
</Col> </Col>
<Col></Col> <Col />
<Col></Col> <Col />
</Row> </Row>
</Container> </Container>
@@ -69,7 +76,7 @@ function TokenCard (props) {
</> </>
) )
} }
// eslint-disable-next-line
{/* <Col> {/* <Col>
<InfoButton token={props.token} /> <InfoButton token={props.token} />
</Col> </Col>
@@ -80,6 +87,6 @@ function TokenCard (props) {
<Col> <Col>
<BuyNftButton appData={props.appData} offer={props.token} /> <BuyNftButton appData={props.appData} offer={props.token} />
</Col> */} </Col> */ }
export default TokenCard export default TokenCard