diff --git a/web/cashtab/src/components/Common/EnhancedInputs.js b/web/cashtab/src/components/Common/EnhancedInputs.js index e71146f3d..48d3cff7b 100644 --- a/web/cashtab/src/components/Common/EnhancedInputs.js +++ b/web/cashtab/src/components/Common/EnhancedInputs.js @@ -1,342 +1,342 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { Form, Input, Select } from 'antd'; import { ThemedDollarOutlined, ThemedWalletOutlined, } from '@components/Common/CustomIcons'; import styled, { css } from 'styled-components'; import ScanQRCode from './ScanQRCode'; import useBCH from '@hooks/useBCH'; import { currency } from '@components/Common/Ticker.js'; export const AntdFormCss = css` .ant-input-group-addon { background-color: ${props => props.theme.forms.addonBackground} !important; border: 1px solid ${props => props.theme.forms.border}; color: ${props => props.theme.forms.addonForeground} !important; } input.ant-input, .ant-select-selection { background-color: ${props => props.theme.forms.selectionBackground} !important; box-shadow: none !important; border-radius: 4px; font-weight: bold; color: ${props => props.theme.forms.text}; opacity: 1; height: 50px; } textarea.ant-input, .ant-select-selection { background-color: ${props => props.theme.forms.selectionBackground} !important; box-shadow: none !important; border-radius: 4px; font-weight: bold; color: ${props => props.theme.forms.text}; opacity: 1; height: 50px; min-height: 100px; } .ant-input-affix-wrapper { background-color: ${props => props.theme.forms.selectionBackground}; border: 1px solid ${props => props.theme.wallet.borders.color} !important; } .ant-select-selector { height: 60px !important; border: 1px solid ${props => props.theme.wallet.borders.color} !important; } .ant-form-item-has-error > div > div.ant-form-item-control-input > div > span > span > span.ant-input-affix-wrapper { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-has-error .ant-input, .ant-form-item-has-error .ant-input-affix-wrapper, .ant-form-item-has-error .ant-input:hover, .ant-form-item-has-error .ant-input-affix-wrapper:hover { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-has-error .ant-select:not(.ant-select-disabled):not(.ant-select-customize-input) .ant-select-selector { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-select-single .ant-select-selector .ant-select-selection-item, .ant-select-single .ant-select-selector .ant-select-selection-placeholder { line-height: 60px; text-align: left; color: ${props => props.theme.forms.text}; font-weight: bold; } .ant-form-item-has-error .ant-input-group-addon { color: ${props => props.theme.forms.error} !important; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-explain.ant-form-item-explain-error { color: ${props => props.theme.forms.error} !important; } `; export const AntdFormWrapper = styled.div` ${AntdFormCss} `; export const InputAddonText = styled.span` width: 100%; height: 100%; display: block; ${props => props.disabled ? ` cursor: not-allowed; ` : `cursor: pointer;`} `; export const InputNumberAddonText = styled.span` background-color: ${props => props.theme.forms.addonBackground} !important; border: 1px solid ${props => props.theme.forms.border}; color: ${props => props.theme.forms.addonForeground} !important; height: 50px; line-height: 47px; * { color: ${props => props.theme.forms.addonForeground} !important; } ${props => props.disabled ? ` cursor: not-allowed; ` : `cursor: pointer;`} `; export const SendBchInput = ({ onMax, inputProps, selectProps, activeFiatCode, ...otherProps }) => { const { Option } = Select; const currencies = [ { value: currency.ticker, label: currency.ticker, }, { value: activeFiatCode ? activeFiatCode : 'USD', label: activeFiatCode ? activeFiatCode : 'USD', }, ]; const currencyOptions = currencies.map(currency => { return ( ); }); const CurrencySelect = ( ); return ( ) : ( ) } {...inputProps} /> {CurrencySelect} max ); }; SendBchInput.propTypes = { onMax: PropTypes.func, inputProps: PropTypes.object, selectProps: PropTypes.object, activeFiatCode: PropTypes.string, }; export const FormItemWithMaxAddon = ({ onMax, inputProps, ...otherProps }) => { return ( } addonAfter={ max } {...inputProps} /> ); }; FormItemWithMaxAddon.propTypes = { onMax: PropTypes.func, inputProps: PropTypes.object, }; // loadWithCameraOpen prop: if true, load page with camera scanning open -export const FormItemWithQRCodeAddon = ({ +export const DestinationAddressSingle = ({ onScan, loadWithCameraOpen, inputProps, ...otherProps }) => { return ( } autoComplete="off" addonAfter={ } {...inputProps} /> ); }; -FormItemWithQRCodeAddon.propTypes = { +DestinationAddressSingle.propTypes = { onScan: PropTypes.func, loadWithCameraOpen: PropTypes.bool, inputProps: PropTypes.object, }; export const CurrencySelectDropdown = selectProps => { const { Option } = Select; // Build select dropdown from currency.fiatCurrencies const currencyMenuOptions = []; const currencyKeys = Object.keys(currency.fiatCurrencies); for (let i = 0; i < currencyKeys.length; i += 1) { const currencyMenuOption = {}; currencyMenuOption.value = currency.fiatCurrencies[currencyKeys[i]].slug; currencyMenuOption.label = `${ currency.fiatCurrencies[currencyKeys[i]].name } (${currency.fiatCurrencies[currencyKeys[i]].symbol})`; currencyMenuOptions.push(currencyMenuOption); } const currencyOptions = currencyMenuOptions.map(currencyMenuOption => { return ( ); }); return ( ); }; export const AddressValidators = () => { const { BCH } = useBCH(); return { safelyDetectAddressFormat: value => { try { return BCH.Address.detectAddressFormat(value); } catch (error) { return null; } }, isSLPAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'slpaddr', isBCHAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'cashaddr', isLegacyAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'legacy', }(); }; diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js index e18154061..9e5aeca80 100644 --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -1,768 +1,768 @@ import React, { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { AntdFormWrapper, SendBchInput, - FormItemWithQRCodeAddon, + DestinationAddressSingle, } from '@components/Common/EnhancedInputs'; import { StyledCollapse, AdvancedCollapse, } from '@components/Common/StyledCollapse'; import { Form, message, Modal, Alert, Collapse, Input, notification, } from 'antd'; const { Panel } = Collapse; const { TextArea } = Input; import { Row, Col } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; import PrimaryButton, { SecondaryButton, SmartButton, } from '@components/Common/PrimaryButton'; import useBCH from '@hooks/useBCH'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { sendXecNotification, errorNotification, messageSignedNotification, } from '@components/Common/Notifications'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { currency, isValidTokenPrefix, parseAddress, toLegacy, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { fiatToCrypto, shouldRejectAmountInput } from '@utils/validation'; import BalanceHeader from '@components/Common/BalanceHeader'; import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; import { ZeroBalanceHeader, ConvertAmount, AlertMsg, } from '@components/Common/Atoms'; import { getWalletState } from '@utils/cashMethods'; import ApiError from '@components/Common/ApiError'; import { formatFiatBalance } from '@utils/validation'; import WalletLabel from '@components/Common/WalletLabel.js'; import Wallet from '@components/Wallet/Wallet'; import { TokenParamLabel } from '@components/Common/Atoms'; import { PlusSquareOutlined } from '@ant-design/icons'; import styled from 'styled-components'; import { convertToEcashPrefix } from '@utils/cashMethods'; import { CopyToClipboard } from 'react-copy-to-clipboard'; const StyledSpacer = styled.div` height: 1px; width: 100%; background-color: ${props => props.theme.wallet.borders.color}; margin: 60px 0 50px; `; const SignMessageLabel = styled.div` text-align: left; color: #0074c2; `; const TextAreaLabel = styled.div` text-align: left; color: #0074c2; padding-left: 1px; `; // Note jestBCH is only used for unit tests; BCHJS must be mocked for jest const SendBCH = ({ jestBCH, passLoadingStatus }) => { // use balance parameters from wallet.state object and not legacy balances parameter from walletState, if user has migrated wallet // this handles edge case of user with old wallet who has not opened latest Cashtab version yet // If the wallet object from ContextValue has a `state key`, then check which keys are in the wallet object // Else set it as blank const ContextValue = React.useContext(WalletContext); const location = useLocation(); const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; const walletState = getWalletState(wallet); const { balances, slpBalancesAndUtxos } = walletState; // Modal settings const [showConfirmMsgToSign, setShowConfirmMsgToSign] = useState(false); const [msgToSign, setMsgToSign] = useState(''); const [signMessageIsValid, setSignMessageIsValid] = useState(null); // 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 [queryStringText, setQueryStringText] = useState(null); const [sendBchAddressError, setSendBchAddressError] = useState(false); const [sendBchAmountError, setSendBchAmountError] = useState(false); const [selectedCurrency, setSelectedCurrency] = useState(currency.ticker); // Support cashtab button from web pages const [txInfoFromUrl, setTxInfoFromUrl] = useState(false); // Show a confirmation modal on transactions created by populating form from web page button const [isModalVisible, setIsModalVisible] = useState(false); const [messageSignature, setMessageSignature] = useState(''); const [sigCopySuccess, setSigCopySuccess] = useState(''); const showModal = () => { setIsModalVisible(true); }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; const { getBCH, getRestUrl, sendBch, calcFee, signPkMessage } = useBCH(); // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const BCH = jestBCH ? jestBCH : getBCH(); // 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 useEffect(() => { passLoadingStatus(false); }, [balances.totalBalance]); useEffect(() => { // Manually parse for txInfo object on page load when Send.js is loaded with a query string // if this was routed from Wallet screen's Reply to message link then prepopulate the address and value field if (location && location.state && location.state.replyAddress) { setFormData({ address: location.state.replyAddress, value: 5.5, }); } // Do not set txInfo in state if query strings are not present if ( !window.location || !window.location.hash || window.location.hash === '#/send' ) { return; } const txInfoArr = window.location.hash.split('?')[1].split('&'); // Iterate over this to create object const txInfo = {}; for (let i = 0; i < txInfoArr.length; i += 1) { let txInfoKeyValue = txInfoArr[i].split('='); let key = txInfoKeyValue[0]; let value = txInfoKeyValue[1]; txInfo[key] = value; } console.log(`txInfo from page params`, txInfo); setTxInfoFromUrl(txInfo); populateFormsFromUrl(txInfo); }, []); function populateFormsFromUrl(txInfo) { if (txInfo && txInfo.address && txInfo.value) { setFormData({ address: txInfo.address, value: txInfo.value, }); } } async function submit() { setFormData({ ...formData, dirty: false, }); if ( !formData.address || !formData.value || Number(formData.value) <= 0 ) { return; } let optionalOpReturnMsg = formData.opReturnMsg; // Event("Category", "Action", "Label") // Track number of BCHA send transactions and whether users // are sending BCHA or USD Event('Send.js', 'Send', selectedCurrency); passLoadingStatus(true); const { address, value } = formData; // Get the param-free address let cleanAddress = address.split('?')[0]; // Ensure address has bitcoincash: prefix and checksum cleanAddress = toLegacy(cleanAddress); let hasValidCashPrefix; try { hasValidCashPrefix = cleanAddress.startsWith( currency.legacyPrefix + ':', ); } catch (err) { hasValidCashPrefix = false; console.log(`toLegacy() returned an error:`, cleanAddress); } if (!hasValidCashPrefix) { // set loading to false and set address validation to false // Now that the no-prefix case is handled, this happens when user tries to send // BCHA to an SLPA address passLoadingStatus(false); setSendBchAddressError( `Destination is not a valid ${currency.ticker} address`, ); return; } // Calculate the amount in BCH let bchValue = value; if (selectedCurrency !== 'XEC') { bchValue = fiatToCrypto(value, fiatPrice); } try { const link = await sendBch( BCH, wallet, slpBalancesAndUtxos.nonSlpUtxos, cleanAddress, bchValue, currency.defaultFee, optionalOpReturnMsg, ); sendXecNotification(link); } catch (e) { // Set loading to false here as well, as balance may not change depending on where error occured in try loop 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 if ( e.error && e.error.includes( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)', ) ) { message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Sending XEC'); } } const handleAddressChange = e => { const { value, name } = e.target; let error = false; let addressString = value; // parse address const addressInfo = parseAddress(BCH, addressString); /* Model addressInfo = { address: '', isValid: false, queryString: '', amount: null, }; */ const { address, isValid, queryString, amount } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = `Invalid ${currency.ticker} address`; // If valid address but token format if (isValidTokenPrefix(address)) { error = `Token addresses are not supported for ${currency.ticker} sends`; } } setSendBchAddressError(error); // Set amount if it's in the query string if (amount !== null) { // Set currency to BCHA setSelectedCurrency(currency.ticker); // Use this object to mimic user input and get validation for the value let amountObj = { target: { name: 'value', value: amount, }, }; handleBchAmountChange(amountObj); setFormData({ ...formData, value: amount, }); } // Set address field to user input setFormData(p => ({ ...p, [name]: value, })); }; const handleSelectedCurrencyChange = e => { setSelectedCurrency(e); // Clear input field to prevent accidentally sending 1 BCH instead of 1 USD setFormData(p => ({ ...p, value: '', })); }; const handleOpReturnMsgChange = e => { const { value, name } = e.target; setFormData(p => ({ ...p, [name]: value, })); }; const handleBchAmountChange = e => { const { value, name } = e.target; let bchValue = value; const error = shouldRejectAmountInput( bchValue, selectedCurrency, fiatPrice, balances.totalBalance, ); setSendBchAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const handleSignMsgChange = e => { const { value } = e.target; // validation if (value && value.length && value.length < 150) { setMsgToSign(value); setSignMessageIsValid(true); } else { setSignMessageIsValid(false); } }; const signMessageByPk = async () => { try { const messageSignature = await signPkMessage( BCH, wallet.Path1899.fundingWif, msgToSign, ); setMessageSignature(messageSignature); messageSignedNotification(messageSignature); } catch (err) { let message; if (!err.error && !err.message) { message = err.message || err.error || JSON.stringify(err); } errorNotification(err, message, 'Message Signing Error'); throw err; } // Hide the modal setShowConfirmMsgToSign(false); setSigCopySuccess(''); }; const handleOnSigCopy = () => { if (messageSignature != '') { setSigCopySuccess('Signature copied to clipboard'); } }; const onMax = async () => { // Clear amt error setSendBchAmountError(false); // Set currency to BCH setSelectedCurrency(currency.ticker); try { const txFeeSats = calcFee(BCH, slpBalancesAndUtxos.nonSlpUtxos); const txFeeBch = txFeeSats / 10 ** currency.cashDecimals; let value = balances.totalBalance - txFeeBch >= 0 ? (balances.totalBalance - txFeeBch).toFixed( currency.cashDecimals, ) : 0; 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', ); } }; // Display price in USD below input field for send amount, if it can be calculated let fiatPriceString = ''; if (fiatPrice !== null && !isNaN(formData.value)) { if (selectedCurrency === currency.ticker) { // calculate conversion to fiatPrice fiatPriceString = `${(fiatPrice * Number(formData.value)).toFixed( 2, )}`; // formats to fiat locale style fiatPriceString = formatFiatBalance(Number(fiatPriceString)); // insert symbol and currency before/after the locale formatted fiat balance fiatPriceString = `${ cashtabSettings ? `${ currency.fiatCurrencies[cashtabSettings.fiatCurrency] .symbol } ` : '$ ' } ${fiatPriceString} ${ cashtabSettings && cashtabSettings.fiatCurrency ? cashtabSettings.fiatCurrency.toUpperCase() : 'USD' }`; } else { fiatPriceString = `${ formData.value ? formatFiatBalance( Number(fiatToCrypto(formData.value, fiatPrice)), ) : formatFiatBalance(0) } ${currency.ticker}`; } } const priceApiError = fiatPrice === null && selectedCurrency !== 'XEC'; return ( <>

Are you sure you want to send {formData.value}{' '} {currency.ticker} to {formData.address}?

{!balances.totalBalance ? ( You currently have 0 {currency.ticker}
Deposit some funds to use this feature
) : ( <> {fiatPrice !== null && ( )} )}
- handleAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.ticker} Address`, name: 'address', onChange: e => handleAddressChange(e), required: true, value: formData.address, }} - > + > handleBchAmountChange(e), required: true, value: formData.value, }} selectProps={{ value: selectedCurrency, disabled: queryStringText !== null, onChange: e => handleSelectedCurrencyChange(e), }} > {priceApiError && ( Error fetching fiat price. Setting send by{' '} {currency.fiatCurrencies[ cashtabSettings.fiatCurrency ].slug.toUpperCase()}{' '} disabled )} {fiatPriceString !== '' && '='} {fiatPriceString}
Message: