`;
diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js
index ea2e07ebb..d7c5daa8d 100644
--- a/web/cashtab/src/components/Send/Send.js
+++ b/web/cashtab/src/components/Send/Send.js
@@ -1,1027 +1,1039 @@
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 { ThemedMailOutlined } from 'components/Common/CustomIcons';
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')};
}
`;
+const PanelHeaderCtn = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1rem;
+`;
+
// 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