diff --git a/src/components/app-body/counter-offers/cancel-counter-offer-btn.js b/src/components/app-body/counter-offers/cancel-counter-offer-btn.js new file mode 100644 index 0000000..4724a28 --- /dev/null +++ b/src/components/app-body/counter-offers/cancel-counter-offer-btn.js @@ -0,0 +1,42 @@ +/* + This component renders as a button. When clicked, it opens a modal that + cancels the counter offer. + + This is a functional component with as little state as possible. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Button, Modal, Container } from 'react-bootstrap' + +function CancelCounterOfferBtn (props) { + const [show, setShow] = useState(false) + + const handleClose = () => { + setShow(false) + // props.instance.setState({ showModal: false }) + } + + const handleOpen = () => { + setShow(true) + } + + return ( + <> + + + + Cancel + + + + {/** ... */} + + + + + + ) +} + +export default CancelCounterOfferBtn diff --git a/src/components/app-body/counter-offers/counter-offer-card.js b/src/components/app-body/counter-offers/counter-offer-card.js index d6641e5..d72f573 100644 --- a/src/components/app-body/counter-offers/counter-offer-card.js +++ b/src/components/app-body/counter-offers/counter-offer-card.js @@ -1,64 +1,79 @@ /* - This Card component displays a counter offer with token icon, name, and price. + This Card component summarizes an SLP token. + if a token icon does not exist or cant be loaded , then display a default icon from Jdenticon library. */ // Global npm libraries -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { Container, Row, Col, Card, Button } from 'react-bootstrap' import Jdenticon from '@chris.troutner/react-jdenticon' +// Local libraries +import InfoButton from './info-button' +import CancelCounterOfferBtn from './cancel-counter-offer-btn' function CounterOfferCard (props) { - const { offer } = props - const [icon, setIcon] = useState(offer.tokenIcon) + const { token } = props + const [icon, setIcon] = useState(token.icon) + + // Update icon state every token.icon changes + useEffect(() => { + setIcon(token.icon) + }, [token.icon]) return ( <> - - - {/** If the icon is loaded, display it */} - {icon && ( - { - setIcon(null) // Set the icon to null if it fails to load the image url. - }} - /> - )} + + + {/** If the icon is loaded, display it */ + icon && ( + { + 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 && ( -
- -
- )} - - -

{offer.ticker}

+ {/** If the icon is not loaded, display the Jdenticon */ + !icon && ( + + ) + } + +

{props.token.ticker}

- {/* {offer.tokenName} */} - Counter Offer UTXO + {props.token.name}
- - - {/* Price: */} - {offer.price} -
- - + + + + + + + + + +
diff --git a/src/components/app-body/counter-offers/index.js b/src/components/app-body/counter-offers/index.js index 3bf41a4..5b1eb96 100644 --- a/src/components/app-body/counter-offers/index.js +++ b/src/components/app-body/counter-offers/index.js @@ -1,89 +1,245 @@ /* - Shows Counter Offers created by the user. + This component displays the counter offers for the current wallet. */ // Global npm libraries -import React, { useState, useEffect } from 'react' -import { Container, Row, Col, Spinner } from 'react-bootstrap' - -// Local libraries +import React, { useState, useEffect, useCallback } from 'react' +import { Container, Row, Col, Spinner, Button } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import CounterOfferCard from './counter-offer-card' import AsyncLoad from '../../../services/async-load' +import { faRedo } from '@fortawesome/free-solid-svg-icons' +// Local libraries -function CounterOffers (props) { - const appData = props.appData - +const CounterOffers = (props) => { + const { appData } = props + const [iconsAreLoaded, setIconsAreLoaded] = useState(false) + const [dataAreLoaded, setDataAreLoaded] = useState(false) const [counterOffers, setCounterOffers] = useState([]) const [isLoading, setIsLoading] = useState(true) - // Generate counter offer cards + // 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 loads the token data . + const lazyLoadTokenData = useCallback(async (tokens) => { + try { + setDataAreLoaded(false) + // 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) + console.log('tokenData', tokenData) + 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 + thisToken.tokenId = tokenData.genesisData.tokenId + thisToken.ticker = tokenData.genesisData.ticker + thisToken.name = tokenData.genesisData.name + thisToken.decimals = tokenData.genesisData.decimals + thisToken.tokenType = tokenData.genesisData.type + thisToken.url = tokenData.genesisData.documentUri + } + + // Mark token to prevent fetch token data again. + thisToken.dataAlreadyDownloaded = true + } + + setDataAreLoaded(true) + } catch (error) { + setDataAreLoaded(true) + } + }, [appData]) + + // Fetch mutable data if it exist and get the token icon url + const fetchTokenMutableData = 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 + } + const userData = json.userData + // Return icon url + return { iconUrl, userData } + } catch (error) { + return false + } + }, [appData]) + + // This function loads the token icons from the ipfs gateways. + const lazyLoadMutableData = 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, userData } = await fetchTokenMutableData(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 + thisToken.userData = userData + } + + // Mark token to prevent fetch token icon again. + thisToken.iconAlreadyDownloaded = true + } + + setIconsAreLoaded(true) + } catch (error) { + setIconsAreLoaded(true) + } + }, [fetchTokenMutableData]) + + // Load the counter offers for the current wallet. + const loadData = useCallback(async () => { + try { + setIsLoading(true) + setCounterOffers([]) + // Get the wallet state and server url from the app data. + const { bchWalletState, serverUrl } = appData + // Create a new AsyncLoad instance. + const asyncLoad = new AsyncLoad() + // Load the wallet library. + await asyncLoad.loadWalletLib() + // Get the counter offer derivated wallet + const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1") + // Get the utxos from the counter offer wallet. + const utxos = await counterOfferWallet.getUtxos() + const bchUtxos = utxos.bchUtxos + // Get the counter offers data for the current wallet address. + const { counterOffers } = await asyncLoad.getCounterOffersByAddress(bchWalletState.cashAddress) + // Filter the counter offers to only include the ones that are in the utxos. + const filteredCounterOffers = counterOffers.filter(val => + bchUtxos.some(utxo => utxo.txid === val.counterOfferUtxo) + ) + + setCounterOffers(filteredCounterOffers) + setIsLoading(false) + // Load the token data for the counter offers in background. + await lazyLoadTokenData(filteredCounterOffers) + // Load the token icons for the counter offers in background. + await lazyLoadMutableData(filteredCounterOffers) + } catch (error) { + setIsLoading(false) + console.error('Error loading counter offers:', error) + } + }, [appData, lazyLoadTokenData, lazyLoadMutableData]) + + // Start to load the token icons when the component is mounted + useEffect(() => { + loadData() + }, [loadData]) + + // Handler for refresh button + const handleRefresh = useCallback(() => { + loadData() + }, [loadData]) + // Generate the token cards for each token in the wallet. const generateCards = () => { - return counterOffers.map((offer) => ( + return counterOffers.map(thisCounterOffer => ( )) } - useEffect(() => { - const loadWallet = async () => { - try { - setIsLoading(true) - const { bchWalletState, serverUrl } = appData - const asyncLoad = new AsyncLoad() - await asyncLoad.loadWalletLib() - const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1") - - const utxoStore = counterOfferWallet.utxos.utxoStore - const bchUtxos = utxoStore.bchUtxos - setCounterOffers(bchUtxos) - console.log('counterOffers', bchUtxos) - setIsLoading(false) - } catch (error) { - setIsLoading(false) - console.log('error', error) - } - } - - loadWallet() - }, [appData]) - return ( - - - -

Counter Offers

-

Your pending counter offers

