Changeset View
Changeset View
Standalone View
Standalone View
web/cashtab/src/components/Send/Send.js
import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||
import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||
import { WalletContext } from '@utils/context'; | import { WalletContext } from '@utils/context'; | ||||
import { | import { | ||||
Form, | Form, | ||||
message, | message, | ||||
Modal, | Modal, | ||||
Alert, | Alert, | ||||
Collapse, | Collapse, | ||||
Input, | Input, | ||||
Button, | |||||
notification, | notification, | ||||
} from 'antd'; | } from 'antd'; | ||||
const { TextArea } = Input; | const { TextArea } = Input; | ||||
import { Row, Col } from 'antd'; | import { Row, Col } from 'antd'; | ||||
import { | import { | ||||
StyledCollapse, | StyledCollapse, | ||||
AdvancedCollapse, | AdvancedCollapse, | ||||
} from '@components/Common/StyledCollapse'; | } from '@components/Common/StyledCollapse'; | ||||
import PrimaryButton, { | import PrimaryButton, { | ||||
SecondaryButton, | SecondaryButton, | ||||
SmartButton, | SmartButton, | ||||
} from '@components/Common/PrimaryButton'; | } from '@components/Common/PrimaryButton'; | ||||
import { | import { | ||||
SendBchInput, | SendBchInput, | ||||
FormItemWithQRCodeAddon, | FormItemWithQRCodeAddon, | ||||
FormItemWithoutQRCodeAddon, | |||||
AntdFormWrapper, | AntdFormWrapper, | ||||
} from '@components/Common/EnhancedInputs'; | } from '@components/Common/EnhancedInputs'; | ||||
import useBCH from '@hooks/useBCH'; | import useBCH from '@hooks/useBCH'; | ||||
import useWindowDimensions from '@hooks/useWindowDimensions'; | import useWindowDimensions from '@hooks/useWindowDimensions'; | ||||
import { | import { | ||||
sendXecNotification, | sendXecNotification, | ||||
errorNotification, | errorNotification, | ||||
messageSignedNotification, | messageSignedNotification, | ||||
} from '@components/Common/Notifications'; | } from '@components/Common/Notifications'; | ||||
import { isMobile, isIOS, isSafari } from 'react-device-detect'; | import { isMobile, isIOS, isSafari } from 'react-device-detect'; | ||||
import { | import { | ||||
currency, | currency, | ||||
isValidTokenPrefix, | isValidTokenPrefix, | ||||
parseAddress, | parseAddress, | ||||
toLegacy, | toLegacy, | ||||
toLegacyArray, | |||||
} from '@components/Common/Ticker.js'; | } from '@components/Common/Ticker.js'; | ||||
import { Event } from '@utils/GoogleAnalytics'; | import { Event } from '@utils/GoogleAnalytics'; | ||||
import { fiatToCrypto, shouldRejectAmountInput } from '@utils/validation'; | import { | ||||
fiatToCrypto, | |||||
shouldRejectAmountInput, | |||||
isValidSendToMany, | |||||
} from '@utils/validation'; | |||||
import BalanceHeader from '@components/Common/BalanceHeader'; | import BalanceHeader from '@components/Common/BalanceHeader'; | ||||
import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; | import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; | ||||
import { | import { | ||||
ZeroBalanceHeader, | ZeroBalanceHeader, | ||||
ConvertAmount, | ConvertAmount, | ||||
AlertMsg, | AlertMsg, | ||||
} from '@components/Common/Atoms'; | } from '@components/Common/Atoms'; | ||||
import { getWalletState } from '@utils/cashMethods'; | import { getWalletState } from '@utils/cashMethods'; | ||||
Show All 15 Lines | const StyledSpacer = styled.div` | ||||
margin: 60px 0 50px; | margin: 60px 0 50px; | ||||
`; | `; | ||||
const SignMessageLabel = styled.div` | const SignMessageLabel = styled.div` | ||||
text-align: left; | text-align: left; | ||||
color: #0074c2; | color: #0074c2; | ||||
`; | `; | ||||
const RecipientModeLabel = styled.div` | |||||
color: silver; | |||||
`; | |||||
// Note jestBCH is only used for unit tests; BCHJS must be mocked for jest | // Note jestBCH is only used for unit tests; BCHJS must be mocked for jest | ||||
const SendBCH = ({ jestBCH, passLoadingStatus }) => { | const SendBCH = ({ jestBCH, passLoadingStatus }) => { | ||||
// use balance parameters from wallet.state object and not legacy balances parameter from walletState, if user has migrated wallet | // 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 | // 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 | // If the wallet object from ContextValue has a `state key`, then check which keys are in the wallet object | ||||
// Else set it as blank | // Else set it as blank | ||||
const ContextValue = React.useContext(WalletContext); | const ContextValue = React.useContext(WalletContext); | ||||
const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; | const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; | ||||
const walletState = getWalletState(wallet); | const walletState = getWalletState(wallet); | ||||
const { balances, slpBalancesAndUtxos } = walletState; | const { balances, slpBalancesAndUtxos } = walletState; | ||||
// Modal settings | // Modal settings | ||||
const [showConfirmMsgToSign, setShowConfirmMsgToSign] = useState(false); | const [showConfirmMsgToSign, setShowConfirmMsgToSign] = useState(false); | ||||
const [msgToSign, setMsgToSign] = useState(''); | const [msgToSign, setMsgToSign] = useState(''); | ||||
const [signMessageIsValid, setSignMessageIsValid] = useState(null); | const [signMessageIsValid, setSignMessageIsValid] = useState(null); | ||||
const [isOneToManyXECSend, setIsOneToManyXECSend] = useState(false); | |||||
// Get device window width | // Get device window width | ||||
// If this is less than 769, the page will open with QR scanner open | // If this is less than 769, the page will open with QR scanner open | ||||
const { width } = useWindowDimensions(); | const { width } = useWindowDimensions(); | ||||
// Load with QR code open if device is mobile and NOT iOS + anything but safari | // Load with QR code open if device is mobile and NOT iOS + anything but safari | ||||
const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); | const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); | ||||
const [formData, setFormData] = useState({ | const [formData, setFormData] = useState({ | ||||
dirty: true, | dirty: true, | ||||
Show All 22 Lines | const handleOk = () => { | ||||
setIsModalVisible(false); | setIsModalVisible(false); | ||||
submit(); | submit(); | ||||
}; | }; | ||||
const handleCancel = () => { | const handleCancel = () => { | ||||
setIsModalVisible(false); | setIsModalVisible(false); | ||||
}; | }; | ||||
const { getBCH, getRestUrl, sendBch, calcFee, signPkMessage } = useBCH(); | const { getBCH, getRestUrl, sendXec, calcFee, signPkMessage } = useBCH(); | ||||
// jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); | // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); | ||||
const BCH = jestBCH ? jestBCH : getBCH(); | const BCH = jestBCH ? jestBCH : getBCH(); | ||||
// If the balance has changed, unlock the UI | // 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 | // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked | ||||
useEffect(() => { | useEffect(() => { | ||||
passLoadingStatus(false); | passLoadingStatus(false); | ||||
Show All 30 Lines | function populateFormsFromUrl(txInfo) { | ||||
if (txInfo && txInfo.address && txInfo.value) { | if (txInfo && txInfo.address && txInfo.value) { | ||||
setFormData({ | setFormData({ | ||||
address: txInfo.address, | address: txInfo.address, | ||||
value: txInfo.value, | value: txInfo.value, | ||||
}); | }); | ||||
} | } | ||||
} | } | ||||
async function submit() { | async function oneToManyXECSend() { | ||||
setFormData({ | |||||
...formData, | |||||
dirty: false, | |||||
}); | |||||
// 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, value } = 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 = toLegacyArray(addressAndValueArray); | |||||
const link = await sendXec( | |||||
BCH, | |||||
wallet, | |||||
slpBalancesAndUtxos.nonSlpUtxos, | |||||
null, // destinationAddress param not required in one to many mode | |||||
bytesofman: Change the order of these params so that they are at the end and can just be excluded when… | |||||
null, // sendAmount param not required in one to many mode | |||||
currency.defaultFee, | |||||
true, // indicate send mode is one to many | |||||
cleanAddressAndValueArray, | |||||
); | |||||
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'); | |||||
} | |||||
} | |||||
async function oneToOneXECSend() { | |||||
setFormData({ | setFormData({ | ||||
...formData, | ...formData, | ||||
dirty: false, | dirty: false, | ||||
}); | }); | ||||
if ( | if ( | ||||
!formData.address || | !formData.address || | ||||
!formData.value || | !formData.value || | ||||
Show All 40 Lines | async function oneToOneXECSend() { | ||||
// Calculate the amount in BCH | // Calculate the amount in BCH | ||||
let bchValue = value; | let bchValue = value; | ||||
if (selectedCurrency !== 'XEC') { | if (selectedCurrency !== 'XEC') { | ||||
bchValue = fiatToCrypto(value, fiatPrice); | bchValue = fiatToCrypto(value, fiatPrice); | ||||
} | } | ||||
try { | try { | ||||
const link = await sendBch( | const link = await sendXec( | ||||
BCH, | BCH, | ||||
wallet, | wallet, | ||||
slpBalancesAndUtxos.nonSlpUtxos, | slpBalancesAndUtxos.nonSlpUtxos, | ||||
cleanAddress, | cleanAddress, | ||||
bchValue, | bchValue, | ||||
currency.defaultFee, | currency.defaultFee, | ||||
); | ); | ||||
sendXecNotification(link); | sendXecNotification(link); | ||||
Show All 20 Lines | async function oneToOneXECSend() { | ||||
} else { | } else { | ||||
message = e.message || e.error || JSON.stringify(e); | message = e.message || e.error || JSON.stringify(e); | ||||
} | } | ||||
errorNotification(e, message, 'Sending XEC'); | errorNotification(e, message, 'Sending XEC'); | ||||
} | } | ||||
} | } | ||||
async function submit() { | |||||
if (isOneToManyXECSend) { | |||||
// this is a one to many send XEC transaction | |||||
oneToManyXECSend(); | |||||
} else { | |||||
// this is a one to one send XEC transaction | |||||
oneToOneXECSend(); | |||||
} | |||||
} | |||||
const handleAddressChange = e => { | const handleAddressChange = e => { | ||||
const { value, name } = e.target; | const { value, name } = e.target; | ||||
let error = false; | let error = false; | ||||
let addressString = value; | let addressString = value; | ||||
// parse address | // parse address | ||||
const addressInfo = parseAddress(BCH, addressString); | const addressInfo = parseAddress(BCH, addressString); | ||||
/* | /* | ||||
Model | Model | ||||
addressInfo = | addressInfo = | ||||
{ | { | ||||
address: '', | address: '', | ||||
isValid: false, | isValid: false, | ||||
queryString: '', | queryString: '', | ||||
amount: null, | amount: null, | ||||
}; | }; | ||||
*/ | */ | ||||
const { address, isValid, queryString, amount } = addressInfo; | const { address, isValid, queryString, amount } = addressInfo; | ||||
// If query string, | // If query string, | ||||
// Show an alert that only amount and currency.ticker are supported | // Show an alert that only amount and currency.ticker are supported | ||||
setQueryStringText(queryString); | setQueryStringText(queryString); | ||||
// Is this valid address? | // Is this valid address? | ||||
if (!isValid) { | if (!isValid) { | ||||
error = `Invalid ${currency.ticker} address`; | error = `Invalid ${currency.ticker} address`; | ||||
// If valid address but token format | // If valid address but token format | ||||
Show All 24 Lines | const handleAddressChange = e => { | ||||
// Set address field to user input | // Set address field to user input | ||||
setFormData(p => ({ | setFormData(p => ({ | ||||
...p, | ...p, | ||||
[name]: value, | [name]: value, | ||||
})); | })); | ||||
}; | }; | ||||
const handleMultiAddressChange = e => { | |||||
const { value, name } = e.target; | |||||
let error = false; | |||||
if (!value) { | |||||
error = 'recipient input must not be blank'; | |||||
} | |||||
//convert each line from the <TextArea> innput into array | |||||
let addressStringArray = value.split('\n'); | |||||
const arrayLength = addressStringArray.length; | |||||
for (let i = 0; i < arrayLength; i++) { | |||||
if (addressStringArray[i].trim() === '') { | |||||
// if this line is a line break or bunch of spaces | |||||
error = 'Empty rows must be removed'; | |||||
break; | |||||
} | |||||
let addressString = addressStringArray[i].split(',')[0]; | |||||
let valueString = addressStringArray[i].split(',')[1]; | |||||
const addressInfo = parseAddress(BCH, addressString); | |||||
error = isValidSendToMany( | |||||
addressInfo, | |||||
valueString, | |||||
currency.ticker, | |||||
); | |||||
if (!error === false) { | |||||
// if one line is invalid, break loop and avoid wasting further iterations | |||||
break; | |||||
} | |||||
} | |||||
// no error displayed if isValidMultiRecipients returns false, otherwise display error message | |||||
setSendBchAddressError(error); | |||||
// Set address field to user input | |||||
setFormData(p => ({ | |||||
...p, | |||||
[name]: value, | |||||
})); | |||||
}; | |||||
const handleSelectedCurrencyChange = e => { | const handleSelectedCurrencyChange = e => { | ||||
setSelectedCurrency(e); | setSelectedCurrency(e); | ||||
// Clear input field to prevent accidentally sending 1 BCH instead of 1 USD | // Clear input field to prevent accidentally sending 1 BCH instead of 1 USD | ||||
setFormData(p => ({ | setFormData(p => ({ | ||||
...p, | ...p, | ||||
value: '', | value: '', | ||||
})); | })); | ||||
}; | }; | ||||
// true: renders the multi recipient <TextArea> | |||||
// false: renders the single recipient <Input> | |||||
const handle1toManyXECSend = sendXecMode => { | |||||
bytesofmanUnsubmitted Done Inline ActionsChange 1 to One, i.e. handleOneToManyXecSend bytesofman: Change `1` to `One`, i.e. `handleOneToManyXecSend` | |||||
setIsOneToManyXECSend(sendXecMode); | |||||
}; | |||||
const handleBchAmountChange = e => { | const handleBchAmountChange = e => { | ||||
const { value, name } = e.target; | const { value, name } = e.target; | ||||
let bchValue = value; | let bchValue = value; | ||||
const error = shouldRejectAmountInput( | const error = shouldRejectAmountInput( | ||||
bchValue, | bchValue, | ||||
selectedCurrency, | selectedCurrency, | ||||
fiatPrice, | fiatPrice, | ||||
balances.totalBalance, | balances.totalBalance, | ||||
▲ Show 20 Lines • Show All 148 Lines • ▼ Show 20 Lines | return ( | ||||
<Row type="flex"> | <Row type="flex"> | ||||
<Col span={24}> | <Col span={24}> | ||||
<Form | <Form | ||||
style={{ | style={{ | ||||
width: 'auto', | width: 'auto', | ||||
}} | }} | ||||
> | > | ||||
{!isOneToManyXECSend ? ( | |||||
<> | |||||
<Button | |||||
type="text" | |||||
block | |||||
onClick={() => setIsOneToManyXECSend(true)} | |||||
> | |||||
<RecipientModeLabel> | |||||
Switch to multiple recipients | |||||
</RecipientModeLabel> | |||||
</Button> | |||||
<FormItemWithQRCodeAddon | <FormItemWithQRCodeAddon | ||||
loadWithCameraOpen={scannerSupported} | loadWithCameraOpen={scannerSupported} | ||||
validateStatus={sendBchAddressError ? 'error' : ''} | validateStatus={ | ||||
sendBchAddressError ? 'error' : '' | |||||
} | |||||
help={ | help={ | ||||
sendBchAddressError ? sendBchAddressError : '' | sendBchAddressError | ||||
? sendBchAddressError | |||||
: '' | |||||
} | } | ||||
onScan={result => | onScan={result => | ||||
handleAddressChange({ | handleAddressChange({ | ||||
target: { | target: { | ||||
name: 'address', | name: 'address', | ||||
value: result, | value: result, | ||||
}, | }, | ||||
}) | }) | ||||
} | } | ||||
inputProps={{ | inputProps={{ | ||||
placeholder: `${currency.ticker} Address`, | placeholder: `${currency.ticker} Address`, | ||||
name: 'address', | name: 'address', | ||||
onChange: e => handleAddressChange(e), | onChange: e => handleAddressChange(e), | ||||
required: true, | required: true, | ||||
value: formData.address, | value: formData.address, | ||||
}} | }} | ||||
></FormItemWithQRCodeAddon> | ></FormItemWithQRCodeAddon> | ||||
<SendBchInput | <SendBchInput | ||||
activeFiatCode={ | activeFiatCode={ | ||||
cashtabSettings && cashtabSettings.fiatCurrency | cashtabSettings && | ||||
cashtabSettings.fiatCurrency | |||||
? cashtabSettings.fiatCurrency.toUpperCase() | ? cashtabSettings.fiatCurrency.toUpperCase() | ||||
: 'USD' | : 'USD' | ||||
} | } | ||||
validateStatus={sendBchAmountError ? 'error' : ''} | validateStatus={ | ||||
help={sendBchAmountError ? sendBchAmountError : ''} | sendBchAmountError ? 'error' : '' | ||||
} | |||||
help={ | |||||
sendBchAmountError | |||||
? sendBchAmountError | |||||
: '' | |||||
} | |||||
onMax={onMax} | onMax={onMax} | ||||
inputProps={{ | inputProps={{ | ||||
name: 'value', | name: 'value', | ||||
dollar: selectedCurrency === 'USD' ? 1 : 0, | dollar: | ||||
selectedCurrency === 'USD' ? 1 : 0, | |||||
placeholder: 'Amount', | placeholder: 'Amount', | ||||
onChange: e => handleBchAmountChange(e), | onChange: e => handleBchAmountChange(e), | ||||
required: true, | required: true, | ||||
value: formData.value, | value: formData.value, | ||||
}} | }} | ||||
selectProps={{ | selectProps={{ | ||||
value: selectedCurrency, | value: selectedCurrency, | ||||
disabled: queryStringText !== null, | disabled: queryStringText !== null, | ||||
onChange: e => handleSelectedCurrencyChange(e), | onChange: e => | ||||
handleSelectedCurrencyChange(e), | |||||
}} | }} | ||||
></SendBchInput> | ></SendBchInput> | ||||
{priceApiError && ( | {priceApiError && ( | ||||
<AlertMsg> | <AlertMsg> | ||||
Error fetching fiat price. Setting send by{' '} | Error fetching fiat price. Setting send | ||||
by{' '} | |||||
{currency.fiatCurrencies[ | {currency.fiatCurrencies[ | ||||
cashtabSettings.fiatCurrency | cashtabSettings.fiatCurrency | ||||
].slug.toUpperCase()}{' '} | ].slug.toUpperCase()}{' '} | ||||
disabled | disabled | ||||
</AlertMsg> | </AlertMsg> | ||||
)} | )} | ||||
<ConvertAmount> | <ConvertAmount> | ||||
{fiatPriceString !== '' && '='} {fiatPriceString} | {fiatPriceString !== '' && '='}{' '} | ||||
{fiatPriceString} | |||||
</ConvertAmount> | </ConvertAmount> | ||||
</> | |||||
) : ( | |||||
<> | |||||
<Button | |||||
type="text" | |||||
block | |||||
onClick={() => setIsOneToManyXECSend(false)} | |||||
> | |||||
<RecipientModeLabel> | |||||
Switch to a single recipient | |||||
</RecipientModeLabel> | |||||
</Button> | |||||
<FormItemWithoutQRCodeAddon | |||||
validateStatus={ | |||||
sendBchAddressError ? 'error' : '' | |||||
} | |||||
help={ | |||||
sendBchAddressError | |||||
? sendBchAddressError | |||||
: '' | |||||
} | |||||
inputProps={{ | |||||
placeholder: `One XEC address & value per line, separated by comma \ne.g. \necash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8,500 \necash:qzvydd4n3lm3xv62cx078nu9rg0e3srmqq0knykfed,700`, | |||||
name: 'address', | |||||
onChange: e => | |||||
handleMultiAddressChange(e), | |||||
required: true, | |||||
value: formData.address, | |||||
}} | |||||
></FormItemWithoutQRCodeAddon> | |||||
</> | |||||
)} | |||||
<div | <div | ||||
style={{ | style={{ | ||||
paddingTop: '12px', | paddingTop: '12px', | ||||
}} | }} | ||||
> | > | ||||
{!balances.totalBalance || | {!balances.totalBalance || | ||||
apiError || | apiError || | ||||
sendBchAmountError || | sendBchAmountError || | ||||
▲ Show 20 Lines • Show All 132 Lines • Show Last 20 Lines |
Change the order of these params so that they are at the end and can just be excluded when sendOneToMany is true