diff --git a/web/cashtab/src/components/Common/Alerts.js b/web/cashtab/src/components/Common/Alerts.js new file mode 100644 index 000000000..81790d5de --- /dev/null +++ b/web/cashtab/src/components/Common/Alerts.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { Alert } from 'antd'; + +const TokenIconAlert = () => { + return ( + <> +
+ +
+ + ); +}; + +export default TokenIconAlert; diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index ebb5a67d2..c43d6403b 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,432 +1,433 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { Form, message, Row, Col, Alert, Descriptions } from 'antd'; +import TokenIconAlert from '@components/Common/Alerts.js'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; import { FormItemWithMaxAddon, FormItemWithQRCodeAddon, } from '@components/Common/EnhancedInputs'; import useBCH from '@hooks/useBCH'; import BalanceHeader from '@components/Common/BalanceHeader'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from '@hooks/useWindowDimensions'; 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, parseAddress, isValidTokenPrefix, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { getWalletState, convertEtokenToSimpleledger, } from '@utils/cashMethods'; import ApiError from '@components/Common/ApiError'; import { sendTokenNotification, errorNotification, } from '@components/Common/Notifications'; const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => { const { wallet, apiError } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { tokens, slpBalancesAndUtxos } = walletState; const token = tokens.find(token => token.tokenId === tokenId); const [tokenStats, setTokenStats] = useState(null); const [queryStringText, setQueryStringText] = useState(null); const [sendTokenAddressError, setSendTokenAddressError] = useState(false); const [sendTokenAmountError, setSendTokenAmountError] = 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 = width < 769 && isMobile && !(isIOS && !isSafari); const [formData, setFormData] = useState({ dirty: true, value: '', address: '', }); const { getBCH, getRestUrl, sendToken, getTokenStats } = useBCH(); // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const BCH = jestBCH ? jestBCH : getBCH(); // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { getTokenStats(BCH, tokenId).then( result => { setTokenStats(result); }, err => { console.log(`Error getting token stats: ${err}`); }, ); } async function submit() { setFormData({ ...formData, dirty: false, }); 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]; // Convert to simpleledger prefix if etoken cleanAddress = convertEtokenToSimpleledger(cleanAddress); try { const link = await sendToken(BCH, wallet, slpBalancesAndUtxos, { tokenId: tokenId, tokenReceiverAddress: cleanAddress, amount: value, }); sendTokenNotification(link); } 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 = parseAddress(BCH, addressString, true); /* Model addressInfo = { address: '', isValid: false, queryString: '', amount: null, }; */ const { address, isValid, queryString } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = 'Address is not a valid etoken: address'; // If valid address but token format } else if (!isValidTokenPrefix(address)) { error = `Cashtab only supports sending to ${currency.tokenPrefixes[0]} prefixed addresses`; } 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', ); } }; useEffect(() => { // If the balance has changed, unlock the UI // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked passLoadingStatus(false); }, [token]); return ( <> {!token && } {token && ( <> - +
handleTokenAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.tokenTicker} 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} ) : ( submit()}> Send {token.info.tokenName} )}
{queryStringText && ( )} {apiError && } {tokenStats !== null && ( {token.info.decimals} {token.tokenId} {tokenStats && ( <> {tokenStats.documentUri} {tokenStats.timestampUnix !== null ? new Date( tokenStats.timestampUnix * 1000, ).toLocaleDateString() : 'Just now (Genesis tx confirming)'} {tokenStats.containsBaton ? 'No' : 'Yes'} {tokenStats.initialTokenQty.toLocaleString()} {tokenStats.totalBurned.toLocaleString()} {tokenStats.totalMinted.toLocaleString()} {tokenStats.circulatingSupply.toLocaleString()} )} )}
)} ); }; /* 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; diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap index 2ec09c0ab..aeb1c269c 100644 --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap @@ -1,242 +1,289 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances and tokens 1`] = `null`; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
6.001 TBS
, +
+
+ + + +
+
+
+ If you would like to request an icon for an eToken that has already been created, please email icons@e.cash. +
+
+
+
,
TBS max
, ] `; exports[`Without wallet defined 1`] = `null`; diff --git a/web/cashtab/src/components/Tokens/Tokens.js b/web/cashtab/src/components/Tokens/Tokens.js index d5cee5b71..48e6cbd14 100644 --- a/web/cashtab/src/components/Tokens/Tokens.js +++ b/web/cashtab/src/components/Tokens/Tokens.js @@ -1,125 +1,128 @@ import React from 'react'; import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { fromSmallestDenomination, getWalletState } from '@utils/cashMethods'; +import TokenIconAlert from '@components/Common/Alerts.js'; + import CreateTokenForm from '@components/Tokens/CreateTokenForm'; import { currency } from '@components/Common/Ticker.js'; import TokenList from '@components/Wallet/TokenList'; import useBCH from '@hooks/useBCH'; import BalanceHeader from '@components/Common/BalanceHeader'; import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; import { ZeroBalanceHeader, AlertMsg } from '@components/Common/Atoms'; import ApiError from '@components/Common/ApiError'; const Tokens = ({ jestBCH, passLoadingStatus }) => { /* Dev note This is the first new page created after the wallet migration to include state in storage As such, it will only load this type of wallet If any user is still migrating at this point, this page will display a loading spinner until their wallet has updated (ETA within 10 seconds) Going forward, this approach will be the model for Wallet, Send, and SendToken, as the legacy wallet state parameters not stored in the wallet object are deprecated */ const { wallet, apiError, fiatPrice, cashtabSettings } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { balances, tokens } = walletState; const { getBCH, getRestUrl, createToken } = useBCH(); // Support using locally installed bchjs for unit tests const BCH = jestBCH ? jestBCH : getBCH(); return ( <> {!balances.totalBalance ? ( <> You need some {currency.ticker} in your wallet to create tokens. ) : ( <> {fiatPrice !== null && !isNaN(balances.totalBalance) && ( )} )} + {apiError && } {balances.totalBalanceInSatoshis < currency.dustSats && ( You need at least{' '} {fromSmallestDenomination(currency.dustSats).toString()}{' '} {currency.ticker} ( {cashtabSettings ? `${ currency.fiatCurrencies[ cashtabSettings.fiatCurrency ].symbol } ` : '$ '} {( fromSmallestDenomination(currency.dustSats).toString() * fiatPrice ).toFixed(4)}{' '} {cashtabSettings ? `${currency.fiatCurrencies[ cashtabSettings.fiatCurrency ].slug.toUpperCase()} ` : 'USD'} ) to create a token )} {tokens && tokens.length > 0 ? ( <> ) : ( <>No {currency.tokenTicker} tokens in this wallet )} ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in Tokens.test.js status => {console.log(status)} is an arbitrary stub function */ Tokens.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; Tokens.propTypes = { jestBCH: PropTypes.object, passLoadingStatus: PropTypes.func, }; export default Tokens; diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap index be19e4d8d..415e43530 100644 --- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap +++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap @@ -1,407 +1,642 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [
You need some XEC in your wallet to create tokens.
,
0 XEC
, +
+
+ + + +
+
+
+ If you would like to request an icon for an eToken that has already been created, please email icons@e.cash. +
+
+
+
,
Create eToken
,

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, "No ", "eToken", " tokens in this wallet", ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [
You need some XEC in your wallet to create tokens.
,
0 XEC
, +
+
+ + + +
+
+
+ If you would like to request an icon for an eToken that has already been created, please email icons@e.cash. +
+
+
+
,
Create eToken
,

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, "No ", "eToken", " tokens in this wallet", ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
0.06 XEC
,
$ NaN USD
, +
+
+ + + +
+
+
+ If you would like to request an icon for an eToken that has already been created, please email icons@e.cash. +
+
+
+
,
Create eToken
,
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [
You need some XEC in your wallet to create tokens.
,
0 XEC
, +
+
+ + + +
+
+
+ If you would like to request an icon for an eToken that has already been created, please email icons@e.cash. +
+
+
+
,
Create eToken
,

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, "No ", "eToken", " tokens in this wallet", ] `; exports[`Without wallet defined 1`] = ` Array [
You need some XEC in your wallet to create tokens.
,
0 XEC
, +
+
+ + + +
+
+
+ If you would like to request an icon for an eToken that has already been created, please email icons@e.cash. +
+
+
+
,
Create eToken
,

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, "No ", "eToken", " tokens in this wallet", ] `;