- -
- {isLoading - ? ( - - - - Loading... - -

Loading counter offers...

- -
- ) - : ( - <> - - {generateCards()} - - {counterOffers.length === 0 && ( - - -

No counter offers found.

+ <> + + + + {!isLoading && ( + + + )} - + + + + {appData.asyncInitSucceeded && ( + + + {/** Show spinner info if tokens are loaded but data is not loaded */ + isLoading && ( +
+ Loading Counter Offers + +
+ ) + } + {/** Show spinner info if tokens are loaded but data is not loaded */ + !isLoading && !dataAreLoaded && counterOffers.length > 0 && ( +
+ Loading Token Data + +
+ ) + } + {/** Show spinner info if tokens are loaded but icons are not loaded */ + !isLoading && dataAreLoaded && !iconsAreLoaded && ( +
+ Loading Token Icons + +
+ ) + } + + )} -
+
+
+ + + {generateCards()} + + {/** Display a message if no tokens are found */} + {!isLoading && counterOffers.length === 0 && ( + + No tokens found in wallet + + )} + +
+ ) } diff --git a/src/components/app-body/counter-offers/info-button.js b/src/components/app-body/counter-offers/info-button.js new file mode 100644 index 0000000..b3e7235 --- /dev/null +++ b/src/components/app-body/counter-offers/info-button.js @@ -0,0 +1,146 @@ +/* + This component renders as a button. When clicked, it opens a modal that + displays information about the token. + + This is a functional component with as little state as possible. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap' + +// Takes a string as input. If it matches a pattern for a link, a JSX object is +// returned with a link. Otherwise the original string is returned. +function linkIfUrl (url) { + // Convert the URL into a link if it contains 'http' + if (url?.includes('http')) { + url = ({url}) + + // + } else if (url?.includes('ipfs://')) { + // Convert to a Filecoin link if its an IPFS reference. + + const cid = url.substring(7) + url = ({url}) + } + + return url +} + +function InfoButton (props) { + const [show, setShow] = useState(false) + const [mutableDataCid, setMutableDataCid] = useState(null) + + const handleClose = () => { + setShow(false) + // props.instance.setState({ showModal: false }) + } + + const handleOpen = () => { + setShow(true) + } + + // Convert the url property of the token to a link, if it matches common patterns. + let url = props.token.url + url = linkIfUrl(props.token.url) + + // console.log('props.token: ', props.token) + + // 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 + } + // Get token user data if it exists and verify if it contains media or markdown + useEffect(() => { + try { + console.log('props token', props.token) + const userDataStr = props.token.userData + if (userDataStr) { + const userData = JSON.parse(userDataStr) + + // If user data contains media or markdown, set the mutable data cid + if (userData?.media || userData?.markdown) { + setMutableDataCid(parseCid(props.token.tokenData.mutableData)) + } + } + } catch (error) { + // Do nothing + } + }, [props.token, show]) + + return ( + <> + + + + Token Information + + + + + Ticker: + {props.token.ticker} + + + + Name: + {props.token.name} + + + + Token ID: + + + {props.token.tokenId} + + + + + + Decimals: + {props.token.decimals} + + + + Token Type: + {props.token.tokenType} + + + + URL: + {url} + + {!props.token.iconAlreadyDownloaded && ( +
+ +
+ )} + {mutableDataCid && ( + + User Data: + + + + + )} +
+
+ +
+ + ) +} + +export default InfoButton diff --git a/src/services/async-load.js b/src/services/async-load.js index cac4adb..8a7c77e 100644 --- a/src/services/async-load.js +++ b/src/services/async-load.js @@ -15,6 +15,10 @@ import { bytesToHex } from '@noble/hashes/utils' // already an installed depende import { getPublicKey } from 'nostr-tools/pure' import * as nip19 from 'nostr-tools/nip19' +import config from '../config' + +const SERVER = `${config.dexServer}/` + class AsyncLoad { constructor () { this.BchWallet = false @@ -386,6 +390,21 @@ class AsyncLoad { throw error } } + + // Get the counter offers for a given address + async getCounterOffersByAddress (addr) { + try { + const options = { + method: 'GET', + url: `${SERVER}offer/list/counter-offer/${addr}` + } + const result = await axios.request(options) + return result.data + } catch (error) { + console.error('Error getting counter offers by address', error) + throw error + } + } } function sleep (ms) {