diff options
Diffstat (limited to 'ui/app')
14 files changed, 578 insertions, 32 deletions
diff --git a/ui/app/components/page-container/index.js b/ui/app/components/page-container/index.js new file mode 100644 index 000000000..415870b37 --- /dev/null +++ b/ui/app/components/page-container/index.js @@ -0,0 +1 @@ +export { default } from './page-container.component' diff --git a/ui/app/components/page-container/page-container-footer/index.js b/ui/app/components/page-container/page-container-footer/index.js new file mode 100644 index 000000000..7825c4520 --- /dev/null +++ b/ui/app/components/page-container/page-container-footer/index.js @@ -0,0 +1 @@ +export { default } from './page-container-footer.component' diff --git a/ui/app/components/page-container/page-container-footer.component.js b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js index 475ce6b1c..fafe1c19e 100644 --- a/ui/app/components/page-container/page-container-footer.component.js +++ b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js @@ -5,12 +5,24 @@ export default class PageContainerFooter extends Component { static propTypes = { onCancel: PropTypes.func, + cancelText: PropTypes.string, onSubmit: PropTypes.func, + submitText: PropTypes.string, disabled: PropTypes.bool, - }; + } + + static contextTypes = { + t: PropTypes.func, + } render () { - const { onCancel, onSubmit, disabled } = this.props + const { + onCancel, + cancelText, + onSubmit, + submitText, + disabled, + } = this.props return ( <div className="page-container__footer"> @@ -19,15 +31,15 @@ export default class PageContainerFooter extends Component { className="btn-secondary--lg page-container__footer-button" onClick={() => onCancel()} > - {this.context.t('cancel')} + { this.context.t('cancel') || cancelText } </button> <button className="btn-primary--lg page-container__footer-button" disabled={disabled} - onClick={(e) => onSubmit(e)} + onClick={e => onSubmit(e)} > - {this.context.t('next')} + { this.context.t('next') || submitText } </button> </div> @@ -35,7 +47,3 @@ export default class PageContainerFooter extends Component { } } - -PageContainerFooter.contextTypes = { - t: PropTypes.func, -} diff --git a/ui/app/components/page-container/tests/page-container-content-component.test.js b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js index e69de29bb..e69de29bb 100644 --- a/ui/app/components/page-container/tests/page-container-content-component.test.js +++ b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js diff --git a/ui/app/components/page-container/page-container-header/index.js b/ui/app/components/page-container/page-container-header/index.js new file mode 100644 index 000000000..b194af057 --- /dev/null +++ b/ui/app/components/page-container/page-container-header/index.js @@ -0,0 +1 @@ +export { default } from './page-container-header.component' diff --git a/ui/app/components/page-container/page-container-header/page-container-header.component.js b/ui/app/components/page-container/page-container-header/page-container-header.component.js new file mode 100644 index 000000000..28882edce --- /dev/null +++ b/ui/app/components/page-container/page-container-header/page-container-header.component.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class PageContainerHeader extends Component { + + static propTypes = { + title: PropTypes.string.isRequired, + subtitle: PropTypes.string, + onClose: PropTypes.func, + showBackButton: PropTypes.bool, + onBackButtonClick: PropTypes.func, + backButtonStyles: PropTypes.object, + backButtonString: PropTypes.string, + }; + + renderHeaderRow () { + const { showBackButton, onBackButtonClick, backButtonStyles, backButtonString } = this.props + + return showBackButton && ( + <div className="page-container__header-row"> + <span + className="page-container__back-button" + onClick={onBackButtonClick} + style={backButtonStyles} + > + { backButtonString || 'Back' } + </span> + </div> + ) + } + + render () { + const { title, subtitle, onClose } = this.props + + return ( + <div className="page-container__header"> + + { this.renderHeaderRow() } + + <div className="page-container__title"> + {title} + </div> + + <div className="page-container__subtitle"> + {subtitle} + </div> + + <div + className="page-container__header-close" + onClick={() => onClose()} + /> + + </div> + ) + } + +} diff --git a/ui/app/components/page-container/tests/page-container-footer-component.test.js b/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js index e69de29bb..e69de29bb 100644 --- a/ui/app/components/page-container/tests/page-container-footer-component.test.js +++ b/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js diff --git a/ui/app/components/page-container/page-container.component.js b/ui/app/components/page-container/page-container.component.js index dc3745d4a..9bfb99ade 100644 --- a/ui/app/components/page-container/page-container.component.js +++ b/ui/app/components/page-container/page-container.component.js @@ -1,16 +1,70 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' +import PageContainerHeader from './page-container-header' +import PageContainerFooter from './page-container-footer' + export default class PageContainer extends Component { static propTypes = { - children: PropTypes.node.isRequired, + // PageContainerHeader props + title: PropTypes.string.isRequired, + subtitle: PropTypes.string, + onClose: PropTypes.func, + showBackButton: PropTypes.bool, + onBackButtonClick: PropTypes.func, + backButtonStyles: PropTypes.object, + backButtonString: PropTypes.string, + // Content props + ContentComponent: PropTypes.func, + contentComponentProps: PropTypes.object, + // PageContainerFooter props + onCancel: PropTypes.func, + cancelText: PropTypes.string, + onSubmit: PropTypes.func, + submitText: PropTypes.string, + disabled: PropTypes.bool, }; render () { + const { + title, + subtitle, + onClose, + showBackButton, + onBackButtonClick, + backButtonStyles, + backButtonString, + ContentComponent, + contentComponentProps, + onCancel, + cancelText, + onSubmit, + submitText, + disabled, + } = this.props + return ( <div className="page-container"> - {this.props.children} + <PageContainerHeader + title={title} + subtitle={subtitle} + onClose={onClose} + showBackButton={showBackButton} + onBackButtonClick={onBackButtonClick} + backButtonStyles={backButtonStyles} + backButtonString={backButtonString} + /> + <div className="page-container__content"> + <ContentComponent { ...contentComponentProps } /> + </div> + <PageContainerFooter + onCancel={onCancel} + cancelText={cancelText} + onSubmit={onSubmit} + submitText={submitText} + disabled={disabled} + /> </div> ) } diff --git a/ui/app/components/page-container/tests/page-container-header-component.test.js b/ui/app/components/page-container/tests/page-container.component.test.js index e69de29bb..e69de29bb 100644 --- a/ui/app/components/page-container/tests/page-container-header-component.test.js +++ b/ui/app/components/page-container/tests/page-container.component.test.js diff --git a/ui/app/components/send/send-v2-container.js b/ui/app/components/send/send-v2-container.js index 6464439f2..cdbee0cdc 100644 --- a/ui/app/components/send/send-v2-container.js +++ b/ui/app/components/send/send-v2-container.js @@ -62,6 +62,7 @@ function mapStateToProps (state) { tokenContract: getSelectedTokenContract(state), unapprovedTxs: state.metamask.unapprovedTxs, network: state.metamask.network, + isToken: Boolean(getSelectedToken(state)), } } diff --git a/ui/app/components/send_/send-footer/send-footer.component.js b/ui/app/components/send_/send-footer/send-footer.component.js index ffece433e..fc7a78a94 100644 --- a/ui/app/components/send_/send-footer/send-footer.component.js +++ b/ui/app/components/send_/send-footer/send-footer.component.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import PageContainerFooter from '../../page-container/page-container-footer.component' +import PageContainerFooter from '../../page-container/page-container-footer' import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../routes' export default class SendFooter extends Component { diff --git a/ui/app/components/send_/send-header/send-header.component.js b/ui/app/components/send_/send-header/send-header.component.js index 098fd89a4..dc4190b93 100644 --- a/ui/app/components/send_/send-header/send-header.component.js +++ b/ui/app/components/send_/send-header/send-header.component.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import PageContainerHeader from '../../page-container/page-container-header.component' +import PageContainerHeader from '../../page-container/page-container-header' import { DEFAULT_ROUTE } from '../../../routes' export default class SendHeader extends Component { diff --git a/ui/app/components/send_/send.component.js b/ui/app/components/send_/send.component.js index 969d76946..e14a97537 100644 --- a/ui/app/components/send_/send.component.js +++ b/ui/app/components/send_/send.component.js @@ -6,7 +6,6 @@ import { doesAmountErrorRequireUpdate, } from './send.utils' -import PageContainer from '..//page-container/page-container.component' import SendHeader from './send-header/send-header.container' import SendContent from './send-content/send-content.component' import SendFooter from './send-footer/send-footer.container' @@ -128,11 +127,11 @@ export default class SendTransactionScreen extends PersistentForm { const { history } = this.props return ( - <PageContainer> + <div className="page-container"> <SendHeader history={history}/> <SendContent/> <SendFooter history={history}/> - </PageContainer> + </div> ) } diff --git a/ui/app/send-v2.js b/ui/app/send-v2.js index 16964b45d..228cb22d0 100644 --- a/ui/app/send-v2.js +++ b/ui/app/send-v2.js @@ -3,8 +3,23 @@ const PropTypes = require('prop-types') const PersistentForm = require('../lib/persistent-form') const h = require('react-hyperscript') +const ethAbi = require('ethereumjs-abi') +const ethUtil = require('ethereumjs-util') + +const FromDropdown = require('./components/send/from-dropdown') +const EnsInput = require('./components/ens-input') +const CurrencyDisplay = require('./components/send/currency-display') +const MemoTextArea = require('./components/send/memo-textarea') +const GasFeeDisplay = require('./components/send/gas-fee-display-v2') + +const { + TOKEN_TRANSFER_FUNCTION_SIGNATURE, +} = require('./components/send/send-constants') + const { + multiplyCurrencies, conversionGreaterThan, + subtractCurrencies, } = require('./conversion-util') const { calcTokenAmount, @@ -14,11 +29,12 @@ const { isTokenBalanceSufficient, getGasTotal, } = require('./components/send/send-utils') +const { isValidAddress } = require('./util') -import PageContainer from './components/page-container/page-container.component' -import SendHeader from './components/send_/send-header/send-header.container' -import SendContent from './components/send_/send-content/send-content.component' -import SendFooter from './components/send_/send-footer/send-footer.container' +import PageContainer from './components/page-container' +// import SendHeader from './components/send_/send-header/send-header.container' +// import PageContainerContent from './components/page-container/page-container-content.component' +// import PageContainerFooter from './components/page-container/page-container-footer.component' SendTransactionScreen.contextTypes = { t: PropTypes.func, @@ -40,6 +56,8 @@ function SendTransactionScreen () { gasLoadingError: false, } + this.handleToChange = this.handleToChange.bind(this) + this.handleAmountChange = this.handleAmountChange.bind(this) this.validateAmount = this.validateAmount.bind(this) } @@ -74,6 +92,17 @@ SendTransactionScreen.prototype.updateSendTokenBalance = function (usersToken) { } SendTransactionScreen.prototype.componentWillMount = function () { + const { + updateTokenExchangeRate, + selectedToken = {}, + } = this.props + + const { symbol } = selectedToken || {} + + if (symbol) { + updateTokenExchangeRate(symbol) + } + this.updateGas() } @@ -84,7 +113,7 @@ SendTransactionScreen.prototype.updateGas = function () { estimateGas, selectedAddress, data, - setGasTotal, + updateGasTotal, from, tokenContract, editingTransactionId, @@ -110,7 +139,7 @@ SendTransactionScreen.prototype.updateGas = function () { ]) .then(([gasPrice, gas]) => { const newGasTotal = getGasTotal(gas, gasPrice) - setGasTotal(newGasTotal) + updateGasTotal(newGasTotal) this.setState({ gasLoadingError: false }) }) .catch(err => { @@ -118,7 +147,7 @@ SendTransactionScreen.prototype.updateGas = function () { }) } else { const newGasTotal = getGasTotal(gasLimit, gasPrice) - setGasTotal(newGasTotal) + updateGasTotal(newGasTotal) } } @@ -157,6 +186,139 @@ SendTransactionScreen.prototype.componentDidUpdate = function (prevProps) { } } +SendTransactionScreen.prototype.renderErrorMessage = function (errorType) { + const { errors } = this.props + const errorMessage = errors[errorType] + + return errorMessage + ? h('div.send-v2__error', [ errorMessage ]) + : null +} + +SendTransactionScreen.prototype.handleFromChange = async function (newFrom) { + const { + updateSendFrom, + tokenContract, + } = this.props + + if (tokenContract) { + const usersToken = await tokenContract.balanceOf(newFrom.address) + this.updateSendTokenBalance(usersToken) + } + updateSendFrom(newFrom) +} + +SendTransactionScreen.prototype.renderFromRow = function () { + const { + from, + fromAccounts, + conversionRate, + } = this.props + + const { fromDropdownOpen } = this.state + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', 'From:'), + + h('div.send-v2__form-field', [ + h(FromDropdown, { + dropdownOpen: fromDropdownOpen, + accounts: fromAccounts, + selectedAccount: from, + onSelect: newFrom => this.handleFromChange(newFrom), + openDropdown: () => this.setState({ fromDropdownOpen: true }), + closeDropdown: () => this.setState({ fromDropdownOpen: false }), + conversionRate, + }), + ]), + + ]) +} + +SendTransactionScreen.prototype.handleToChange = function (to, nickname = '') { + const { + updateSendTo, + updateSendErrors, + } = this.props + let toError = null + + if (!to) { + toError = this.context.t('required') + } else if (!isValidAddress(to)) { + toError = this.context.t('invalidAddressRecipient') + } + + updateSendTo(to, nickname) + updateSendErrors({ to: toError }) +} + +SendTransactionScreen.prototype.renderToRow = function () { + const { toAccounts, errors, to, network } = this.props + + const { toDropdownOpen } = this.state + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', [ + + this.context.t('to'), + + this.renderErrorMessage(this.context.t('to')), + + ]), + + h('div.send-v2__form-field', [ + h(EnsInput, { + name: 'address', + placeholder: 'Recipient Address', + network, + to, + accounts: Object.entries(toAccounts).map(([key, account]) => account), + dropdownOpen: toDropdownOpen, + openDropdown: () => this.setState({ toDropdownOpen: true }), + closeDropdown: () => this.setState({ toDropdownOpen: false }), + onChange: this.handleToChange, + inError: Boolean(errors.to), + }), + ]), + + ]) +} + +SendTransactionScreen.prototype.handleAmountChange = function (value) { + const amount = value + const { updateSendAmount, setMaxModeTo } = this.props + + setMaxModeTo(false) + this.validateAmount(amount) + updateSendAmount(amount) +} + +SendTransactionScreen.prototype.setAmountToMax = function () { + const { + from: { balance }, + updateSendAmount, + updateSendErrors, + tokenBalance, + selectedToken, + gasTotal, + } = this.props + const { decimals } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + + const maxAmount = selectedToken + ? multiplyCurrencies(tokenBalance, multiplier, {toNumericBase: 'hex'}) + : subtractCurrencies( + ethUtil.addHexPrefix(balance), + ethUtil.addHexPrefix(gasTotal), + { toNumericBase: 'hex' } + ) + + updateSendErrors({ amount: null }) + + updateSendAmount(maxAmount) +} SendTransactionScreen.prototype.validateAmount = function (value) { const { @@ -203,29 +365,291 @@ SendTransactionScreen.prototype.validateAmount = function (value) { ) if (conversionRate && !sufficientBalance) { - amountError = 'insufficientFunds' + amountError = this.context.t('insufficientFunds') } else if (verifyTokenBalance && !sufficientTokens) { - amountError = 'insufficientTokens' + amountError = this.context.t('insufficientTokens') } else if (amountLessThanZero) { - amountError = 'negativeETH' + amountError = this.context.t('negativeETH') } updateSendErrors({ amount: amountError }) } +SendTransactionScreen.prototype.renderAmountRow = function () { + const { + selectedToken, + primaryCurrency = 'ETH', + convertedCurrency, + amountConversionRate, + errors, + amount, + setMaxModeTo, + maxModeOn, + gasTotal, + } = this.props + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', [ + 'Amount:', + this.renderErrorMessage('amount'), + !errors.amount && gasTotal && h('div.send-v2__amount-max', { + onClick: (event) => { + event.preventDefault() + setMaxModeTo(true) + this.setAmountToMax() + }, + }, [ !maxModeOn ? this.context.t('max') : '' ]), + ]), + + h('div.send-v2__form-field', [ + h(CurrencyDisplay, { + inError: Boolean(errors.amount), + primaryCurrency, + convertedCurrency, + selectedToken, + value: amount || '0x0', + conversionRate: amountConversionRate, + handleChange: this.handleAmountChange, + }), + ]), + + ]) +} + +SendTransactionScreen.prototype.renderGasRow = function () { + const { + conversionRate, + convertedCurrency, + showCustomizeGasModal, + gasTotal, + } = this.props + const { gasLoadingError } = this.state + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', this.context.t('gasFee')), + + h('div.send-v2__form-field', [ + + h(GasFeeDisplay, { + gasTotal, + conversionRate, + convertedCurrency, + onClick: showCustomizeGasModal, + gasLoadingError, + }), + + ]), + + ]) +} + +SendTransactionScreen.prototype.renderMemoRow = function () { + const { updateSendMemo, memo } = this.props + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', 'Transaction Memo:'), + + h('div.send-v2__form-field', [ + h(MemoTextArea, { + memo, + onChange: (event) => updateSendMemo(event.target.value), + }), + ]), + + ]) +} + +SendTransactionScreen.prototype.renderForm = function () { + return h(PageContainerContent, [ + h('.send-v2__form', [ + this.renderFromRow(), + + this.renderToRow(), + + this.renderAmountRow(), + + this.renderGasRow(), + ]), + ]) +} + +SendTransactionScreen.prototype.renderFooter = function () { + const { + goHome, + clearSend, + gasTotal, + tokenBalance, + selectedToken, + errors: { amount: amountError, to: toError }, + } = this.props + + const missingTokenBalance = selectedToken && !tokenBalance + const noErrors = !amountError && toError === null + + return h(PageContainerFooter, { + onCancel: () => { + clearSend() + goHome() + }, + onSubmit: e => this.onSubmit(e), + disabled: !noErrors || !gasTotal || missingTokenBalance, + }) +} + SendTransactionScreen.prototype.render = function () { - const { history } = this.props + const { + isToken, + clearSend, + goHome, + gasTotal, + tokenBalance, + selectedToken, + errors: { amount: amountError, to: toError }, + } = this.props + + const missingTokenBalance = selectedToken && !tokenBalance + const noErrors = !amountError && toError === null return ( - h(PageContainer, [ + h(PageContainer, { + title: isToken ? this.context.t('sendTokens') : this.context.t('sendETH'), + subtitle: this.context.t('onlySendToEtherAddress'), + onClose: () => { + clearSend() + goHome() + }, + ContentComponent: this.renderForm, + onCancel: () => { + clearSend() + goHome() + }, + onSubmit: e => this.onSubmit(e), + disabled: !noErrors || !gasTotal || missingTokenBalance, + }) + // , [ - h(SendHeader), + // h(SendHeader), - h(SendContent), + // this.renderForm(), - h(SendFooter, { history }), - ]) + // this.renderFooter(), + // ]) ) } + +SendTransactionScreen.prototype.addToAddressBookIfNew = function (newAddress, nickname = '') { + const { toAccounts, addToAddressBook } = this.props + if (!toAccounts.find(({ address }) => newAddress === address)) { + // TODO: nickname, i.e. addToAddressBook(recipient, nickname) + addToAddressBook(newAddress, nickname) + } +} + +SendTransactionScreen.prototype.getEditedTx = function () { + const { + from: {address: from}, + to, + amount, + gasLimit: gas, + gasPrice, + selectedToken, + editingTransactionId, + unapprovedTxs, + } = this.props + + const editingTx = { + ...unapprovedTxs[editingTransactionId], + txParams: { + from: ethUtil.addHexPrefix(from), + gas: ethUtil.addHexPrefix(gas), + gasPrice: ethUtil.addHexPrefix(gasPrice), + }, + } + + if (selectedToken) { + const data = TOKEN_TRANSFER_FUNCTION_SIGNATURE + Array.prototype.map.call( + ethAbi.rawEncode(['address', 'uint256'], [to, ethUtil.addHexPrefix(amount)]), + x => ('00' + x.toString(16)).slice(-2) + ).join('') + + Object.assign(editingTx.txParams, { + value: ethUtil.addHexPrefix('0'), + to: ethUtil.addHexPrefix(selectedToken.address), + data, + }) + } else { + const { data } = unapprovedTxs[editingTransactionId].txParams + + Object.assign(editingTx.txParams, { + value: ethUtil.addHexPrefix(amount), + to: ethUtil.addHexPrefix(to), + data, + }) + + if (typeof editingTx.txParams.data === 'undefined') { + delete editingTx.txParams.data + } + } + + return editingTx +} + +SendTransactionScreen.prototype.onSubmit = function (event) { + event.preventDefault() + const { + from: {address: from}, + to: _to, + amount, + gasLimit: gas, + gasPrice, + signTokenTx, + signTx, + updateTx, + selectedToken, + editingTransactionId, + toNickname, + errors: { amount: amountError, to: toError }, + } = this.props + + const noErrors = !amountError && toError === null + + if (!noErrors) { + return + } + + const to = ethUtil.addHexPrefix(_to) + + this.addToAddressBookIfNew(to, toNickname) + + if (editingTransactionId) { + const editedTx = this.getEditedTx() + + updateTx(editedTx) + } else { + + const txParams = { + from, + value: '0', + gas, + gasPrice, + } + + if (!selectedToken) { + txParams.value = amount + txParams.to = to + } + + Object.keys(txParams).forEach(key => { + txParams[key] = ethUtil.addHexPrefix(txParams[key]) + }) + + selectedToken + ? signTokenTx(selectedToken.address, to, amount, txParams) + : signTx(txParams) + } +} |