diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index e157942f0..7629997a2 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,786 +1,778 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { WalletContext } from 'utils/context'; import { Form, message, Row, Col, Alert, Descriptions, Modal, Button, Input, } from 'antd'; import PrimaryButton, { SecondaryButton, } from 'components/Common/PrimaryButton'; import { FireTwoTone } from '@ant-design/icons'; import { DestinationAmount, DestinationAddressSingle, AntdFormWrapper, } from 'components/Common/EnhancedInputs'; import useBCH from 'hooks/useBCH'; import { SidePaddingCtn } from 'components/Common/Atoms'; import BalanceHeaderToken from 'components/Common/BalanceHeaderToken'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from 'hooks/useWindowDimensions'; import usePrevious from 'hooks/usePrevious'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { Img } from 'react-image'; import makeBlockie from 'ethereum-blockies-base64'; import BigNumber from 'bignumber.js'; import { currency, parseAddressForParams } from 'components/Common/Ticker.js'; import { Event } from 'utils/GoogleAnalytics'; import { getWalletState } from 'utils/cashMethods'; import ApiError from 'components/Common/ApiError'; import { sendTokenNotification, errorNotification, burnTokenNotification, } from 'components/Common/Notifications'; import { isValidXecAddress, isValidEtokenAddress, isValidEtokenBurnAmount, } from 'utils/validation'; import { getTokenStats } from 'utils/chronik'; import { formatDate } from 'utils/formatting'; import styled, { css } from 'styled-components'; import TokenIcon from 'components/Tokens/TokenIcon'; const AntdDescriptionsCss = css` .ant-descriptions-item-label, .ant-input-number, .ant-descriptions-item-content { background-color: ${props => props.theme.contrast} !important; color: ${props => props.theme.dropdownText}; } .ant-descriptions-title { color: ${props => props.theme.lightWhite}; } `; const AntdDescriptionsWrapper = styled.div` ${AntdDescriptionsCss} `; const AirdropButton = styled.div` text-align: center; width: 100%; padding: 10px; border-radius: 5px; background: ${props => props.theme.sentMessage}; a { color: ${props => props.theme.darkBlue}; margin: 0; font-size: 11px; border: 1px solid ${props => props.theme.darkBlue}; border-radius: 5px; padding: 2px 10px; opacity: 0.6; } a:hover { opacity: 1; border-color: ${props => props.theme.eCashBlue}; color: ${props => props.theme.contrast}; background: ${props => props.theme.eCashBlue}; } ${({ received, ...props }) => received && ` text-align: left; background: ${props.theme.receivedMessage}; `} `; const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => { const { BCH, wallet, apiError, cashtabSettings, chronik } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { tokens } = walletState; const token = tokens.find(token => token.tokenId === tokenId); - const previousToken = usePrevious(token); + const previousWalletState = usePrevious(walletState); const [tokenStats, setTokenStats] = useState(null); const [queryStringText, setQueryStringText] = useState(null); const [sendTokenAddressError, setSendTokenAddressError] = useState(false); const [sendTokenAmountError, setSendTokenAmountError] = useState(false); const [eTokenBurnAmount, setETokenBurnAmount] = useState(new BigNumber(1)); const [showConfirmBurnEtoken, setShowConfirmBurnEtoken] = useState(false); const [burnTokenAmountError, setBurnTokenAmountError] = useState(false); const [burnConfirmationValid, setBurnConfirmationValid] = useState(null); const [confirmationOfEtokenToBeBurnt, setConfirmationOfEtokenToBeBurnt] = useState(''); const [bchObj, setBchObj] = useState(false); // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = cashtabSettings && cashtabSettings.autoCameraOn === true && width < 769 && isMobile && !(isIOS && !isSafari); const [isModalVisible, setIsModalVisible] = useState(false); const [formData, setFormData] = useState({ value: '', address: '', }); const { getRestUrl, sendToken, burnToken } = useBCH(); useEffect(() => { // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const activeBCH = jestBCH ? jestBCH : BCH; // set the BCH instance to state, for other functions to reference setBchObj(activeBCH); }, [BCH]); // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { getTokenStats(chronik, tokenId).then( result => { setTokenStats(result); }, err => { console.log(`Error getting token stats: ${err}`); }, ); } // Clears address and amount fields following sendTokenNotification const clearInputForms = () => { setFormData({ value: '', address: '', }); }; async function submit() { setFormData({ ...formData, }); if ( !formData.address || !formData.value || Number(formData.value <= 0) || sendTokenAmountError ) { return; } // Event("Category", "Action", "Label") // Track number of SLPA send transactions and // SLPA token IDs Event('SendToken.js', 'Send', tokenId); passLoadingStatus(true); const { address, value } = formData; // Clear params from address let cleanAddress = address.split('?')[0]; try { const link = await sendToken(bchObj, chronik, wallet, { tokenId: tokenId, tokenReceiverAddress: cleanAddress, amount: value, }); sendTokenNotification(link); clearInputForms(); } catch (e) { passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Sending eToken'); } } const handleSlpAmountChange = e => { let error = false; const { value, name } = e.target; // test if exceeds balance using BigNumber let isGreaterThanBalance = false; if (!isNaN(value)) { const bigValue = new BigNumber(value); // Returns 1 if greater, -1 if less, 0 if the same, null if n/a isGreaterThanBalance = bigValue.comparedTo(token.balance); } // Validate value for > 0 if (isNaN(value)) { error = 'Amount must be a number'; } else if (value <= 0) { error = 'Amount must be greater than 0'; } else if (token && token.balance && isGreaterThanBalance === 1) { error = `Amount cannot exceed your ${token.info.tokenTicker} balance of ${token.balance}`; } else if (!isNaN(value) && value.toString().includes('.')) { if (value.toString().split('.')[1].length > token.info.decimals) { error = `This token only supports ${token.info.decimals} decimal places`; } } setSendTokenAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const handleTokenAddressChange = e => { const { value, name } = e.target; // validate for token address // validate for parameters // show warning that query strings are not supported let error = false; let addressString = value; const addressInfo = parseAddressForParams(addressString); /* Model addressInfo = { address: '', queryString: '', amount: null, }; */ const { address, queryString } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); const isValid = isValidEtokenAddress(address) || isValidXecAddress(address); // Is this valid address? if (!isValid) { error = 'Invalid address'; } setSendTokenAddressError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear this error before updating field setSendTokenAmountError(false); try { let value = token.balance; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; const checkForConfirmationBeforeSendEtoken = () => { if (cashtabSettings.sendModal) { setIsModalVisible(cashtabSettings.sendModal); } else { // if the user does not have the send confirmation enabled in settings then send directly submit(); } }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; const handleEtokenBurnAmountChange = e => { const { value } = e.target; const burnAmount = new BigNumber(value); setETokenBurnAmount(burnAmount); let error = false; if (!isValidEtokenBurnAmount(burnAmount, token.balance)) { error = 'Burn amount must be between 1 and ' + token.balance; } setBurnTokenAmountError(error); }; const onMaxBurn = () => { setETokenBurnAmount(token.balance); // trigger validation on the inserted max value handleEtokenBurnAmountChange({ target: { value: token.balance, }, }); }; async function burn() { if ( !burnConfirmationValid || burnConfirmationValid === null || !eTokenBurnAmount ) { return; } // Event("Category", "Action", "Label") Event('SendToken.js', 'Burn eToken', tokenId); passLoadingStatus(true); try { const link = await burnToken(bchObj, chronik, wallet, { tokenId: tokenId, amount: eTokenBurnAmount, }); burnTokenNotification(link); clearInputForms(); setShowConfirmBurnEtoken(false); setConfirmationOfEtokenToBeBurnt(''); } catch (e) { setShowConfirmBurnEtoken(false); passLoadingStatus(false); setConfirmationOfEtokenToBeBurnt(''); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if (/dust/.test(e.error)) { message = 'Unable to burn due to insufficient eToken utxos.'; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Burning eToken'); } } const handleBurnConfirmationInput = e => { const { value } = e.target; if (value && value === `burn ${token.info.tokenTicker}`) { setBurnConfirmationValid(true); } else { setBurnConfirmationValid(false); } setConfirmationOfEtokenToBeBurnt(value); }; const handleBurnAmountInput = () => { if (!burnTokenAmountError) { setShowConfirmBurnEtoken(true); } }; - useEffect(() => { - /* - If the balance has changed, unlock the UI - - Note that the 'token' dependency changes every time the wallet state is set - This useEffect loop can't use token.balance as the dependency as this is - occasionally undefined, which the screen UI also makes use of - */ + // Unlock UI after user sends an eToken tx to their own wallet if ( - token && - token.balance && - previousToken && - previousToken.balance && - token.balance.toString() !== previousToken.balance.toString() + walletState && + walletState.balances && + walletState.balances.totalBalanceInSatoshis && + previousWalletState && + previousWalletState.balances && + previousWalletState.balances.totalBalanceInSatoshis && + walletState.balances.totalBalanceInSatoshis !== + previousWalletState.balances.totalBalanceInSatoshis ) { passLoadingStatus(false); } - // In the case of a tx that burns all of a token, also lose the loader - if (!token && previousToken) { - passLoadingStatus(false); - } - }, [token]); + }, [walletState]); return ( <>

{token && token.info && formData ? `Are you sure you want to send ${formData.value}${' '} ${token.info.tokenTicker} to ${formData.address}?` : ''}

{!token && } {token && ( {/* eToken burn modal */} setShowConfirmBurnEtoken(false)} >
} placeholder={`Type "burn ${token.info.tokenTicker}" to confirm`} name="etokenToBeBurnt" value={confirmationOfEtokenToBeBurnt} onChange={e => handleBurnConfirmationInput(e) } />
handleTokenAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `Address`, name: 'address', onChange: e => handleTokenAddressChange(e), required: true, value: formData.address, }} /> } /> ) : ( {`identicon ), suffix: token.info.tokenTicker, onChange: e => handleSlpAmountChange(e), required: true, value: formData.value, }} />
{apiError || sendTokenAmountError || sendTokenAddressError ? ( <> Send {token.info.tokenName} ) : ( checkForConfirmationBeforeSendEtoken() } > Send {token.info.tokenName} )}
{queryStringText && ( )} {apiError && } {tokenStats !== null && ( {token.info.decimals} {token.tokenId}
Airdrop XEC to holders
{tokenStats && ( <> { tokenStats.slpTxData .genesisInfo .tokenDocumentUrl } {tokenStats.block && tokenStats.block .timestamp !== null ? formatDate( tokenStats.block .timestamp, navigator.language, ) : 'Just now (Genesis tx confirming)'} {tokenStats.containsBaton ? 'No' : 'Yes'} {new BigNumber( tokenStats.initialTokenQuantity, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.tokenStats.totalBurned, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.tokenStats.totalMinted, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.circulatingSupply, ) .toFormat( token.info.decimals, ) .toLocaleString()} handleEtokenBurnAmountChange( e, ), initialvalue: 1, value: eTokenBurnAmount, prefix: ( ), }} /> )}
)}
)} ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in SendToken.test.js status => {console.log(status)} is an arbitrary stub function */ SendToken.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; SendToken.propTypes = { tokenId: PropTypes.string, jestBCH: PropTypes.object, passLoadingStatus: PropTypes.func, }; export default SendToken;