diff --git a/src/components/app-body/index.js b/src/components/app-body/index.js index db46857..cfe441e 100644 --- a/src/components/app-body/index.js +++ b/src/components/app-body/index.js @@ -18,6 +18,7 @@ import Placeholder3 from './placeholder3' import ServerSelectView from './servers/select-server-view' import SelectServerButton from './servers/select-server-button' import BchSend from './bch-send' +import SlpTokens from './slp-tokens' function AppBody (props) { // Dependency injection through props @@ -30,6 +31,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/app-body/slp-tokens/index.js b/src/components/app-body/slp-tokens/index.js new file mode 100644 index 0000000..81c3939 --- /dev/null +++ b/src/components/app-body/slp-tokens/index.js @@ -0,0 +1,169 @@ +/* + This is the 'Token View'. It displays the SLP tokens in the wallet. +*/ + +// Global npm libraries +import React, { useState, useEffect, useCallback } from 'react' +import { Container, Row, Col, Spinner } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons' + +// Local libraries +import TokenCard from './token-card' +import RefreshTokenBalance from './refresh-tokens' + +const SlpTokens = (props) => { + const [appData, setAppData] = useState(props.appData) + const [iconsAreLoaded, setIconsAreLoaded] = useState(false) + const [tokens, setTokens] = useState([]) + + const refreshTokenButtonRef = React.useRef() + + // Update the tokens state when the appData changes + useEffect(() => { + setTokens(appData.bchWalletState.slpTokens) + }, [appData]) + + // This function is triggered when the token balance needs to be refreshed + // from the blockchain. + // This needs to happen after sending a token, to reflect the changed balance + // within the wallet app. + // This function triggers the on-click function within the refresh-tokens.js button. + const refreshTokens = async () => { + const newAppData = await refreshTokenButtonRef.current.handleRefreshTokenBalance() + setAppData(newAppData) + } + + // 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 + } + + // Fetch mutable data if it exist and get the token icon url + const fetchTokenIcon = useCallback(async (token) => { + try { + // Get the token data + const tokenData = await appData.wallet.getTokenData(token.tokenId) + 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 }) + + if (!json) return false + + const iconUrl = json.tokenIcon + // Return icon url + return iconUrl + } catch (error) { + return false + } + }, [appData]) + + // This function loads the token icons from the ipfs gateways. + const lazyLoadTokenIcons = useCallback(async () => { + try { + setIconsAreLoaded(false) + + const tokens = appData.bchWalletState.slpTokens + + setTokens(tokens) // update token state + + // 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 fetchTokenIcon(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 + } + + appData.updateBchWalletState({ walletObj: { slpTokens: tokens }, appData }) + setIconsAreLoaded(true) + } catch (error) { + setIconsAreLoaded(true) + } + }, [appData, fetchTokenIcon]) + + // Start to load the token icons when the component is mounted + useEffect(() => { + lazyLoadTokenIcons() + }, [lazyLoadTokenIcons]) + + // Generate the token cards for each token in the wallet. + const generateCards = () => { + const tokens = appData.bchWalletState.slpTokens + return tokens.map(thisToken => ( + + )) + } + + return ( + <> + + + + + + + + + + + + + + { + !iconsAreLoaded && ( +
+ Loading Token Icons + +
+ ) + } + + +
+
+ + + {generateCards()} + + {/** Display a message if no tokens are found */} + {tokens.length === 0 && ( + + No tokens found in wallet + + )} + +
+ + ) +} + +export default SlpTokens diff --git a/src/components/app-body/slp-tokens/info-button.js b/src/components/app-body/slp-tokens/info-button.js new file mode 100644 index 0000000..8fed8ac --- /dev/null +++ b/src/components/app-body/slp-tokens/info-button.js @@ -0,0 +1,98 @@ +/* + 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 } from 'react' +import { Button, Modal, Container, Row, Col } 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 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) + + 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} + + + + + + + ) +} + +export default InfoButton diff --git a/src/components/app-body/slp-tokens/refresh-tokens.js b/src/components/app-body/slp-tokens/refresh-tokens.js new file mode 100644 index 0000000..80a2a20 --- /dev/null +++ b/src/components/app-body/slp-tokens/refresh-tokens.js @@ -0,0 +1,100 @@ +/* + This component is displayed as a button. When clicked, it displays a modal + with a spinny gif, while the wallets SLP token list is updated from the + blockchain and psf-slp-indexer. +*/ + +// Global npm libraries +import React, { useState, useEffect, useCallback } from 'react' +import { Button } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faRedo } from '@fortawesome/free-solid-svg-icons' + +// Local libraries +import WaitingModal from '../../waiting-modal' + +function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons }) { + const [appData, setAppData] = useState(initialAppData) + const [modalBody, setModalBody] = useState([]) + const [hideSpinner, setHideSpinner] = useState(false) + const [hideWaitingModal, setHideWaitingModal] = useState(true) + + // Add a new line to the waiting modal. + const addToModal = (inStr) => { + setModalBody(prevBody => [...prevBody, inStr]) + } + + // Update the balance of the wallet. + const handleRefreshTokenBalance = useCallback(async () => { + try { + // Throw up the waiting modal + setHideWaitingModal(false) + addToModal('Updating token balance...') + + // Get handles on app data. + const walletState = appData.bchWalletState + const wallet = appData.wallet + + // Update the wallet UTXOs + await wallet.initialize() + const tokenList = await wallet.listTokens() + + // Copy tokens from old token state. + for (let i = 0; i < tokenList.length; i++) { + const thisToken = tokenList[i] + + // Look through the existing wallet state for the matching token. + const existingToken = walletState.slpTokens.filter(x => x.tokenId === thisToken.tokenId) + + // If the current wallet state has an icon, copy it over. + if (existingToken[0] && existingToken[0].icon) { + thisToken.icon = existingToken[0].icon + } + } + + // Update the wallet state. + walletState.slpTokens = tokenList + + appData.updateBchWalletState({ walletObj: walletState, appData }) + + const newAppData = { ...appData, bchWalletState: walletState } + // Update state + setHideWaitingModal(true) + setAppData(newAppData) + setModalBody([]) + + // Lazy load icons for any new tokens. + await lazyLoadTokenIcons() + + return newAppData + } catch (err) { + console.error('Error while trying to update BCH balance: ', err) + setModalBody([`Error: ${err.message}`]) + setHideSpinner(true) + } + }, [appData, lazyLoadTokenIcons]) + + // add a ref to the handleRefreshBalance function + // This is used to call the function from the parent component. + useEffect(() => { + if (ref && !ref.current) ref.current = { handleRefreshTokenBalance } + }, [ref, handleRefreshTokenBalance]) + + return ( + <> + + + {!hideWaitingModal && ( + + )} + + ) +} + +export default RefreshTokenBalance diff --git a/src/components/app-body/slp-tokens/send-token-button.js b/src/components/app-body/slp-tokens/send-token-button.js new file mode 100644 index 0000000..c3b89da --- /dev/null +++ b/src/components/app-body/slp-tokens/send-token-button.js @@ -0,0 +1,218 @@ +/* + This component renders as a button. When clicked, it opens up a modal + for sending a quantity of tokens. + This component requires state, because it's a complex form that is being manipulated + by the user. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Button, Modal, Container, Row, Col, Form, Spinner } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPaperPlane, faPaste } from '@fortawesome/free-solid-svg-icons' + +function SendTokenButton ({ token, appData, refreshTokens }) { + // Convert class state to useState hooks + const [showAddrWarning, setShowAddrWarning] = useState(false) + const [showModal, setShowModal] = useState(false) + const [statusMsg, setStatusMsg] = useState('') + const [hideSpinner, setHideSpinner] = useState(true) + const [shouldRefreshOnModalClose, setShouldRefreshOnModalClose] = useState(false) + const [sendToAddress, setSendToAddress] = useState('') + const [sendQtyStr, setSendQtyStr] = useState('') + const [dialogFinished, setDialogFinished] = useState(true) + + // Handler functions + const handleShowModal = () => setShowModal(true) + + const handleCloseModal = async () => { + if (!dialogFinished) return + + if (shouldRefreshOnModalClose) { + setShowModal(false) + setShouldRefreshOnModalClose(false) + setStatusMsg('') + await refreshTokens() + } else { + setShowModal(false) + setStatusMsg('') + setSendToAddress('') + setSendQtyStr('') + } + } + const handleUpdateSendToAddr = (event) => { + const value = event.target.value + setSendToAddress(value) + setShowAddrWarning(value.includes('bitcoincash')) + } + const handleGetMax = () => { + setSendQtyStr(token.qty) + } + + // Click handler that fires when the user clicks the 'Send' button. + + const handleSendTokens = async () => { + try { + setStatusMsg('Preparing to send tokens...') + setHideSpinner(false) + setDialogFinished(false) + setShowAddrWarning(false) + + // Validate the quantity + const qty = parseFloat(sendQtyStr) + if (isNaN(qty)) throw new Error('Invalid send quantity') + + const wallet = appData.wallet + const bchjs = wallet.bchjs + + // Validate the address + let addr = sendToAddress + if (addr.includes('simpleledger')) { + addr = bchjs.SLP.Address.toCashAddress(addr) + } + if (!addr.includes('bitcoincash')) throw new Error('Invalid address') + + let infoStr = 'Updating UTXOs...' + + setStatusMsg(infoStr) + await wallet.getUtxos() + + const receiver = [{ + address: addr, + tokenId: token.tokenId, + qty + }] + + infoStr = 'Generating and broadcasting transaction...' + setStatusMsg(infoStr) + + const txid = await wallet.sendTokens(receiver, 3) + console.log(`Token sent. TXID: ${txid}`) + + setStatusMsg(

Success! See on Block Explorer

) + setHideSpinner(true) + setSendQtyStr('') + setSendToAddress('') + setShouldRefreshOnModalClose(true) + setDialogFinished(true) + } catch (err) { + console.error('Error in handleSendTokens(): ', err) + setStatusMsg(`Error sending tokens: ${err.message}`) + setHideSpinner(true) + setDialogFinished(true) + } + } + + // Modal JSX + const getModal = () => { + return ( + + + Send Tokens: {token.ticker} + + + + {/* ... existing Modal.Body content ... */} + + + SLP Address: + + + + + +
+ + + +
+ + + + { /** paste from clipboard */ }} + /> + +
+
+ + + Amount: + + + + + +
+ + setSendQtyStr(e.target.value)} + value={sendQtyStr} + /> + +
+ + + + + +
+
+ + + + + + +
+ + {showAddrWarning && ( + <> + + +

+ Warning: Careful! Not all Bitcoin Cash wallets are token-aware. + If you send this token to a wallet that is not + token-aware, it could be burned. It's best practice to + only send tokens to 'simpleledger' addresses and not + 'bitcoincash' addresses. +

+ +
+
+ + )} + + + {statusMsg} + + + + {!hideSpinner && } + + + +
+
+ +
+ ) + } + + return ( + <> + + {showModal && getModal()} + + ) +} + +export default SendTokenButton diff --git a/src/components/app-body/slp-tokens/token-card.js b/src/components/app-body/slp-tokens/token-card.js new file mode 100644 index 0000000..2ceaa3a --- /dev/null +++ b/src/components/app-body/slp-tokens/token-card.js @@ -0,0 +1,83 @@ +/* + 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, useEffect } from 'react' +import { Container, Row, Col, Card } from 'react-bootstrap' +import Jdenticon from '@chris.troutner/react-jdenticon' +// Local libraries +import InfoButton from './info-button' +import SendTokenButton from './send-token-button' + +function TokenCard (props) { + 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 not loaded, display the Jdenticon */ + !icon && ( + + ) + } + +

