Merge pull request #6 from Permissionless-Software-Foundation/dh-token-view

feat(tokens): Ported slp-token View
This commit is contained in:
Chris Troutner
2025-02-11 08:15:51 -07:00
committed by GitHub
9 changed files with 690 additions and 9 deletions
+2
View File
@@ -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) {
<Route path='/balance' element={<GetBalance wallet={appData.wallet} />} />
<Route path='/bch' element={<BchSend appData={appData} />} />
<Route path='/wallet' element={<Wallet appData={appData} />} />
<Route path='/slp-tokens' element={<SlpTokens appData={appData} />} />
<Route path='/placeholder2' element={<Placeholder2 />} />
<Route path='/placeholder3' element={<Placeholder3 />} />
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
+169
View File
@@ -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 => (
<TokenCard
appData={appData}
token={thisToken}
key={`${thisToken.tokenId}`}
refreshTokens={refreshTokens}
/>
))
}
return (
<>
<Container>
<Row>
<Col xs={6}>
<RefreshTokenBalance
appData={appData}
ref={refreshTokenButtonRef}
lazyLoadTokenIcons={lazyLoadTokenIcons}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
<a href='https://youtu.be/f1no5-QHTr4' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
<Row>
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{
!iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
</Row>
<br />
<Row>
{generateCards()}
</Row>
{/** Display a message if no tokens are found */}
{tokens.length === 0 && (
<Row className='text-center'>
<span> No tokens found in wallet </span>
</Row>
)}
</Container>
</>
)
}
export default SlpTokens
@@ -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 = (<a href={url} target='_blank' rel='noreferrer'>{url}</a>)
//
} else if (url.includes('ipfs://')) {
// Convert to a Filecoin link if its an IPFS reference.
const cid = url.substring(7)
url = (<a href={`https://${cid}.ipfs.dweb.link/data.json`} target='_blank' rel='noreferrer'>{url}</a>)
}
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 (
<>
<Button variant='info' onClick={handleOpen}>Info</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Token Information</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col xs={4}><b>Ticker</b>:</Col>
<Col xs={8}>{props.token.ticker}</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Name</b>:</Col>
<Col xs={8}>{props.token.name}</Col>
</Row>
<Row>
<Col xs={4}><b>Token ID</b>:</Col>
<Col xs={8} style={{ wordBreak: 'break-all' }}>
<a href={`https://token.fullstack.cash/?tokenid=${props.token.tokenId}`} target='_blank' rel='noreferrer'>
{props.token.tokenId}
</a>
</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Decimals</b>:</Col>
<Col xs={8}>{props.token.decimals}</Col>
</Row>
<Row>
<Col xs={4}><b>Token Type</b>:</Col>
<Col xs={8}>{props.token.tokenType}</Col>
</Row>
<Row style={{ backgroundColor: '#eee', wordBreak: 'break-all' }}>
<Col xs={4}><b>URL</b>:</Col>
<Col xs={8}>{url}</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
</>
)
}
export default InfoButton
@@ -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 (
<>
<Button variant='success' onClick={handleRefreshTokenBalance}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
{!hideWaitingModal && (
<WaitingModal
heading='Refreshing Token List'
body={modalBody}
hideSpinner={hideSpinner}
/>
)}
</>
)
}
export default RefreshTokenBalance
@@ -0,0 +1,229 @@
/*
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(<p>Success! <a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>See on Block Explorer</a></p>)
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)
}
}
// Load address from clipboard
const pasteFromClipboard = async () => {
try {
const address = await appData.appUtil.readFromClipboard()
setSendToAddress(address)
} catch (err) {
console.warn('Error pasting from clipboard: ', err)
}
}
// Modal JSX
const getModal = () => {
return (
<Modal show={showModal} size='lg' onHide={handleCloseModal}>
<Modal.Header closeButton>
<Modal.Title><FontAwesomeIcon icon={faPaperPlane} size='lg' /> Send Tokens: <span style={{ color: 'red' }}>{token.ticker}</span></Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
{/* ... existing Modal.Body content ... */}
<Row>
<Col style={{ textAlign: 'center' }}>
<b>SLP Address:</b>
</Col>
</Row>
<Row>
<Col xs={10}>
<Form>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
placeholder='simpleledger:qqlrzp23w08434twmvr4fxw672whkjy0pyxpgpyg0n'
onChange={handleUpdateSendToAddr}
value={sendToAddress}
/>
</Form.Group>
</Form>
</Col>
<Col xs={2}>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={faPaste}
size='lg'
onClick={pasteFromClipboard}
/>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<b>Amount:</b>
</Col>
</Row>
<Row>
<Col xs={10}>
<Form style={{ paddingBottom: '10px' }}>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
onChange={e => setSendQtyStr(e.target.value)}
value={sendQtyStr}
/>
</Form.Group>
</Form>
</Col>
<Col xs={2}>
<Button onClick={handleGetMax}>Max</Button>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<Button onClick={handleSendTokens}>Send</Button>
</Col>
</Row>
<br />
{showAddrWarning && (
<>
<Row>
<Col style={{ textAlign: 'center' }}>
<p style={{ color: 'orange' }}>
<b>Warning</b>: 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.
</p>
</Col>
</Row>
<br />
</>
)}
<Row>
<Col xs={10}>
{statusMsg}
</Col>
<Col xs={2}>
{!hideSpinner && <Spinner animation='border' />}
</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
}
return (
<>
<Button variant='info' onClick={handleShowModal}>Send</Button>
{showModal && getModal()}
</>
)
}
export default SendTokenButton
@@ -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 (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
{/** If the icon is loaded, display it */
icon && (
<Card.Img
src={icon}
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' }}>
<h4>{props.token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
{props.token.name}
</Col>
</Row>
<br />
<Row>
<Col>Balance:</Col>
<Col>{props.token.qty}</Col>
</Row>
<br />
<Row>
<Col>
<InfoButton token={props.token} />
</Col>
<Col>
<SendTokenButton
token={props.token}
appData={props.appData}
refreshTokens={props.refreshTokens}
/>
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</>
)
}
export default TokenCard
+8 -1
View File
@@ -59,10 +59,17 @@ function NavMenu (props) {
>
Wallet
</NavLink>
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
onClick={handleClickEvent}
>
Tokens
</NavLink>
<NavLink
className={currentPath === '/Placeholder2' ? 'nav-link-active' : 'nav-link-inactive'}
to='/Placeholder2'
onClick={(e) => handleClickEvent(1)}
onClick={handleClickEvent}
>
Placeholder2
</NavLink>
+1
View File
@@ -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()
-8
View File
@@ -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 = ''// (<Jdenticon size='100' value={x.tokenId} />)
x.iconNeedsDownload = true
return true
})
console.log('slpTokens: ', slpTokens)
// Update the state of the wallet with the balances