diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index 7ad322195..1e42ccf83 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,478 +1,479 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { Form, message, Row, Col, Alert, Descriptions, Popover } from 'antd'; import TokenIconAlert from '@components/Common/Alerts.js'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; import { DestinationAmount, DestinationAddressSingle, } 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, parseAddressForParams } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { getWalletState, toLegacyToken } from '@utils/cashMethods'; import ApiError from '@components/Common/ApiError'; import { sendTokenNotification, errorNotification, } from '@components/Common/Notifications'; import { isValidXecAddress, isValidEtokenAddress } from '@utils/validation'; import { formatDate } from '@utils/formatting'; 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({ 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}`); }, ); } // 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]; // Convert to simpleledger prefix if etoken cleanAddress = toLegacyToken(cleanAddress); try { const link = await sendToken(BCH, wallet, slpBalancesAndUtxos, { 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 isValid = isValidEtokenAddress(addressString); 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); // Is this valid address? if (!isValid) { error = 'Address is not a valid etoken: address'; // If valid address but xec format if (isValidXecAddress(address)) { error = `Cashtab does not support sending eTokens to XEC addresses. Please convert to an eToken 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', ); } }; 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 && ( {currency.tokenIconsUrl !== '' ? ( } trigger="click" color="transparent" > {`identicon } /> ) : ( {`identicon )} {token.info.decimals} {token.tokenId} {tokenStats && ( <> {tokenStats.documentUri} {tokenStats.timestampUnix !== null ? formatDate( tokenStats.timestampUnix, + navigator.language, ) : '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/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js index 8578561cb..dce495da8 100644 --- a/web/cashtab/src/components/Wallet/Tx.js +++ b/web/cashtab/src/components/Wallet/Tx.js @@ -1,495 +1,495 @@ import React from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { ArrowUpOutlined, ArrowDownOutlined, ExperimentOutlined, ExclamationOutlined, } from '@ant-design/icons'; import { currency } from '@components/Common/Ticker'; import makeBlockie from 'ethereum-blockies-base64'; import { Img } from 'react-image'; import { fromLegacyDecimals } from '@utils/cashMethods'; import { formatBalance, formatDate } from '@utils/formatting'; const SentTx = styled(ArrowUpOutlined)` color: ${props => props.theme.secondary} !important; `; const ReceivedTx = styled(ArrowDownOutlined)` color: ${props => props.theme.primary} !important; `; const GenesisTx = styled(ExperimentOutlined)` color: ${props => props.theme.primary} !important; `; const UnparsedTx = styled(ExclamationOutlined)` color: ${props => props.theme.primary} !important; `; const DateType = styled.div` text-align: left; padding: 12px; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const OpReturnType = styled.span` text-align: left; width: 300%; max-height: 200px; padding: 3px; margin: auto; word-break: break-word; padding-left: 13px; padding-right: 30px; /* invisible scrollbar */ overflow: hidden; height: 100%; margin-right: -50px; /* Maximum width of scrollbar */ padding-right: 50px; /* Maximum width of scrollbar */ overflow-y: scroll; ::-webkit-scrollbar { display: none; } `; const SentLabel = styled.span` font-weight: bold; color: ${props => props.theme.secondary} !important; `; const ReceivedLabel = styled.span` font-weight: bold; color: ${props => props.theme.primary} !important; `; const CashtabMessageLabel = styled.span` text-align: left; font-weight: bold; color: ${props => props.theme.primary} !important; white-space: nowrap; `; const EncryptionMessageLabel = styled.span` text-align: left; font-weight: bold; color: red; white-space: nowrap; `; const UnauthorizedDecryptionMessage = styled.span` text-align: left; color: red; white-space: nowrap; font-style: italic; `; const MessageLabel = styled.span` text-align: left; font-weight: bold; color: ${props => props.theme.secondary} !important; white-space: nowrap; `; const ReplyMessageLabel = styled.span` color: ${props => props.theme.primary} !important; `; const TxIcon = styled.div` svg { width: 32px; height: 32px; } height: 32px; width: 32px; @media screen and (max-width: 500px) { svg { width: 24px; height: 24px; } height: 24px; width: 24px; } `; const TxInfo = styled.div` padding: 12px; font-size: 1rem; text-align: right; color: ${props => props.outgoing ? props.theme.secondary : props.theme.primary}; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const TxFiatPrice = styled.span` font-size: 0.8rem; `; const TokenInfo = styled.div` display: grid; grid-template-rows: 50%; grid-template-columns: 24px auto; padding: 12px; font-size: 1rem; color: ${props => props.outgoing ? props.theme.secondary : props.theme.primary}; @media screen and (max-width: 500px) { font-size: 0.8rem; grid-template-columns: 16px auto; } `; const TxTokenIcon = styled.div` img { height: 24px; width: 24px; } @media screen and (max-width: 500px) { img { height: 16px; width: 16px; } } grid-column-start: 1; grid-column-end: span 1; grid-row-start: 1; grid-row-end: span 2; align-self: center; `; const TokenTxAmt = styled.div` padding-left: 12px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TokenName = styled.div` padding-left: 12px; font-size: 0.8rem; @media screen and (max-width: 500px) { font-size: 0.6rem; } text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TxWrapper = styled.div` display: grid; grid-template-columns: 36px 30% 50%; justify-content: space-between; align-items: center; padding: 15px 25px; border-radius: 16px; background: ${props => props.theme.tokenListItem.background}; margin-bottom: 12px; box-shadow: ${props => props.theme.tokenListItem.boxShadow}; :hover { transform: translateY(-2px); box-shadow: rgb(136 172 243 / 25%) 0px 10px 30px, rgb(0 0 0 / 3%) 0px 1px 1px, rgb(0 51 167 / 10%) 0px 10px 20px; transition: all 0.8s cubic-bezier(0.075, 0.82, 0.165, 1) 0s; } @media screen and (max-width: 500px) { grid-template-columns: 24px 30% 50%; padding: 12px 12px; } `; const Tx = ({ data, fiatPrice, fiatCurrency }) => { const txDate = typeof data.blocktime === 'undefined' ? formatDate() - : formatDate(data.blocktime); + : formatDate(data.blocktime, navigator.language); // if data only includes height and txid, then the tx could not be parsed by cashtab // render as such but keep link to block explorer let unparsedTx = false; if (!Object.keys(data).includes('outgoingTx')) { unparsedTx = true; } return ( <> {unparsedTx ? ( Unparsed
{txDate}
Open in Explorer
) : ( {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo.transactionType === 'GENESIS' ? ( ) : ( )} ) : ( )} {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo.transactionType === 'GENESIS' ? ( Genesis ) : ( Sent )} ) : ( Received )}
{txDate}
{data.tokenTx ? ( {data.tokenTx && data.tokenInfo ? ( <> {currency.tokenIconsUrl !== '' ? ( {`identicon } /> ) : ( {`identicon )} {data.outgoingTx ? ( <> {data.tokenInfo.transactionType === 'GENESIS' ? ( <> +{' '} {data.tokenInfo.qtyReceived.toString()}   { data.tokenInfo .tokenTicker } { data.tokenInfo .tokenName } ) : ( <> -{' '} {data.tokenInfo.qtySent.toString()}   { data.tokenInfo .tokenTicker } { data.tokenInfo .tokenName } )} ) : ( <> +{' '} {data.tokenInfo.qtyReceived.toString()}   {data.tokenInfo.tokenTicker} {data.tokenInfo.tokenName} )} ) : ( Token Tx )} ) : ( <> {data.outgoingTx ? ( <> -{' '} {formatBalance( fromLegacyDecimals(data.amountSent), )}{' '} {currency.ticker}
{fiatPrice !== null && !isNaN(data.amountSent) && ( -{' '} { currency.fiatCurrencies[ fiatCurrency ].symbol } {( fromLegacyDecimals( data.amountSent, ) * fiatPrice ).toFixed(2)}{' '} { currency.fiatCurrencies .fiatCurrency } )} ) : ( <> +{' '} {formatBalance( fromLegacyDecimals( data.amountReceived, ), )}{' '} {currency.ticker}
{fiatPrice !== null && !isNaN(data.amountReceived) && ( +{' '} { currency.fiatCurrencies[ fiatCurrency ].symbol } {( fromLegacyDecimals( data.amountReceived, ) * fiatPrice ).toFixed(2)}{' '} { currency.fiatCurrencies .fiatCurrency } )} )}
)} {data.opReturnMessage && ( <>
{data.isCashtabMessage ? ( Cashtab Message ) : ( External Message )} {data.isEncryptedMessage ? (  - Encrypted ) : ( '' )}
{/*unencrypted OP_RETURN Message*/} {data.opReturnMessage && !data.isEncryptedMessage ? Buffer.from( data.opReturnMessage, ).toString() : ''} {/*encrypted and wallet is authorized to view OP_RETURN Message*/} {data.opReturnMessage && data.isEncryptedMessage && data.decryptionSuccess ? Buffer.from( data.opReturnMessage, ).toString() : ''} {/*encrypted but wallet is not authorized to view OP_RETURN Message*/} {data.opReturnMessage && data.isEncryptedMessage && !data.decryptionSuccess ? ( {Buffer.from( data.opReturnMessage, ).toString()} ) : ( '' )} {!data.outgoingTx && data.replyAddress ? (

Reply To Message ) : ( '' )}
)}
)} ); }; Tx.propTypes = { data: PropTypes.object, fiatPrice: PropTypes.number, fiatCurrency: PropTypes.string, }; export default Tx; diff --git a/web/cashtab/src/utils/__tests__/formatting.test.js b/web/cashtab/src/utils/__tests__/formatting.test.js index 221f86042..d8ce63c99 100644 --- a/web/cashtab/src/utils/__tests__/formatting.test.js +++ b/web/cashtab/src/utils/__tests__/formatting.test.js @@ -1,140 +1,140 @@ import { formatDate, formatFiatBalance, formatSavedBalance, formatBalance, } from '@utils/formatting'; describe('Correctly executes formatting functions', () => { it(`test formatBalance with an input of 0`, () => { expect(formatBalance('0')).toBe('0'); }); it(`test formatBalance with zero XEC balance input`, () => { expect(formatBalance('0', 'en-US')).toBe('0'); }); it(`test formatBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatBalance('1574.5445', 'en-US')).toBe('1,574.54'); }); it(`test formatBalance with 1 Million XEC balance input`, () => { expect(formatBalance('1000000', 'en-US')).toBe('1,000,000'); }); it(`test formatBalance with 1 Billion XEC balance input`, () => { expect(formatBalance('1000000000', 'en-US')).toBe('1,000,000,000'); }); it(`test formatBalance with total supply as XEC balance input`, () => { expect(formatBalance('21000000000000', 'en-US')).toBe( '21,000,000,000,000', ); }); it(`test formatBalance with > total supply as XEC balance input`, () => { expect(formatBalance('31000000000000', 'en-US')).toBe( '31,000,000,000,000', ); }); it(`test formatBalance with no balance`, () => { expect(formatBalance('', 'en-US')).toBe('0'); }); it(`test formatBalance with null input`, () => { expect(formatBalance(null, 'en-US')).toBe('0'); }); it(`test formatBalance with undefined as input`, () => { expect(formatBalance(undefined, 'en-US')).toBe('NaN'); }); it(`test formatBalance with non-numeric input`, () => { expect(formatBalance('CainBCHA', 'en-US')).toBe('NaN'); }); it(`Accepts a valid unix timestamp`, () => { - expect(formatDate('1639679649')).toBe('Dec 16, 2021'); + expect(formatDate('1639679649', 'fr')).toBe('16 déc. 2021'); }); it(`Accepts an empty string and generates a new timestamp`, () => { - expect(formatDate('')).toBe( - new Date().toLocaleDateString(undefined, { + expect(formatDate('', 'en-US')).toBe( + new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }), ); }); it(`Accepts no parameter and generates a new timestamp`, () => { - expect(formatDate()).toBe( - new Date().toLocaleDateString(undefined, { + expect(formatDate(null, 'en-US')).toBe( + new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }), ); }); it(`Accepts 'undefined' as a parameter and generates a new date`, () => { - expect(formatDate(undefined)).toBe( - new Date().toLocaleDateString(undefined, { + expect(formatDate(undefined, 'en-US')).toBe( + new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }), ); }); it(`Rejects an invalid string containing letters.`, () => { - expect(formatDate('f')).toBe('Invalid Date'); + expect(formatDate('f', 'en-US')).toBe('Invalid Date'); }); it(`Rejects an invalid string containing numbers.`, () => { - expect(formatDate('10000000000000000')).toBe('Invalid Date'); + expect(formatDate('10000000000000000', 'en-US')).toBe('Invalid Date'); }); it(`test formatSavedBalance with zero XEC balance input`, () => { expect(formatSavedBalance('0', 'en-US')).toBe('0'); }); it(`test formatSavedBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatSavedBalance('1574.5445', 'en-US')).toBe('1,574.54'); }); it(`test formatSavedBalance with 1 Million XEC balance input`, () => { expect(formatSavedBalance('1000000', 'en-US')).toBe('1,000,000'); }); it(`test formatSavedBalance with 1 Billion XEC balance input`, () => { expect(formatSavedBalance('1000000000', 'en-US')).toBe('1,000,000,000'); }); it(`test formatSavedBalance with total supply as XEC balance input`, () => { expect(formatSavedBalance('21000000000000', 'en-US')).toBe( '21,000,000,000,000', ); }); it(`test formatSavedBalance with > total supply as XEC balance input`, () => { expect(formatSavedBalance('31000000000000', 'en-US')).toBe( '31,000,000,000,000', ); }); it(`test formatSavedBalance with no balance`, () => { expect(formatSavedBalance('', 'en-US')).toBe('0'); }); it(`test formatSavedBalance with null input`, () => { expect(formatSavedBalance(null, 'en-US')).toBe('0'); }); it(`test formatSavedBalance with undefined sw.state.balance or sw.state.balance.totalBalance as input`, () => { expect(formatSavedBalance(undefined, 'en-US')).toBe('N/A'); }); it(`test formatSavedBalance with non-numeric input`, () => { expect(formatSavedBalance('CainBCHA', 'en-US')).toBe('NaN'); }); it(`test formatFiatBalance with zero XEC balance input`, () => { expect(formatFiatBalance(Number('0'), 'en-US')).toBe('0.00'); }); it(`test formatFiatBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatFiatBalance(Number('565.54111'), 'en-US')).toBe('565.54'); }); it(`test formatFiatBalance with a large XEC balance input with 2+ decimal figures`, () => { expect(formatFiatBalance(Number('131646565.54111'), 'en-US')).toBe( '131,646,565.54', ); }); it(`test formatFiatBalance with no balance`, () => { expect(formatFiatBalance('', 'en-US')).toBe(''); }); it(`test formatFiatBalance with null input`, () => { expect(formatFiatBalance(null, 'en-US')).toBe(null); }); it(`test formatFiatBalance with undefined input`, () => { expect(formatFiatBalance(undefined, 'en-US')).toBe(undefined); }); }); diff --git a/web/cashtab/src/utils/formatting.js b/web/cashtab/src/utils/formatting.js index 9bece927c..14a0801b3 100644 --- a/web/cashtab/src/utils/formatting.js +++ b/web/cashtab/src/utils/formatting.js @@ -1,73 +1,72 @@ import { currency } from '@components/Common/Ticker.js'; -export const formatDate = dateString => { +export const formatDate = (dateString, userLocale = 'en') => { const options = { month: 'short', day: 'numeric', year: 'numeric' }; const dateFormattingError = 'Unable to format date.'; try { - const userLocale = navigator.language; if (dateString) { return new Date(dateString * 1000).toLocaleDateString( userLocale, options, ); } return new Date().toLocaleDateString(userLocale, options); } catch (error) { return dateFormattingError; } }; export const formatFiatBalance = (fiatBalance, optionalLocale) => { try { if (fiatBalance === 0) { return Number(fiatBalance).toFixed(currency.cashDecimals); } if (optionalLocale === undefined) { return fiatBalance.toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } return fiatBalance.toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } catch (err) { return fiatBalance; } }; export const formatSavedBalance = (swBalance, optionalLocale) => { try { if (swBalance === undefined) { return 'N/A'; } else { if (optionalLocale === undefined) { return new Number(swBalance).toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } else { return new Number(swBalance).toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } } } catch (err) { return 'N/A'; } }; export const formatBalance = (unformattedBalance, optionalLocale) => { try { if (optionalLocale === undefined) { return new Number(unformattedBalance).toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } return new Number(unformattedBalance).toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } catch (err) { console.log(`Error in formatBalance for ${unformattedBalance}`); console.log(err); return unformattedBalance; } };