{props.token.ticker}

+
+ + + + + {props.token.name} + + +
+ + + Balance: + {props.token.qty} + +
+ + + + + + + + + +
+
+
+ + + ) +} + +export default TokenCard diff --git a/src/components/nav-menu/index.js b/src/components/nav-menu/index.js index 5723fb7..50f9bfd 100644 --- a/src/components/nav-menu/index.js +++ b/src/components/nav-menu/index.js @@ -59,10 +59,17 @@ function NavMenu (props) { > Wallet + + Tokens + handleClickEvent(1)} + onClick={handleClickEvent} > Placeholder2 diff --git a/src/hooks/state.js b/src/hooks/state.js index 15c6e72..d3ffb8e 100644 --- a/src/hooks/state.js +++ b/src/hooks/state.js @@ -4,6 +4,7 @@ import useLocalStorageState from 'use-local-storage-state' import AppUtil from '../util' import { useLocation } from 'react-router-dom' + function useAppState () { const location = useLocation() diff --git a/src/services/async-load.js b/src/services/async-load.js index f5a0b45..03be3df 100644 --- a/src/services/async-load.js +++ b/src/services/async-load.js @@ -4,7 +4,6 @@ // Global npm libraries import axios from 'axios' - // Local libraries import GistServers from './gist-servers' @@ -112,13 +111,6 @@ class AsyncLoad { const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress) // console.log('slpTokens: ', slpTokens) - // Add an icon property to each token. - slpTokens.map(x => { - x.icon = ''// () - x.iconNeedsDownload = true - return true - }) - console.log('slpTokens: ', slpTokens) // Update the state of the wallet with the balances