diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js index d0a77e50a..ea2e07ebb 100644 --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -1,989 +1,1027 @@ 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, DestinationAddressSingle, DestinationAddressMulti, } from 'components/Common/EnhancedInputs'; import { CustomCollapseCtn } from 'components/Common/StyledCollapse'; import { Form, message, Modal, Alert, Input } from 'antd'; import { Row, Col, Switch } from 'antd'; import PrimaryButton, { DisabledButton } from 'components/Common/PrimaryButton'; import useBCH from 'hooks/useBCH'; import useWindowDimensions from 'hooks/useWindowDimensions'; import { sendXecNotification, errorNotification, } from 'components/Common/Notifications'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { currency, parseAddressForParams } from 'components/Common/Ticker.js'; import { Event } from 'utils/GoogleAnalytics'; import { fiatToCrypto, shouldRejectAmountInput, isValidXecAddress, isValidEtokenAddress, isValidXecSendAmount, } from 'utils/validation'; import BalanceHeader from 'components/Common/BalanceHeader'; import BalanceHeaderFiat from 'components/Common/BalanceHeaderFiat'; import { ZeroBalanceHeader, ConvertAmount, AlertMsg, WalletInfoCtn, SidePaddingCtn, FormLabel, } from 'components/Common/Atoms'; import { getWalletState, toLegacyCash, toLegacyCashArray, fromSatoshisToXec, calcFee, } from 'utils/cashMethods'; import ApiError from 'components/Common/ApiError'; import { formatFiatBalance, formatBalance } from 'utils/formatting'; import styled from 'styled-components'; import WalletLabel from 'components/Common/WalletLabel.js'; const { TextArea } = Input; const TextAreaLabel = styled.div` text-align: left; color: ${props => props.theme.forms.text}; padding-left: 1px; + white-space: nowrap; `; const AmountPreviewCtn = styled.div` margin-top: -30px; `; const SendInputCtn = styled.div` .ant-form-item-with-help { margin-bottom: 32px; } `; const LocaleFormattedValue = styled.h3` color: ${props => props.theme.contrast}; font-weight: bold; margin-bottom: 0; `; +const SendAddressHeader = styled.div` + display: flex; + align-items: center; +`; +const DestinationAddressSingleCtn = styled.div``; +const DestinationAddressMultiCtn = styled.div``; + +const ExpandingAddressInputCtn = styled.div` + min-height: 14rem; + ${DestinationAddressSingleCtn} { + overflow: hidden; + max-height: ${props => (props.open ? '0rem' : '17rem')}; + } + ${DestinationAddressMultiCtn} { + overflow: hidden; + max-height: ${props => (props.open ? '17rem' : '0rem')}; + } +`; + // 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 { BCH, wallet, fiatPrice, apiError, cashtabSettings, changeCashtabSettings, chronik, } = ContextValue; const walletState = getWalletState(wallet); const { balances, slpBalancesAndUtxos } = walletState; // Modal settings const [isOneToManyXECSend, setIsOneToManyXECSend] = useState(false); const [opReturnMsg, setOpReturnMsg] = useState(false); const [isEncryptedOptionalOpReturnMsg, setIsEncryptedOptionalOpReturnMsg] = useState(false); 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 [formData, setFormData] = useState({ value: '', address: '', airdropTokenId: '', }); 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 [airdropFlag, setAirdropFlag] = useState(false); const userLocale = navigator.language; const clearInputForms = () => { setFormData({ value: '', address: '', }); setOpReturnMsg(''); // OP_RETURN message has its own state field }; const checkForConfirmationBeforeSendXec = () => { if (txInfoFromUrl) { setIsModalVisible(true); } else if (cashtabSettings.sendModal) { setIsModalVisible(cashtabSettings.sendModal); } else { // if the user does not have the send confirmation enabled in settings then send directly send(); } }; const handleOk = () => { setIsModalVisible(false); send(); }; const handleCancel = () => { setIsModalVisible(false); }; const { getRestUrl, sendXec } = useBCH(); // 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(() => { // 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]); 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: `${fromSatoshisToXec(currency.dustSats).toString()}`, }); } // if this was routed from the Contact List if (location && location.state && location.state.contactSend) { setFormData({ address: location.state.contactSend, }); } // if this was routed from the Airdrop screen's Airdrop Calculator then // switch to multiple recipient mode and prepopulate the recipients field if ( location && location.state && location.state.airdropRecipients && location.state.airdropTokenId ) { setIsOneToManyXECSend(true); setFormData({ address: location.state.airdropRecipients, airdropTokenId: location.state.airdropTokenId, }); // validate the airdrop outputs from the calculator handleMultiAddressChange({ target: { value: location.state.airdropRecipients, }, }); setAirdropFlag(true); } // 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, }); } } function handleSendXecError(errorObj, oneToManyFlag) { // 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 (!errorObj.error && !errorObj.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( errorObj.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else if ( errorObj.error && errorObj.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 = errorObj.message || errorObj.error || JSON.stringify(errorObj); } if (oneToManyFlag) { errorNotification(errorObj, message, 'Sending XEC one to many'); } else { errorNotification(errorObj, message, 'Sending XEC'); } } async function send() { setFormData({ ...formData, }); if (isOneToManyXECSend) { // this is a one to many XEC send transactions // ensure multi-recipient input is not blank if (!formData.address) { return; } // Event("Category", "Action", "Label") // Track number of XEC send-to-many transactions Event('Send.js', 'SendToMany', selectedCurrency); passLoadingStatus(true); const { address } = formData; //convert each line from TextArea input let addressAndValueArray = address.split('\n'); try { // construct array of XEC->BCH addresses due to bch-api constraint let cleanAddressAndValueArray = toLegacyCashArray(addressAndValueArray); const link = await sendXec( bchObj, chronik, wallet, slpBalancesAndUtxos.nonSlpUtxos, currency.defaultFee, opReturnMsg, true, // indicate send mode is one to many cleanAddressAndValueArray, null, null, false, // one to many tx msg can't be encrypted airdropFlag, formData.airdropTokenId, ); sendXecNotification(link); clearInputForms(); setAirdropFlag(false); } catch (e) { handleSendXecError(e, isOneToManyXECSend); } } else { // standard one to one XEC send transaction if ( !formData.address || !formData.value || Number(formData.value) <= 0 ) { return; } // 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 = toLegacyCash(cleanAddress); // Calculate the amount in BCH let bchValue = value; if (selectedCurrency !== 'XEC') { bchValue = fiatToCrypto(value, fiatPrice); } // encrypted message limit truncation let optionalOpReturnMsg; if (isEncryptedOptionalOpReturnMsg) { optionalOpReturnMsg = opReturnMsg.substring( 0, currency.opReturn.encryptedMsgCharLimit, ); } else { optionalOpReturnMsg = opReturnMsg; } try { const link = await sendXec( bchObj, chronik, wallet, slpBalancesAndUtxos.nonSlpUtxos, currency.defaultFee, optionalOpReturnMsg, false, // sendToMany boolean flag null, // address array not applicable for one to many tx cleanAddress, bchValue, isEncryptedOptionalOpReturnMsg, ); sendXecNotification(link); clearInputForms(); } catch (e) { handleSendXecError(e, isOneToManyXECSend); } } } const handleAddressChange = e => { const { value, name } = e.target; let error = false; let addressString = value; // parse address for parameters const addressInfo = parseAddressForParams(addressString); // validate address const isValid = isValidXecAddress(addressInfo.address); /* Model addressInfo = { address: '', queryString: '', amount: null, }; */ const { address, 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 (isValidEtokenAddress(address)) { error = `eToken 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 handleMultiAddressChange = e => { const { value, name } = e.target; let error; if (!value) { error = 'Input must not be blank'; setSendBchAddressError(error); return setFormData(p => ({ ...p, [name]: value, })); } //convert each line from the