diff options
author | Chi Kei Chan <chikeichan@gmail.com> | 2018-01-20 07:29:36 +0800 |
---|---|---|
committer | Chi Kei Chan <chikeichan@gmail.com> | 2018-01-20 07:29:36 +0800 |
commit | 77c545336b38aefc6105cde1799b18066df8bef9 (patch) | |
tree | 121a140131c8cb1f4b746d8f37d4a403b0910aad /ui | |
parent | 98b5a62fa74aa6730a25df28dfe5032cfb487697 (diff) | |
parent | 4e63924e607a07f94ff0a741a036ab352b0b7a3f (diff) | |
download | tangerine-wallet-browser-77c545336b38aefc6105cde1799b18066df8bef9.tar tangerine-wallet-browser-77c545336b38aefc6105cde1799b18066df8bef9.tar.gz tangerine-wallet-browser-77c545336b38aefc6105cde1799b18066df8bef9.tar.bz2 tangerine-wallet-browser-77c545336b38aefc6105cde1799b18066df8bef9.tar.lz tangerine-wallet-browser-77c545336b38aefc6105cde1799b18066df8bef9.tar.xz tangerine-wallet-browser-77c545336b38aefc6105cde1799b18066df8bef9.tar.zst tangerine-wallet-browser-77c545336b38aefc6105cde1799b18066df8bef9.zip |
Merge branch 'uat' into newmaster
Diffstat (limited to 'ui')
152 files changed, 17901 insertions, 3280 deletions
diff --git a/ui/app/account-and-transaction-details.js b/ui/app/account-and-transaction-details.js new file mode 100644 index 000000000..60293de77 --- /dev/null +++ b/ui/app/account-and-transaction-details.js @@ -0,0 +1,38 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +// Main Views +const TxView = require('./components/tx-view') +const WalletView = require('./components/wallet-view') + +module.exports = AccountAndTransactionDetails + +inherits(AccountAndTransactionDetails, Component) +function AccountAndTransactionDetails () { + Component.call(this) +} + +AccountAndTransactionDetails.prototype.render = function () { + return h('div', { + style: { + display: 'flex', + flex: '1 0 auto', + }, + }, [ + // wallet + h(WalletView, { + style: { + }, + responsiveDisplayClassname: '.lap-visible', + }, [ + ]), + + // transaction + h(TxView, { + style: { + }, + }, [ + ]), + ]) +} + diff --git a/ui/app/account-detail.js b/ui/app/account-detail.js index c9a8a774d..0da435298 100644 --- a/ui/app/account-detail.js +++ b/ui/app/account-detail.js @@ -5,15 +5,10 @@ const h = require('react-hyperscript') const connect = require('react-redux').connect const actions = require('./actions') const valuesFor = require('./util').valuesFor -const Identicon = require('./components/identicon') -const EthBalance = require('./components/eth-balance') const TransactionList = require('./components/transaction-list') const ExportAccountView = require('./components/account-export') -const ethUtil = require('ethereumjs-util') -const EditableLabel = require('./components/editable-label') const TabBar = require('./components/tab-bar') const TokenList = require('./components/token-list') -const AccountDropdowns = require('./components/account-dropdowns').AccountDropdowns module.exports = connect(mapStateToProps)(AccountDetailScreen) @@ -41,181 +36,11 @@ function AccountDetailScreen () { Component.call(this) } -AccountDetailScreen.prototype.render = function () { - var props = this.props - var selected = props.address || Object.keys(props.accounts)[0] - var checksumAddress = selected && ethUtil.toChecksumAddress(selected) - var identity = props.identities[selected] - var account = props.accounts[selected] - const { network, conversionRate, currentCurrency } = props - - return ( - - h('.account-detail-section.full-flex-height', [ - - // identicon, label, balance, etc - h('.account-data-subsection', { - style: { - margin: '0 20px', - flex: '1 0 auto', - }, - }, [ - - // header - identicon + nav - h('div', { - style: { - paddingTop: '20px', - display: 'flex', - justifyContent: 'flex-start', - alignItems: 'flex-start', - }, - }, [ - - // large identicon and addresses - h('.identicon-wrapper.select-none', [ - h(Identicon, { - diameter: 62, - address: selected, - }), - ]), - h('flex-column', { - style: { - lineHeight: '10px', - marginLeft: '15px', - width: '100%', - }, - }, [ - h(EditableLabel, { - textValue: identity ? identity.name : '', - state: { - isEditingLabel: false, - }, - saveText: (text) => { - props.dispatch(actions.saveAccountLabel(selected, text)) - }, - }, [ - - // What is shown when not editing + edit text: - h('label.editing-label', [h('.edit-text', 'edit')]), - h( - 'div', - { - style: { - display: 'flex', - justifyContent: 'flex-start', - alignItems: 'center', - }, - }, - [ - h( - 'div.font-medium.color-forest', - { - name: 'edit', - style: { - }, - }, - [ - h('h2', { - style: { - maxWidth: '180px', - overflow: 'hidden', - textOverflow: 'ellipsis', - padding: '5px 0px', - lineHeight: '25px', - }, - }, [ - identity && identity.name, - ]), - ] - ), - h( - AccountDropdowns, - { - style: { - marginRight: '8px', - marginLeft: 'auto', - cursor: 'pointer', - }, - selected, - network, - identities: props.identities, - enableAccountOptions: true, - }, - ), - ] - ), - ]), - h('.flex-row', { - style: { - width: '15em', - justifyContent: 'space-between', - alignItems: 'baseline', - }, - }, [ - - // address - - h('div', { - style: { - overflow: 'hidden', - textOverflow: 'ellipsis', - paddingTop: '3px', - width: '5em', - height: '15px', - fontSize: '13px', - fontFamily: 'Montserrat Light', - textRendering: 'geometricPrecision', - marginBottom: '15px', - color: '#AEAEAE', - }, - }, checksumAddress), - ]), - - // account ballence - - ]), - ]), - h('.flex-row', { - style: { - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - }, [ - - h(EthBalance, { - value: account && account.balance, - conversionRate, - currentCurrency, - style: { - lineHeight: '7px', - marginTop: '10px', - }, - }), - - h('.flex-grow'), - - h('button', { - onClick: () => props.dispatch(actions.buyEthView(selected)), - style: { marginRight: '10px' }, - }, 'BUY'), - - h('button', { - onClick: () => props.dispatch(actions.showSendPage()), - style: { - marginBottom: '20px', - marginRight: '8px', - }, - }, 'SEND'), - - ]), - ]), - - // subview (tx history, pk export confirm, buy eth warning) - this.subview(), - - ]) - ) -} +// Note: This component is no longer used. Leaving the file for reference: +// - structuring routing for add token +// - state required for TxList +// Delete file when those features are complete +AccountDetailScreen.prototype.render = function () {} AccountDetailScreen.prototype.subview = function () { var subview diff --git a/ui/app/accounts/import/index.js b/ui/app/accounts/import/index.js index 46260c3e7..0c901c09b 100644 --- a/ui/app/accounts/import/index.js +++ b/ui/app/accounts/import/index.js @@ -2,7 +2,6 @@ const inherits = require('util').inherits const Component = require('react').Component const h = require('react-hyperscript') const connect = require('react-redux').connect -const actions = require('../../actions') import Select from 'react-select' // Subviews @@ -34,34 +33,14 @@ AccountImportSubview.prototype.render = function () { const { type } = state return ( - h('div', { - style: { - }, - }, [ - h('.section-title.flex-row.flex-center', [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - onClick: (event) => { - props.dispatch(actions.goHome()) - }, - }), - h('h2.page-subtitle', 'Import Accounts'), - ]), - h('div', { - style: { - padding: '10px', - color: 'rgb(174, 174, 174)', - }, - }, [ + h('div.new-account-import-form', [ - h('h3', { style: { padding: '3px' } }, 'SELECT TYPE'), + h('div.new-account-import-form__select-section', [ - h('style', ` - .has-value.Select--single > .Select-control .Select-value .Select-value-label, .Select-value-label { - color: rgb(174,174,174); - } - `), + h('div.new-account-import-form__select-label', 'SELECT TYPE'), h(Select, { + className: 'new-account-import-form__select', name: 'import-type-select', clearable: false, value: type || menuItems[0], @@ -72,10 +51,10 @@ AccountImportSubview.prototype.render = function () { } }), onChange: (opt) => { - props.dispatch(actions.showImportPage()) this.setState({ type: opt.value }) }, }), + ]), this.renderImportView(), diff --git a/ui/app/accounts/import/json.js b/ui/app/accounts/import/json.js index 158a3c923..9cefcfa77 100644 --- a/ui/app/accounts/import/json.js +++ b/ui/app/accounts/import/json.js @@ -5,7 +5,7 @@ const connect = require('react-redux').connect const actions = require('../../actions') const FileInput = require('react-simple-file-input').default -const HELP_LINK = 'https://github.com/MetaMask/faq/blob/master/README.md#q-i-cant-use-the-import-feature-for-uploading-a-json-file-the-window-keeps-closing-when-i-try-to-select-a-file' +const HELP_LINK = 'https://support.metamask.io/kb/article/7-importing-accounts' module.exports = connect(mapStateToProps)(JsonImportSubview) @@ -24,14 +24,7 @@ JsonImportSubview.prototype.render = function () { const { error } = this.props return ( - h('div', { - style: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: '5px 15px 0px 15px', - }, - }, [ + h('div.new-account-import-form__json', [ h('p', 'Used by a variety of different clients'), h('a.warning', { href: HELP_LINK, target: '_blank' }, 'File import not working? Click here!'), @@ -40,28 +33,35 @@ JsonImportSubview.prototype.render = function () { readAs: 'text', onLoad: this.onLoad.bind(this), style: { - margin: '20px 0px 12px 20px', + margin: '20px 0px 12px 34%', fontSize: '15px', + display: 'flex', + justifyContent: 'center', }, }), - h('input.large-input.letter-spacey', { + h('input.new-account-import-form__input-password', { type: 'password', placeholder: 'Enter password', id: 'json-password-box', onKeyPress: this.createKeyringOnEnter.bind(this), - style: { - width: 260, - marginTop: 12, - }, }), - h('button.primary', { - onClick: this.createNewKeychain.bind(this), - style: { - margin: 12, - }, - }, 'Import'), + h('div.new-account-create-form__buttons', {}, [ + + h('button.new-account-create-form__button-cancel', { + onClick: () => this.props.goHome(), + }, [ + 'CANCEL', + ]), + + h('button.new-account-create-form__button-create', { + onClick: () => this.createNewKeychain.bind(this), + }, [ + 'IMPORT', + ]), + + ]), error ? h('span.error', error) : null, ]) diff --git a/ui/app/accounts/import/private-key.js b/ui/app/accounts/import/private-key.js index 68ccee58e..43afbca87 100644 --- a/ui/app/accounts/import/private-key.js +++ b/ui/app/accounts/import/private-key.js @@ -4,7 +4,7 @@ const h = require('react-hyperscript') const connect = require('react-redux').connect const actions = require('../../actions') -module.exports = connect(mapStateToProps)(PrivateKeyImportView) +module.exports = connect(mapStateToProps, mapDispatchToProps)(PrivateKeyImportView) function mapStateToProps (state) { return { @@ -12,41 +12,49 @@ function mapStateToProps (state) { } } +function mapDispatchToProps (dispatch) { + return { + goHome: () => dispatch(actions.goHome()), + importNewAccount: (strategy, [ privateKey ]) => { + dispatch(actions.importNewAccount(strategy, [ privateKey ])) + }, + displayWarning: () => dispatch(actions.displayWarning(null)), + } +} + inherits(PrivateKeyImportView, Component) function PrivateKeyImportView () { Component.call(this) } PrivateKeyImportView.prototype.render = function () { - const { error } = this.props + const { error, goHome } = this.props return ( - h('div', { - style: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: '5px 15px 0px 15px', - }, - }, [ - h('span', 'Paste your private key string here'), + h('div.new-account-import-form__private-key', [ + h('span.new-account-create-form__instruction', 'Paste your private key string here:'), - h('input.large-input.letter-spacey', { + h('input.new-account-import-form__input-password', { type: 'password', id: 'private-key-box', - onKeyPress: this.createKeyringOnEnter.bind(this), - style: { - width: 260, - marginTop: 12, - }, + onKeyPress: () => this.createKeyringOnEnter(), }), - h('button.primary', { - onClick: this.createNewKeychain.bind(this), - style: { - margin: 12, - }, - }, 'Import'), + h('div.new-account-create-form__buttons', {}, [ + + h('button.new-account-create-form__button-cancel', { + onClick: () => goHome(), + }, [ + 'CANCEL', + ]), + + h('button.new-account-create-form__button-create', { + onClick: () => this.createNewKeychain(), + }, [ + 'IMPORT', + ]), + + ]), error ? h('span.error', error) : null, ]) @@ -63,5 +71,6 @@ PrivateKeyImportView.prototype.createKeyringOnEnter = function (event) { PrivateKeyImportView.prototype.createNewKeychain = function () { const input = document.getElementById('private-key-box') const privateKey = input.value - this.props.dispatch(actions.importNewAccount('Private Key', [ privateKey ])) + + this.props.importNewAccount('Private Key', [ privateKey ]) } diff --git a/ui/app/accounts/new-account/create-form.js b/ui/app/accounts/new-account/create-form.js new file mode 100644 index 000000000..494726ae4 --- /dev/null +++ b/ui/app/accounts/new-account/create-form.js @@ -0,0 +1,96 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const { connect } = require('react-redux') +const actions = require('../../actions') + +class NewAccountCreateForm extends Component { + constructor (props) { + super(props) + const { numberOfExistingAccounts = 0 } = props + const newAccountNumber = numberOfExistingAccounts + 1 + + this.state = { + newAccountName: `Account ${newAccountNumber}`, + } + } + + render () { + const { newAccountName } = this.state + + return h('div.new-account-create-form', [ + + h('div.new-account-create-form__input-label', {}, [ + 'Account Name', + ]), + + h('div.new-account-create-form__input-wrapper', {}, [ + h('input.new-account-create-form__input', { + value: this.state.newAccountName, + placeholder: 'E.g. My new account', + onChange: event => this.setState({ newAccountName: event.target.value }), + }, []), + ]), + + h('div.new-account-create-form__buttons', {}, [ + + h('button.new-account-create-form__button-cancel', { + onClick: () => this.props.goHome(), + }, [ + 'CANCEL', + ]), + + h('button.new-account-create-form__button-create', { + onClick: () => this.props.createAccount(newAccountName), + }, [ + 'CREATE', + ]), + + ]), + + ]) + } +} + +NewAccountCreateForm.propTypes = { + hideModal: PropTypes.func, + showImportPage: PropTypes.func, + createAccount: PropTypes.func, + goHome: PropTypes.func, + numberOfExistingAccounts: PropTypes.number, +} + +const mapStateToProps = state => { + const { metamask: { network, selectedAddress, identities = {} } } = state + const numberOfExistingAccounts = Object.keys(identities).length + + return { + network, + address: selectedAddress, + numberOfExistingAccounts, + } +} + +const mapDispatchToProps = dispatch => { + return { + toCoinbase: (address) => { + dispatch(actions.buyEth({ network: '1', address, amount: 0 })) + }, + hideModal: () => { + dispatch(actions.hideModal()) + }, + createAccount: (newAccountName) => { + dispatch(actions.addNewAccount()) + .then((newAccountAddress) => { + if (newAccountName) { + dispatch(actions.saveAccountLabel(newAccountAddress, newAccountName)) + } + dispatch(actions.goHome()) + }) + }, + showImportPage: () => dispatch(actions.showImportPage()), + goHome: () => dispatch(actions.goHome()), + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(NewAccountCreateForm) diff --git a/ui/app/accounts/new-account/index.js b/ui/app/accounts/new-account/index.js new file mode 100644 index 000000000..acf0dc6e4 --- /dev/null +++ b/ui/app/accounts/new-account/index.js @@ -0,0 +1,81 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const { getCurrentViewContext } = require('../../selectors') +const classnames = require('classnames') + +const NewAccountCreateForm = require('./create-form') +const NewAccountImportForm = require('../import') + +function mapStateToProps (state) { + return { + displayedForm: getCurrentViewContext(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + displayForm: form => dispatch(actions.setNewAccountForm(form)), + showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), + showExportPrivateKeyModal: () => { + dispatch(actions.showModal({ name: 'EXPORT_PRIVATE_KEY' })) + }, + hideModal: () => dispatch(actions.hideModal()), + saveAccountLabel: (address, label) => dispatch(actions.saveAccountLabel(address, label)), + } +} + +inherits(AccountDetailsModal, Component) +function AccountDetailsModal (props) { + Component.call(this) + + this.state = { + displayedForm: props.displayedForm, + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDetailsModal) + +AccountDetailsModal.prototype.render = function () { + const { displayedForm, displayForm } = this.props + + return h('div.new-account', {}, [ + + h('div.new-account__header', [ + + h('div.new-account__title', 'New Account'), + + h('div.new-account__tabs', [ + + h('div.new-account__tabs__tab', { + className: classnames('new-account__tabs__tab', { + 'new-account__tabs__selected': displayedForm === 'CREATE', + 'new-account__tabs__unselected cursor-pointer': displayedForm !== 'CREATE', + }), + onClick: () => displayForm('CREATE'), + }, 'Create'), + + h('div.new-account__tabs__tab', { + className: classnames('new-account__tabs__tab', { + 'new-account__tabs__selected': displayedForm === 'IMPORT', + 'new-account__tabs__unselected cursor-pointer': displayedForm !== 'IMPORT', + }), + onClick: () => displayForm('IMPORT'), + }, 'Import'), + + ]), + + ]), + + h('div.new-account__form', [ + + displayedForm === 'CREATE' + ? h(NewAccountCreateForm) + : h(NewAccountImportForm), + + ]), + + ]) +} diff --git a/ui/app/actions.js b/ui/app/actions.js index 52ea899aa..25cb2c23f 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -1,10 +1,28 @@ +const abi = require('human-standard-token-abi') const getBuyEthUrl = require('../../app/scripts/lib/buy-eth-url') +const { getTokenAddressFromTokenObject } = require('./util') +const ethUtil = require('ethereumjs-util') var actions = { _setBackgroundConnection: _setBackgroundConnection, GO_HOME: 'GO_HOME', goHome: goHome, + // modal state + MODAL_OPEN: 'UI_MODAL_OPEN', + MODAL_CLOSE: 'UI_MODAL_CLOSE', + showModal: showModal, + hideModal: hideModal, + // sidebar state + SIDEBAR_OPEN: 'UI_SIDEBAR_OPEN', + SIDEBAR_CLOSE: 'UI_SIDEBAR_CLOSE', + showSidebar: showSidebar, + hideSidebar: hideSidebar, + // network dropdown open + NETWORK_DROPDOWN_OPEN: 'UI_NETWORK_DROPDOWN_OPEN', + NETWORK_DROPDOWN_CLOSE: 'UI_NETWORK_DROPDOWN_CLOSE', + showNetworkDropdown: showNetworkDropdown, + hideNetworkDropdown: hideNetworkDropdown, // menu state getNetworkStatus: 'getNetworkStatus', // transition state @@ -33,12 +51,16 @@ var actions = { SHOW_NEW_VAULT_SEED: 'SHOW_NEW_VAULT_SEED', SHOW_INFO_PAGE: 'SHOW_INFO_PAGE', SHOW_IMPORT_PAGE: 'SHOW_IMPORT_PAGE', + SHOW_NEW_ACCOUNT_PAGE: 'SHOW_NEW_ACCOUNT_PAGE', + SET_NEW_ACCOUNT_FORM: 'SET_NEW_ACCOUNT_FORM', unlockMetamask: unlockMetamask, unlockFailed: unlockFailed, showCreateVault: showCreateVault, showRestoreVault: showRestoreVault, showInitializeMenu: showInitializeMenu, showImportPage, + showNewAccountPage, + setNewAccountForm, createNewVaultAndKeychain: createNewVaultAndKeychain, createNewVaultAndRestore: createNewVaultAndRestore, createNewVaultInProgress: createNewVaultInProgress, @@ -68,6 +90,8 @@ var actions = { hideWarning: hideWarning, // accounts screen SET_SELECTED_ACCOUNT: 'SET_SELECTED_ACCOUNT', + SET_SELECTED_TOKEN: 'SET_SELECTED_TOKEN', + setSelectedToken, SHOW_ACCOUNT_DETAIL: 'SHOW_ACCOUNT_DETAIL', SHOW_ACCOUNTS_PAGE: 'SHOW_ACCOUNTS_PAGE', SHOW_CONF_TX_PAGE: 'SHOW_CONF_TX_PAGE', @@ -78,6 +102,8 @@ var actions = { // account detail screen SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', showSendPage: showSendPage, + SHOW_SEND_TOKEN_PAGE: 'SHOW_SEND_TOKEN_PAGE', + showSendTokenPage, ADD_TO_ADDRESS_BOOK: 'ADD_TO_ADDRESS_BOOK', addToAddressBook: addToAddressBook, REQUEST_ACCOUNT_EXPORT: 'REQUEST_ACCOUNT_EXPORT', @@ -86,6 +112,7 @@ var actions = { exportAccount: exportAccount, SHOW_PRIVATE_KEY: 'SHOW_PRIVATE_KEY', showPrivateKey: showPrivateKey, + exportAccountComplete, SAVE_ACCOUNT_LABEL: 'SAVE_ACCOUNT_LABEL', saveAccountLabel: saveAccountLabel, // tx conf screen @@ -93,22 +120,57 @@ var actions = { TRANSACTION_ERROR: 'TRANSACTION_ERROR', NEXT_TX: 'NEXT_TX', PREVIOUS_TX: 'PREV_TX', + EDIT_TX: 'EDIT_TX', signMsg: signMsg, cancelMsg: cancelMsg, signPersonalMsg, cancelPersonalMsg, signTypedMsg, cancelTypedMsg, + sendTx: sendTx, signTx: signTx, + signTokenTx: signTokenTx, + updateTransaction, updateAndApproveTx, cancelTx: cancelTx, completedTx: completedTx, txError: txError, nextTx: nextTx, + editTx, previousTx: previousTx, cancelAllTx: cancelAllTx, viewPendingTx: viewPendingTx, VIEW_PENDING_TX: 'VIEW_PENDING_TX', + updateTransactionParams, + UPDATE_TRANSACTION_PARAMS: 'UPDATE_TRANSACTION_PARAMS', + // send screen + estimateGas, + getGasPrice, + UPDATE_GAS_LIMIT: 'UPDATE_GAS_LIMIT', + UPDATE_GAS_PRICE: 'UPDATE_GAS_PRICE', + UPDATE_GAS_TOTAL: 'UPDATE_GAS_TOTAL', + UPDATE_SEND_FROM: 'UPDATE_SEND_FROM', + UPDATE_SEND_TOKEN_BALANCE: 'UPDATE_SEND_TOKEN_BALANCE', + UPDATE_SEND_TO: 'UPDATE_SEND_TO', + UPDATE_SEND_AMOUNT: 'UPDATE_SEND_AMOUNT', + UPDATE_SEND_MEMO: 'UPDATE_SEND_MEMO', + UPDATE_SEND_ERRORS: 'UPDATE_SEND_ERRORS', + UPDATE_MAX_MODE: 'UPDATE_MAX_MODE', + UPDATE_SEND: 'UPDATE_SEND', + CLEAR_SEND: 'CLEAR_SEND', + updateGasLimit, + updateGasPrice, + updateGasTotal, + updateSendTokenBalance, + updateSendFrom, + updateSendTo, + updateSendAmount, + updateSendMemo, + updateSendErrors, + setMaxModeTo, + updateSend, + clearSend, + setSelectedAddress, // app messages confirmSeedWords: confirmSeedWords, showAccountDetail: showAccountDetail, @@ -125,8 +187,13 @@ var actions = { SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE', showAddTokenPage, addToken, + addTokens, + removeToken, + updateTokens, + UPDATE_TOKENS: 'UPDATE_TOKENS', setRpcTarget: setRpcTarget, setProviderType: setProviderType, + updateProviderType, // loading overlay SHOW_LOADING: 'SHOW_LOADING_INDICATION', HIDE_LOADING: 'HIDE_LOADING_INDICATION', @@ -144,6 +211,8 @@ var actions = { coinBaseSubview: coinBaseSubview, SHAPESHIFT_SUBVIEW: 'SHAPESHIFT_SUBVIEW', shapeShiftSubview: shapeShiftSubview, + UPDATE_TOKEN_EXCHANGE_RATE: 'UPDATE_TOKEN_EXCHANGE_RATE', + updateTokenExchangeRate, PAIR_UPDATE: 'PAIR_UPDATE', pairUpdate: pairUpdate, coinShiftRquest: coinShiftRquest, @@ -168,6 +237,25 @@ var actions = { callBackgroundThenUpdate, forceUpdateMetamaskState, + + TOGGLE_ACCOUNT_MENU: 'TOGGLE_ACCOUNT_MENU', + toggleAccountMenu, + + useEtherscanProvider, + + SET_USE_BLOCKIE: 'SET_USE_BLOCKIE', + setUseBlockie, + + // Feature Flags + setFeatureFlag, + updateFeatureFlags, + UPDATE_FEATURE_FLAGS: 'UPDATE_FEATURE_FLAGS', + + // Network + setNetworkEndpoints, + updateNetworkEndpointType, + UPDATE_NETWORK_ENDPOINT_TYPE: 'UPDATE_NETWORK_ENDPOINT_TYPE', + retryTransaction, } @@ -357,7 +445,24 @@ function navigateToNewAccountScreen () { function addNewAccount () { log.debug(`background.addNewAccount`) - return callBackgroundThenUpdate(background.addNewAccount) + return (dispatch, getState) => { + const oldIdentities = getState().metamask.identities + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.addNewAccount((err, { identities: newIdentities}) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + const newAccountAddress = Object.keys(newIdentities).find(address => !oldIdentities[address]) + + dispatch(actions.hideLoadingIndication()) + + forceUpdateMetamaskState(dispatch) + return resolve(newAccountAddress) + }) + }) + } } function showInfoPage () { @@ -368,16 +473,16 @@ function showInfoPage () { function setCurrentCurrency (currencyCode) { return (dispatch) => { - dispatch(this.showLoadingIndication()) + dispatch(actions.showLoadingIndication()) log.debug(`background.setCurrentCurrency`) background.setCurrentCurrency(currencyCode, (err, data) => { - dispatch(this.hideLoadingIndication()) + dispatch(actions.hideLoadingIndication()) if (err) { log.error(err.stack) return dispatch(actions.displayWarning(err.message)) } dispatch({ - type: this.SET_CURRENT_FIAT, + type: actions.SET_CURRENT_FIAT, value: { currentCurrency: data.currentCurrency, conversionRate: data.conversionRate, @@ -450,10 +555,170 @@ function signTx (txData) { dispatch(actions.showLoadingIndication()) global.ethQuery.sendTransaction(txData, (err, data) => { dispatch(actions.hideLoadingIndication()) - if (err) dispatch(actions.displayWarning(err.message)) - dispatch(this.goHome()) + if (err) return dispatch(actions.displayWarning(err.message)) + dispatch(actions.hideWarning()) + }) + dispatch(actions.showConfTxPage({})) + } +} + +function estimateGas (params = {}) { + return (dispatch) => { + return new Promise((resolve, reject) => { + global.ethQuery.estimateGas(params, (err, data) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + dispatch(actions.hideWarning()) + dispatch(actions.updateGasLimit(data)) + return resolve(data) + }) + }) + } +} + +function updateGasLimit (gasLimit) { + return { + type: actions.UPDATE_GAS_LIMIT, + value: gasLimit, + } +} + +function getGasPrice () { + return (dispatch) => { + return new Promise((resolve, reject) => { + global.ethQuery.gasPrice((err, data) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + dispatch(actions.hideWarning()) + dispatch(actions.updateGasPrice(data)) + return resolve(data) + }) + }) + } +} + +function updateGasPrice (gasPrice) { + return { + type: actions.UPDATE_GAS_PRICE, + value: gasPrice, + } +} + +function updateGasTotal (gasTotal) { + return { + type: actions.UPDATE_GAS_TOTAL, + value: gasTotal, + } +} + +function updateSendTokenBalance (tokenBalance) { + return { + type: actions.UPDATE_SEND_TOKEN_BALANCE, + value: tokenBalance, + } +} + +function updateSendFrom (from) { + return { + type: actions.UPDATE_SEND_FROM, + value: from, + } +} + +function updateSendTo (to) { + return { + type: actions.UPDATE_SEND_TO, + value: to, + } +} + +function updateSendAmount (amount) { + return { + type: actions.UPDATE_SEND_AMOUNT, + value: amount, + } +} + +function updateSendMemo (memo) { + return { + type: actions.UPDATE_SEND_MEMO, + value: memo, + } +} + +function updateSendErrors (error) { + return { + type: actions.UPDATE_SEND_ERRORS, + value: error, + } +} + +function setMaxModeTo (bool) { + return { + type: actions.UPDATE_MAX_MODE, + value: bool, + } +} + +function updateSend (newSend) { + return { + type: actions.UPDATE_SEND, + value: newSend, + } +} + +function clearSend () { + return { + type: actions.CLEAR_SEND, + } +} + + +function sendTx (txData) { + log.info(`actions - sendTx: ${JSON.stringify(txData.txParams)}`) + return (dispatch) => { + log.debug(`actions calling background.approveTransaction`) + background.approveTransaction(txData.id, (err) => { + if (err) { + dispatch(actions.txError(err)) + return log.error(err.message) + } + dispatch(actions.completedTx(txData.id)) + }) + } +} + +function signTokenTx (tokenAddress, toAddress, amount, txData) { + return dispatch => { + dispatch(actions.showLoadingIndication()) + const token = global.eth.contract(abi).at(tokenAddress) + token.transfer(toAddress, ethUtil.addHexPrefix(amount), txData) + .catch(err => { + dispatch(actions.hideLoadingIndication()) + dispatch(actions.displayWarning(err.message)) + }) + dispatch(actions.showConfTxPage({})) + } +} + +function updateTransaction (txData) { + log.info('actions: updateTx: ' + JSON.stringify(txData)) + return (dispatch) => { + log.debug(`actions calling background.updateTx`) + background.updateTransaction(txData, (err) => { + dispatch(actions.hideLoadingIndication()) + dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) + if (err) { + dispatch(actions.txError(err)) + dispatch(actions.goHome()) + return log.error(err.message) + } + dispatch(actions.showConfTxPage({ id: txData.id })) }) - dispatch(actions.showConfTxPage()) } } @@ -463,6 +728,8 @@ function updateAndApproveTx (txData) { log.debug(`actions calling background.updateAndApproveTx`) background.updateAndApproveTransaction(txData, (err) => { dispatch(actions.hideLoadingIndication()) + dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) + dispatch(actions.clearSend()) if (err) { dispatch(actions.txError(err)) dispatch(actions.goHome()) @@ -480,6 +747,14 @@ function completedTx (id) { } } +function updateTransactionParams (id, txParams) { + return { + type: actions.UPDATE_TRANSACTION_PARAMS, + id, + value: txParams, + } +} + function txError (err) { return { type: actions.TRANSACTION_ERROR, @@ -509,6 +784,7 @@ function cancelTx (txData) { return (dispatch) => { log.debug(`background.cancelTransaction`) background.cancelTransaction(txData.id, () => { + dispatch(actions.clearSend()) dispatch(actions.completedTx(txData.id)) }) } @@ -558,6 +834,20 @@ function showImportPage () { } } +function showNewAccountPage (formToSelect) { + return { + type: actions.SHOW_NEW_ACCOUNT_PAGE, + formToSelect, + } +} + +function setNewAccountForm (formToSelect) { + return { + type: actions.SET_NEW_ACCOUNT_FORM, + formToSelect, + } +} + function createNewVaultInProgress () { return { type: actions.CREATE_NEW_VAULT_IN_PROGRESS, @@ -614,9 +904,54 @@ function updateMetamaskState (newState) { } } +const backgroundSetLocked = () => { + return new Promise((resolve, reject) => { + background.setLocked(error => { + if (error) { + return reject(error) + } + + resolve() + }) + }) +} + +const updateMetamaskStateFromBackground = () => { + log.debug(`background.getState`) + + return new Promise((resolve, reject) => { + background.getState((error, newState) => { + if (error) { + return reject(error) + } + + resolve(newState) + }) + }) +} + function lockMetamask () { log.debug(`background.setLocked`) - return callBackgroundThenUpdate(background.setLocked) + + return dispatch => { + dispatch(actions.showLoadingIndication()) + + return backgroundSetLocked() + .then(() => updateMetamaskStateFromBackground()) + .catch(error => { + dispatch(actions.displayWarning(error.message)) + return Promise.reject(error) + }) + .then(newState => { + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + dispatch({ type: actions.LOCK_METAMASK }) + }) + .catch(() => { + dispatch(actions.hideLoadingIndication()) + dispatch({ type: actions.LOCK_METAMASK }) + }) + } } function setCurrentAccountTab (newTabName) { @@ -624,6 +959,26 @@ function setCurrentAccountTab (newTabName) { return callBackgroundThenUpdateNoSpinner(background.setCurrentAccountTab, newTabName) } +function setSelectedToken (tokenAddress) { + return { + type: actions.SET_SELECTED_TOKEN, + value: tokenAddress || null, + } +} + +function setSelectedAddress (address) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.setSelectedAddress`) + background.setSelectedAddress(address, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + }) + } +} + function showAccountDetail (address) { return (dispatch) => { dispatch(actions.showLoadingIndication()) @@ -637,6 +992,7 @@ function showAccountDetail (address) { type: actions.SHOW_ACCOUNT_DETAIL, value: address, }) + dispatch(actions.setSelectedToken()) }) } } @@ -654,10 +1010,11 @@ function showAccountsPage () { } } -function showConfTxPage (transForward = true) { +function showConfTxPage ({transForward = true, id}) { return { type: actions.SHOW_CONF_TX_PAGE, - transForward: transForward, + transForward, + id, } } @@ -680,6 +1037,13 @@ function previousTx () { } } +function editTx (txId) { + return { + type: actions.EDIT_TX, + value: txId, + } +} + function showConfigPage (transitionForward = true) { return { type: actions.SHOW_CONFIG_PAGE, @@ -697,18 +1061,64 @@ function showAddTokenPage (transitionForward = true) { function addToken (address, symbol, decimals) { return (dispatch) => { dispatch(actions.showLoadingIndication()) - background.addToken(address, symbol, decimals, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - setTimeout(() => { - dispatch(actions.goHome()) - }, 250) + return new Promise((resolve, reject) => { + background.addToken(address, symbol, decimals, (err, tokens) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + dispatch(actions.updateTokens(tokens)) + resolve(tokens) + }) }) } } +function removeToken (address) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.removeToken(address, (err, tokens) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + dispatch(actions.updateTokens(tokens)) + resolve(tokens) + }) + }) + } +} + +function addTokens (tokens) { + return dispatch => { + if (Array.isArray(tokens)) { + dispatch(actions.setSelectedToken(getTokenAddressFromTokenObject(tokens[0]))) + return Promise.all(tokens.map(({ address, symbol, decimals }) => ( + dispatch(addToken(address, symbol, decimals)) + ))) + } else { + dispatch(actions.setSelectedToken(getTokenAddressFromTokenObject(tokens))) + return Promise.all( + Object + .entries(tokens) + .map(([_, { address, symbol, decimals }]) => ( + dispatch(addToken(address, symbol, decimals)) + )) + ) + } + } +} + +function updateTokens (newTokens) { + return { + type: actions.UPDATE_TOKENS, + newTokens, + } +} + function goBackToInitView () { return { type: actions.BACK_TO_INIT_MENU, @@ -785,11 +1195,17 @@ function setProviderType (type) { log.error(err) return dispatch(self.displayWarning('Had a problem changing networks!')) } + dispatch(actions.updateProviderType(type)) + dispatch(actions.setSelectedToken()) }) - return { - type: actions.SET_PROVIDER_TYPE, - value: type, - } + + } +} + +function updateProviderType (type) { + return { + type: actions.SET_PROVIDER_TYPE, + value: type, } } @@ -806,7 +1222,7 @@ function setRpcTarget (newRpc) { } // Calls the addressBookController to add a new address. -function addToAddressBook (recipient, nickname) { +function addToAddressBook (recipient, nickname = '') { log.debug(`background.addToAddressBook`) return (dispatch) => { background.setAddressBook(recipient, nickname, (err, result) => { @@ -818,6 +1234,54 @@ function addToAddressBook (recipient, nickname) { } } +function useEtherscanProvider () { + log.debug(`background.useEtherscanProvider`) + background.useEtherscanProvider() + return { + type: actions.USE_ETHERSCAN_PROVIDER, + } +} + +function showNetworkDropdown () { + return { + type: actions.NETWORK_DROPDOWN_OPEN, + } +} + +function hideNetworkDropdown () { + return { + type: actions.NETWORK_DROPDOWN_CLOSE, + } +} + + +function showModal (payload) { + return { + type: actions.MODAL_OPEN, + payload, + } +} + +function hideModal (payload) { + return { + type: actions.MODAL_CLOSE, + payload, + } +} + +function showSidebar () { + return { + type: actions.SIDEBAR_OPEN, + } +} + +function hideSidebar () { + return { + type: actions.SIDEBAR_CLOSE, + } +} + + function showLoadingIndication (message) { return { type: actions.SHOW_LOADING, @@ -869,27 +1333,40 @@ function exportAccount (password, address) { dispatch(self.showLoadingIndication()) log.debug(`background.submitPassword`) - background.submitPassword(password, function (err) { - if (err) { - log.error('Error in submiting password.') - dispatch(self.hideLoadingIndication()) - return dispatch(self.displayWarning('Incorrect Password.')) - } - log.debug(`background.exportAccount`) - background.exportAccount(address, function (err, result) { - dispatch(self.hideLoadingIndication()) - + return new Promise((resolve, reject) => { + background.submitPassword(password, function (err) { if (err) { - log.error(err) - return dispatch(self.displayWarning('Had a problem exporting the account.')) + log.error('Error in submiting password.') + dispatch(self.hideLoadingIndication()) + dispatch(self.displayWarning('Incorrect Password.')) + return reject(err) } + log.debug(`background.exportAccount`) + return background.exportAccount(address, function (err, result) { + dispatch(self.hideLoadingIndication()) + + if (err) { + log.error(err) + dispatch(self.displayWarning('Had a problem exporting the account.')) + return reject(err) + } + + // dispatch(self.exportAccountComplete()) + dispatch(self.showPrivateKey(result)) - dispatch(self.showPrivateKey(result)) + return resolve(result) + }) }) }) } } +function exportAccountComplete () { + return { + type: actions.EXPORT_ACCOUNT, + } +} + function showPrivateKey (key) { return { type: actions.SHOW_PRIVATE_KEY, @@ -901,14 +1378,22 @@ function saveAccountLabel (account, label) { return (dispatch) => { dispatch(actions.showLoadingIndication()) log.debug(`background.saveAccountLabel`) - background.saveAccountLabel(account, label, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - dispatch({ - type: actions.SAVE_ACCOUNT_LABEL, - value: { account, label }, + + return new Promise((resolve, reject) => { + background.saveAccountLabel(account, label, (err) => { + dispatch(actions.hideLoadingIndication()) + + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + + dispatch({ + type: actions.SAVE_ACCOUNT_LABEL, + value: { account, label }, + }) + + resolve(account) }) }) } @@ -920,6 +1405,12 @@ function showSendPage () { } } +function showSendTokenPage () { + return { + type: actions.SHOW_SEND_TOKEN_PAGE, + } +} + function buyEth (opts) { return (dispatch) => { const url = getBuyEthUrl(opts) @@ -968,7 +1459,6 @@ function pairUpdate (coin) { function shapeShiftSubview (network) { var pair = 'btc_eth' - return (dispatch) => { dispatch(actions.showSubLoadingIndication()) shapeShiftRequest('marketinfo', {pair}, (mktResponse) => { @@ -994,7 +1484,7 @@ function coinShiftRquest (data, marketData) { dispatch(actions.hideLoadingIndication()) if (response.error) return dispatch(actions.displayWarning(response.error)) var message = ` - Deposit your ${response.depositType} to the address bellow:` + Deposit your ${response.depositType} to the address below:` log.debug(`background.createShapeShiftTx`) background.createShapeShiftTx(response.deposit, response.depositType) dispatch(actions.showQrView(response.deposit, [message].concat(marketData))) @@ -1030,13 +1520,17 @@ function reshowQrCode (data, coin) { if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) var message = [ - `Deposit your ${coin} to the address bellow:`, + `Deposit your ${coin} to the address below:`, `Deposit Limit: ${mktResponse.limit}`, `Deposit Minimum:${mktResponse.minimum}`, ] dispatch(actions.hideLoadingIndication()) return dispatch(actions.showQrView(data, message)) + // return dispatch(actions.showModal({ + // name: 'SHAPESHIFT_DEPOSIT_TX', + // Qr: { data, message }, + // })) }) } } @@ -1070,6 +1564,53 @@ function shapeShiftRequest (query, options, cb) { } } +function updateTokenExchangeRate (token = '') { + const pair = `${token.toLowerCase()}_eth` + + return dispatch => { + if (!token) { + return + } + + shapeShiftRequest('marketinfo', { pair }, marketinfo => { + if (!marketinfo.error) { + dispatch({ + type: actions.UPDATE_TOKEN_EXCHANGE_RATE, + payload: { + pair, + marketinfo, + }, + }) + } + }) + } +} + +function setFeatureFlag (feature, activated, notificationType) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.setFeatureFlag(feature, activated, (err, updatedFeatureFlags) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + dispatch(actions.updateFeatureFlags(updatedFeatureFlags)) + notificationType && dispatch(actions.showModal({ name: notificationType })) + resolve(updatedFeatureFlags) + }) + }) + } +} + +function updateFeatureFlags (updatedFeatureFlags) { + return { + type: actions.UPDATE_FEATURE_FLAGS, + value: updatedFeatureFlags, + } +} + // Call Background Then Update // // A function generator for a common pattern wherein: @@ -1111,3 +1652,50 @@ function forceUpdateMetamaskState (dispatch) { dispatch(actions.updateMetamaskState(newState)) }) } + +function toggleAccountMenu () { + return { + type: actions.TOGGLE_ACCOUNT_MENU, + } +} + +function setUseBlockie (val) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.setUseBlockie`) + background.setUseBlockie(val, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + }) + dispatch({ + type: actions.SET_USE_BLOCKIE, + value: val, + }) + } +} + +function setNetworkEndpoints (networkEndpointType) { + return dispatch => { + log.debug('background.setNetworkEndpoints') + return new Promise((resolve, reject) => { + background.setNetworkEndpoints(networkEndpointType, err => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.updateNetworkEndpointType(networkEndpointType)) + resolve(networkEndpointType) + }) + }) + } +} + +function updateNetworkEndpointType (networkEndpointType) { + return { + type: actions.UPDATE_NETWORK_ENDPOINT_TYPE, + value: networkEndpointType, + } +} diff --git a/ui/app/add-token.js b/ui/app/add-token.js index 9354a4cad..3a806d34b 100644 --- a/ui/app/add-token.js +++ b/ui/app/add-token.js @@ -1,238 +1,362 @@ const inherits = require('util').inherits const Component = require('react').Component +const classnames = require('classnames') const h = require('react-hyperscript') const connect = require('react-redux').connect +const R = require('ramda') +const Fuse = require('fuse.js') +const contractMap = require('eth-contract-metadata') +const TokenBalance = require('./components/token-balance') +const Identicon = require('./components/identicon') +const contractList = Object.entries(contractMap) + .map(([ _, tokenData]) => tokenData) + .filter(tokenData => Boolean(tokenData.erc20)) +const fuse = new Fuse(contractList, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'symbol', weight: 0.5 }, + ], +}) const actions = require('./actions') -const Tooltip = require('./components/tooltip.js') - - const ethUtil = require('ethereumjs-util') -const abi = require('human-standard-token-abi') -const Eth = require('ethjs-query') -const EthContract = require('ethjs-contract') +const { tokenInfoGetter } = require('./token-util') const emptyAddr = '0x0000000000000000000000000000000000000000' -module.exports = connect(mapStateToProps)(AddTokenScreen) +module.exports = connect(mapStateToProps, mapDispatchToProps)(AddTokenScreen) function mapStateToProps (state) { + const { identities, tokens } = state.metamask return { - identities: state.metamask.identities, + identities, + tokens, + } +} + +function mapDispatchToProps (dispatch) { + return { + goHome: () => dispatch(actions.goHome()), + addTokens: tokens => dispatch(actions.addTokens(tokens)), } } inherits(AddTokenScreen, Component) function AddTokenScreen () { this.state = { - warning: null, - address: null, - symbol: 'TOKEN', - decimals: 18, + isShowingConfirmation: false, + customAddress: '', + customSymbol: '', + customDecimals: 0, + searchQuery: '', + isCollapsed: true, + selectedTokens: {}, + errors: {}, } + this.tokenAddressDidChange = this.tokenAddressDidChange.bind(this) + this.onNext = this.onNext.bind(this) Component.call(this) } -AddTokenScreen.prototype.render = function () { - const state = this.state - const props = this.props - const { warning, symbol, decimals } = state - - return ( - h('.flex-column.flex-grow', [ - - // subtitle and nav - h('.section-title.flex-row.flex-center', [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - onClick: (event) => { - props.dispatch(actions.goHome()) - }, - }), - h('h2.page-subtitle', 'Add Token'), - ]), - - h('.error', { - style: { - display: warning ? 'block' : 'none', - padding: '0 20px', - textAlign: 'center', - }, - }, warning), - - // conf view - h('.flex-column.flex-justify-center.flex-grow.select-none', [ - h('.flex-space-around', { - style: { - padding: '20px', - }, - }, [ - - h('div', [ - h(Tooltip, { - position: 'top', - title: 'The contract of the actual token contract. Click for more info.', - }, [ - h('a', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - href: 'https://support.metamask.io/kb/article/24-what-is-a-token-contract-address', - target: '_blank', - }, [ - h('span', 'Token Contract Address '), - h('i.fa.fa-question-circle'), - ]), - ]), - ]), - - h('section.flex-row.flex-center', [ - h('input#token-address', { - name: 'address', - placeholder: 'Token Contract Address', - onChange: this.tokenAddressDidChange.bind(this), - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - }), - ]), - - h('div', [ - h('span', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - }, 'Token Symbol'), - ]), - - h('div', { style: {display: 'flex'} }, [ - h('input#token_symbol', { - placeholder: `Like "ETH"`, - value: symbol, - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - onChange: (event) => { - var element = event.target - var symbol = element.value - this.setState({ symbol }) - }, - }), - ]), - - h('div', [ - h('span', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - }, 'Decimals of Precision'), - ]), - - h('div', { style: {display: 'flex'} }, [ - h('input#token_decimals', { - value: decimals, - type: 'number', - min: 0, - max: 36, - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - onChange: (event) => { - var element = event.target - var decimals = element.value.trim() - this.setState({ decimals }) - }, - }), - ]), - - h('button', { - style: { - alignSelf: 'center', - }, - onClick: (event) => { - const valid = this.validateInputs() - if (!valid) return +AddTokenScreen.prototype.componentWillMount = function () { + this.tokenInfoGetter = tokenInfoGetter() +} - const { address, symbol, decimals } = this.state - this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals)) - }, - }, 'Add'), - ]), - ]), - ]) - ) +AddTokenScreen.prototype.toggleToken = function (address, token) { + const { selectedTokens, errors } = this.state + const { [address]: selectedToken } = selectedTokens + this.setState({ + selectedTokens: { + ...selectedTokens, + [address]: selectedToken ? null : token, + }, + errors: { + ...errors, + tokenSelector: null, + }, + }) } -AddTokenScreen.prototype.componentWillMount = function () { - if (typeof global.ethereumProvider === 'undefined') return +AddTokenScreen.prototype.onNext = function () { + const { isValid, errors } = this.validate() - this.eth = new Eth(global.ethereumProvider) - this.contract = new EthContract(this.eth) - this.TokenContract = this.contract(abi) + return !isValid + ? this.setState({ errors }) + : this.setState({ isShowingConfirmation: true }) } -AddTokenScreen.prototype.tokenAddressDidChange = function (event) { - const el = event.target - const address = el.value.trim() - if (ethUtil.isValidAddress(address) && address !== emptyAddr) { - this.setState({ address }) - this.attemptToAutoFillTokenParams(address) +AddTokenScreen.prototype.tokenAddressDidChange = function (e) { + const customAddress = e.target.value.trim() + this.setState({ customAddress }) + if (ethUtil.isValidAddress(customAddress) && customAddress !== emptyAddr) { + this.attemptToAutoFillTokenParams(customAddress) + } else { + this.setState({ + customSymbol: '', + customDecimals: 0, + }) } } -AddTokenScreen.prototype.validateInputs = function () { - let msg = '' - const state = this.state - const identitiesList = Object.keys(this.props.identities) - const { address, symbol, decimals } = state - const standardAddress = ethUtil.addHexPrefix(address).toLowerCase() - - const validAddress = ethUtil.isValidAddress(address) - if (!validAddress) { - msg += 'Address is invalid. ' +AddTokenScreen.prototype.checkExistingAddresses = function (address) { + if (!address) return false + const tokensList = this.props.tokens + const matchesAddress = existingToken => { + return existingToken.address.toLowerCase() === address.toLowerCase() } - const validDecimals = decimals >= 0 && decimals < 36 - if (!validDecimals) { - msg += 'Decimals must be at least 0, and not over 36. ' - } + return R.any(matchesAddress)(tokensList) +} - const symbolLen = symbol.trim().length - const validSymbol = symbolLen > 0 && symbolLen < 10 - if (!validSymbol) { - msg += 'Symbol must be between 0 and 10 characters.' +AddTokenScreen.prototype.validate = function () { + const errors = {} + const identitiesList = Object.keys(this.props.identities) + const { customAddress, customSymbol, customDecimals, selectedTokens } = this.state + const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() + + if (customAddress) { + const validAddress = ethUtil.isValidAddress(customAddress) + if (!validAddress) { + errors.customAddress = 'Address is invalid. ' + } + + const validDecimals = customDecimals >= 0 && customDecimals < 36 + if (!validDecimals) { + errors.customDecimals = 'Decimals must be at least 0, and not over 36.' + } + + const symbolLen = customSymbol.trim().length + const validSymbol = symbolLen > 0 && symbolLen < 10 + if (!validSymbol) { + errors.customSymbol = 'Symbol must be between 0 and 10 characters.' + } + + const ownAddress = identitiesList.includes(standardAddress) + if (ownAddress) { + errors.customAddress = 'Personal address detected. Input the token contract address.' + } + + const tokenAlreadyAdded = this.checkExistingAddresses(customAddress) + if (tokenAlreadyAdded) { + errors.customAddress = 'Token has already been added.' + } + } else if ( + Object.entries(selectedTokens) + .reduce((isEmpty, [ symbol, isSelected ]) => ( + isEmpty && !isSelected + ), true) + ) { + errors.tokenSelector = 'Must select at least 1 token.' } - const ownAddress = identitiesList.includes(standardAddress) - if (ownAddress) { - msg = 'Personal address detected. Input the token contract address.' + return { + isValid: !Object.keys(errors).length, + errors, } +} - const isValid = validAddress && validDecimals && !ownAddress - - if (!isValid) { +AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { + const { symbol, decimals } = await this.tokenInfoGetter(address) + if (symbol && decimals) { this.setState({ - warning: msg, + customSymbol: symbol, + customDecimals: decimals.toString(), }) - } else { - this.setState({ warning: null }) } +} + +AddTokenScreen.prototype.renderCustomForm = function () { + const { customAddress, customSymbol, customDecimals, errors } = this.state - return isValid + return !this.state.isCollapsed && ( + h('div.add-token__add-custom-form', [ + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customAddress, + }), + }, [ + h('div.add-token__add-custom-label', 'Token Address'), + h('input.add-token__add-custom-input', { + type: 'text', + onChange: this.tokenAddressDidChange, + value: customAddress, + }), + h('div.add-token__add-custom-error-message', errors.customAddress), + ]), + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customSymbol, + }), + }, [ + h('div.add-token__add-custom-label', 'Token Symbol'), + h('input.add-token__add-custom-input', { + type: 'text', + value: customSymbol, + disabled: true, + }), + h('div.add-token__add-custom-error-message', errors.customSymbol), + ]), + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customDecimals, + }), + }, [ + h('div.add-token__add-custom-label', 'Decimals of Precision'), + h('input.add-token__add-custom-input', { + type: 'number', + value: customDecimals, + disabled: true, + }), + h('div.add-token__add-custom-error-message', errors.customDecimals), + ]), + ]) + ) } -AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { - const contract = this.TokenContract.at(address) +AddTokenScreen.prototype.renderTokenList = function () { + const { searchQuery = '', selectedTokens } = this.state + const fuseSearchResult = fuse.search(searchQuery) + const addressSearchResult = contractList.filter(token => { + return token.address.toLowerCase() === searchQuery.toLowerCase() + }) + const results = [...addressSearchResult, ...fuseSearchResult] + + return Array(6).fill(undefined) + .map((_, i) => { + const { logo, symbol, name, address } = results[i] || {} + const tokenAlreadyAdded = this.checkExistingAddresses(address) + return Boolean(logo || symbol || name) && ( + h('div.add-token__token-wrapper', { + className: classnames({ + 'add-token__token-wrapper--selected': selectedTokens[address], + 'add-token__token-wrapper--disabled': tokenAlreadyAdded, + }), + onClick: () => !tokenAlreadyAdded && this.toggleToken(address, results[i]), + }, [ + h('div.add-token__token-icon', { + style: { + backgroundImage: `url(images/contract/${logo})`, + }, + }), + h('div.add-token__token-data', [ + h('div.add-token__token-symbol', symbol), + h('div.add-token__token-name', name), + ]), + // tokenAlreadyAdded && ( + // h('div.add-token__token-message', 'Already added') + // ), + ]) + ) + }) +} - const results = await Promise.all([ - contract.symbol(), - contract.decimals(), - ]) +AddTokenScreen.prototype.renderConfirmation = function () { + const { + customAddress: address, + customSymbol: symbol, + customDecimals: decimals, + selectedTokens, + } = this.state - const [ symbol, decimals ] = results - if (symbol && decimals) { - console.log('SETTING SYMBOL AND DECIMALS', { symbol, decimals }) - this.setState({ symbol: symbol[0], decimals: decimals[0].toString() }) + const { addTokens, goHome } = this.props + + const customToken = { + address, + symbol, + decimals, } + + const tokens = address && symbol && decimals + ? { ...selectedTokens, [address]: customToken } + : selectedTokens + + return ( + h('div.add-token', [ + h('div.add-token__wrapper', [ + h('div.add-token__title-container.add-token__confirmation-title', [ + h('div.add-token__title', 'Add Token'), + h('div.add-token__description', 'Would you like to add these tokens?'), + ]), + h('div.add-token__content-container.add-token__confirmation-content', [ + h('div.add-token__description.add-token__confirmation-description', 'Your balances'), + h('div.add-token__confirmation-token-list', + Object.entries(tokens) + .map(([ address, token ]) => ( + h('span.add-token__confirmation-token-list-item', [ + h(Identicon, { + className: 'add-token__confirmation-token-icon', + diameter: 75, + address, + }), + h(TokenBalance, { token }), + ]) + )) + ), + ]), + ]), + h('div.add-token__buttons', [ + h('button.btn-cancel.add-token__button', { + onClick: () => this.setState({ isShowingConfirmation: false }), + }, 'Back'), + h('button.btn-clear.add-token__button', { + onClick: () => addTokens(tokens).then(goHome), + }, 'Add Tokens'), + ]), + ]) + ) +} + +AddTokenScreen.prototype.render = function () { + const { isCollapsed, errors, isShowingConfirmation } = this.state + const { goHome } = this.props + + return isShowingConfirmation + ? this.renderConfirmation() + : ( + h('div.add-token', [ + h('div.add-token__wrapper', [ + h('div.add-token__title-container', [ + h('div.add-token__title', 'Add Token'), + h('div.add-token__description', 'Keep track of the tokens you’ve bought with your MetaMask account. If you bought tokens using a different account, those tokens will not appear here.'), + h('div.add-token__description', 'Search for tokens or select from our list of popular tokens.'), + ]), + h('div.add-token__content-container', [ + h('div.add-token__input-container', [ + h('input.add-token__input', { + type: 'text', + placeholder: 'Search', + onChange: e => this.setState({ searchQuery: e.target.value }), + }), + h('div.add-token__search-input-error-message', errors.tokenSelector), + ]), + h( + 'div.add-token__token-icons-container', + this.renderTokenList(), + ), + ]), + h('div.add-token__footers', [ + h('div.add-token__add-custom', { + onClick: () => this.setState({ isCollapsed: !isCollapsed }), + }, [ + 'Add custom token', + h(`i.fa.fa-angle-${isCollapsed ? 'down' : 'up'}`), + ]), + this.renderCustomForm(), + ]), + ]), + h('div.add-token__buttons', [ + h('button.btn-cancel.add-token__button', { + onClick: goHome, + }, 'Cancel'), + h('button.btn-clear.add-token__button', { + onClick: this.onNext, + }, 'Next'), + ]), + ]) + ) } diff --git a/ui/app/app.js b/ui/app/app.js index f0dfef34f..df9eab03c 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -3,39 +3,46 @@ const Component = require('react').Component const connect = require('react-redux').connect const h = require('react-hyperscript') const actions = require('./actions') +const classnames = require('classnames') + // mascara const MascaraFirstTime = require('../../mascara/src/app/first-time').default const MascaraBuyEtherScreen = require('../../mascara/src/app/first-time/buy-ether-screen').default // init const InitializeMenuScreen = require('./first-time/init-menu') const NewKeyChainScreen = require('./new-keychain') -// unlock -const UnlockScreen = require('./unlock') // accounts -const AccountDetailScreen = require('./account-detail') -const SendTransactionScreen = require('./send') +const MainContainer = require('./main-container') +const SendTransactionScreen2 = require('./components/send/send-v2-container') const ConfirmTxScreen = require('./conf-tx') // notice const NoticeScreen = require('./components/notice') const generateLostAccountsNotice = require('../lib/lost-accounts-notice') + +// slideout menu +const WalletView = require('./components/wallet-view') + // other views -const ConfigScreen = require('./config') +const Settings = require('./settings') const AddTokenScreen = require('./add-token') const Import = require('./accounts/import') -const InfoScreen = require('./info') +const NewAccount = require('./accounts/new-account') const Loading = require('./components/loading') -const SandwichExpando = require('sandwich-expando') -const Dropdown = require('./components/dropdown').Dropdown -const DropdownMenuItem = require('./components/dropdown').DropdownMenuItem const NetworkIndicator = require('./components/network') +const Identicon = require('./components/identicon') const BuyView = require('./components/buy-button-subview') -const QrView = require('./components/qr-code') const HDCreateVaultComplete = require('./keychains/hd/create-vault-complete') const HDRestoreVaultScreen = require('./keychains/hd/restore-vault') const RevealSeedConfirmation = require('./keychains/hd/recover-seed/confirmation') -const AccountDropdowns = require('./components/account-dropdowns').AccountDropdowns +const ReactCSSTransitionGroup = require('react-addons-css-transition-group') +const NetworkDropdown = require('./components/dropdowns/network-dropdown') +const AccountMenu = require('./components/account-menu') +const QrView = require('./components/qr-code') + +// Global Modals +const Modal = require('./components/modals/index').Modal -module.exports = connect(mapStateToProps)(App) +module.exports = connect(mapStateToProps, mapDispatchToProps)(App) inherits(App, Component) function App () { Component.call(this) } @@ -54,11 +61,14 @@ function mapStateToProps (state) { return { // state from plugin + networkDropdownOpen: state.appState.networkDropdownOpen, + sidebarOpen: state.appState.sidebarOpen, isLoading: state.appState.isLoading, loadingMessage: state.appState.loadingMessage, noActiveNotices: state.metamask.noActiveNotices, isInitialized: state.metamask.isInitialized, isUnlocked: state.metamask.isUnlocked, + selectedAddress: state.metamask.selectedAddress, currentView: state.appState.currentView, activeAddress: state.appState.activeAddress, transForward: state.appState.transForward, @@ -74,6 +84,7 @@ function mapStateToProps (state) { lastUnreadNotice: state.metamask.lastUnreadNotice, lostAccounts: state.metamask.lostAccounts, frequentRpcList: state.metamask.frequentRpcList || [], + currentCurrency: state.metamask.currentCurrency, // state needed to get account dropdown temporarily rendering from app bar identities, @@ -82,52 +93,140 @@ function mapStateToProps (state) { } } +function mapDispatchToProps (dispatch, ownProps) { + return { + dispatch, + hideSidebar: () => dispatch(actions.hideSidebar()), + showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), + hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), + setCurrentCurrencyToUSD: () => dispatch(actions.setCurrentCurrency('usd')), + toggleAccountMenu: () => dispatch(actions.toggleAccountMenu()), + } +} + +App.prototype.componentWillMount = function () { + if (!this.props.currentCurrency) { + this.props.setCurrentCurrencyToUSD() + } +} + App.prototype.render = function () { var props = this.props - const { isLoading, loadingMessage, transForward, network } = props + const { isLoading, loadingMessage, network } = props const isLoadingNetwork = network === 'loading' && props.currentView.name !== 'config' const loadMessage = loadingMessage || isLoadingNetwork ? `Connecting to ${this.getNetworkName()}` : null log.debug('Main ui render function') return ( - h('.flex-column.full-height', { style: { - // Windows was showing a vertical scroll bar: - overflow: 'hidden', + overflowX: 'hidden', position: 'relative', alignItems: 'center', }, }, [ + // global modal + h(Modal, {}, []), + // app bar this.renderAppBar(), - this.renderNetworkDropdown(), - this.renderDropdown(), - this.renderLoadingIndicator({ isLoading, isLoadingNetwork, loadMessage }), + // sidebar + this.renderSidebar(), - // panel content - h('.app-primary' + (transForward ? '.from-right' : '.from-left'), { - style: { - width: '100%', - }, - }, [ - this.renderPrimary(), - ]), + // network dropdown + h(NetworkDropdown, { + provider: this.props.provider, + frequentRpcList: this.props.frequentRpcList, + }, []), + + h(AccountMenu), + + (isLoading || isLoadingNetwork) && h(Loading, { + loadingMessage: loadMessage, + }), + + // this.renderLoadingIndicator({ isLoading, isLoadingNetwork, loadMessage }), + + // content + this.renderPrimary(), ]) ) } +App.prototype.renderGlobalModal = function () { + return h(Modal, { + ref: 'modalRef', + }, [ + // h(BuyOptions, {}, []), + ]) +} + +App.prototype.renderSidebar = function () { + + return h('div', { + }, [ + h('style', ` + .sidebar-enter { + transition: transform 300ms ease-in-out; + transform: translateX(-100%); + } + .sidebar-enter.sidebar-enter-active { + transition: transform 300ms ease-in-out; + transform: translateX(0%); + } + .sidebar-leave { + transition: transform 200ms ease-out; + transform: translateX(0%); + } + .sidebar-leave.sidebar-leave-active { + transition: transform 200ms ease-out; + transform: translateX(-100%); + } + `), + + h(ReactCSSTransitionGroup, { + transitionName: 'sidebar', + transitionEnterTimeout: 300, + transitionLeaveTimeout: 200, + }, [ + // A second instance of Walletview is used for non-mobile viewports + this.props.sidebarOpen ? h(WalletView, { + responsiveDisplayClassname: '.sidebar', + style: {}, + }) : undefined, + + ]), + + // overlay + // TODO: add onClick for overlay to close sidebar + this.props.sidebarOpen ? h('div.sidebar-overlay', { + style: {}, + onClick: () => { + this.props.hideSidebar() + }, + }, []) : undefined, + ]) +} + App.prototype.renderAppBar = function () { + const { + isUnlocked, + network, + provider, + networkDropdownOpen, + showNetworkDropdown, + hideNetworkDropdown, + currentView, + } = this.props + if (window.METAMASK_UI_TYPE === 'notification') { return null } const props = this.props - const state = this.state || {} - const isNetworkMenuOpen = state.isNetworkMenuOpen || false const {isMascara, isOnboarding} = props // Do not render header if user is in mascara onboarding @@ -143,267 +242,66 @@ App.prototype.renderAppBar = function () { return ( h('.full-width', { - height: '38px', + style: {}, }, [ h('.app-header.flex-row.flex-space-between', { - style: { - alignItems: 'center', - visibility: props.isUnlocked ? 'visible' : 'none', - background: props.isUnlocked ? 'white' : 'none', - height: '38px', - position: 'relative', - zIndex: 12, - }, + className: classnames({ + 'app-header--initialized': !isOnboarding, + }), }, [ - - h('div.left-menu-section', { - style: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }, - }, [ - - // mini logo - h('img', { - height: 24, - width: 24, - src: '/images/icon-128.png', - }), - - h(NetworkIndicator, { - network: this.props.network, - provider: this.props.provider, - onClick: (event) => { - event.preventDefault() - event.stopPropagation() - this.setState({ isNetworkMenuOpen: !isNetworkMenuOpen }) - }, - }), - ]), - - props.isUnlocked && h('div', { - style: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }, - }, [ - - props.isUnlocked && h(AccountDropdowns, { - style: {}, - enableAccountsSelector: true, - identities: this.props.identities, - selected: this.props.currentView.context, - network: this.props.network, - keyrings: this.props.keyrings, - }, []), - - // hamburger - props.isUnlocked && h(SandwichExpando, { - className: 'sandwich-expando', - width: 16, - barHeight: 2, - padding: 0, - isOpen: state.isMainMenuOpen, - color: 'rgb(247,146,30)', + h('div.app-header-contents', {}, [ + h('div.left-menu-wrapper', { onClick: () => { - this.setState({ - isMainMenuOpen: !state.isMainMenuOpen, - }) + props.dispatch(actions.backToAccountDetail(props.activeAddress)) }, - }), + }, [ + // mini logo + h('img.metafox-icon', { + height: 42, + width: 42, + src: '/images/metamask-fox.svg', + }), + + // metamask name + h('h1', 'MetaMask'), + + ]), + + h('div.header__right-actions', [ + h('div.network-component-wrapper', { + style: {}, + }, [ + // Network Indicator + h(NetworkIndicator, { + network, + provider, + disabled: currentView.name === 'confTx', + onClick: (event) => { + event.preventDefault() + event.stopPropagation() + return networkDropdownOpen === false + ? showNetworkDropdown() + : hideNetworkDropdown() + }, + }), + + ]), + + isUnlocked && h('div.account-menu__icon', { onClick: this.props.toggleAccountMenu }, [ + h(Identicon, { + address: this.props.selectedAddress, + diameter: 32, + }), + ]), + ]), ]), ]), + ]) ) } -App.prototype.renderNetworkDropdown = function () { - const props = this.props - const { provider: { type: providerType, rpcTarget: activeNetwork } } = props - const rpcList = props.frequentRpcList - const state = this.state || {} - const isOpen = state.isNetworkMenuOpen - - return h(Dropdown, { - useCssTransition: true, - isOpen, - onClickOutside: (event) => { - const { classList } = event.target - const isNotToggleElement = [ - classList.contains('menu-icon'), - classList.contains('network-name'), - classList.contains('network-indicator'), - ].filter(bool => bool).length === 0 - // classes from three constituent nodes of the toggle element - - if (isNotToggleElement) { - this.setState({ isNetworkMenuOpen: false }) - } - }, - zIndex: 11, - style: { - position: 'absolute', - left: '2px', - top: '36px', - }, - innerStyle: { - padding: '2px 16px 2px 0px', - }, - }, [ - - h( - DropdownMenuItem, - { - key: 'main', - closeMenu: () => this.setState({ isNetworkMenuOpen: !isOpen }), - onClick: () => props.dispatch(actions.setProviderType('mainnet')), - style: { - fontSize: '18px', - }, - }, - [ - h('.menu-icon.diamond'), - 'Main Ethereum Network', - providerType === 'mainnet' ? h('.check', '✓') : null, - ] - ), - - h( - DropdownMenuItem, - { - key: 'ropsten', - closeMenu: () => this.setState({ isNetworkMenuOpen: !isOpen }), - onClick: () => props.dispatch(actions.setProviderType('ropsten')), - style: { - fontSize: '18px', - }, - }, - [ - h('.menu-icon.red-dot'), - 'Ropsten Test Network', - providerType === 'ropsten' ? h('.check', '✓') : null, - ] - ), - - h( - DropdownMenuItem, - { - key: 'kovan', - closeMenu: () => this.setState({ isNetworkMenuOpen: !isOpen }), - onClick: () => props.dispatch(actions.setProviderType('kovan')), - style: { - fontSize: '18px', - }, - }, - [ - h('.menu-icon.hollow-diamond'), - 'Kovan Test Network', - providerType === 'kovan' ? h('.check', '✓') : null, - ] - ), - - h( - DropdownMenuItem, - { - key: 'rinkeby', - closeMenu: () => this.setState({ isNetworkMenuOpen: !isOpen }), - onClick: () => props.dispatch(actions.setProviderType('rinkeby')), - style: { - fontSize: '18px', - }, - }, - [ - h('.menu-icon.golden-square'), - 'Rinkeby Test Network', - providerType === 'rinkeby' ? h('.check', '✓') : null, - ] - ), - - h( - DropdownMenuItem, - { - key: 'default', - closeMenu: () => this.setState({ isNetworkMenuOpen: !isOpen }), - onClick: () => props.dispatch(actions.setProviderType('localhost')), - style: { - fontSize: '18px', - }, - }, - [ - h('i.fa.fa-question-circle.fa-lg.menu-icon'), - 'Localhost 8545', - activeNetwork === 'http://localhost:8545' ? h('.check', '✓') : null, - ] - ), - - this.renderCustomOption(props.provider), - this.renderCommonRpc(rpcList, props.provider), - - h( - DropdownMenuItem, - { - closeMenu: () => this.setState({ isNetworkMenuOpen: !isOpen }), - onClick: () => this.props.dispatch(actions.showConfigPage()), - style: { - fontSize: '18px', - }, - }, - [ - h('i.fa.fa-question-circle.fa-lg.menu-icon'), - 'Custom RPC', - activeNetwork === 'custom' ? h('.check', '✓') : null, - ] - ), - - ]) -} - -App.prototype.renderDropdown = function () { - const state = this.state || {} - const isOpen = state.isMainMenuOpen - - return h(Dropdown, { - useCssTransition: true, - isOpen: isOpen, - zIndex: 11, - onClickOutside: (event) => { - const classList = event.target.classList - const parentClassList = event.target.parentElement.classList - - const isToggleElement = classList.contains('sandwich-expando') || - parentClassList.contains('sandwich-expando') - - if (isOpen && !isToggleElement) { - this.setState({ isMainMenuOpen: false }) - } - }, - style: { - position: 'absolute', - right: '2px', - top: '38px', - }, - innerStyle: {}, - }, [ - h(DropdownMenuItem, { - closeMenu: () => this.setState({ isMainMenuOpen: !isOpen }), - onClick: () => { this.props.dispatch(actions.showConfigPage()) }, - }, 'Settings'), - - h(DropdownMenuItem, { - closeMenu: () => this.setState({ isMainMenuOpen: !isOpen }), - onClick: () => { this.props.dispatch(actions.lockMetamask()) }, - }, 'Log Out'), - - h(DropdownMenuItem, { - closeMenu: () => this.setState({ isMainMenuOpen: !isOpen }), - onClick: () => { this.props.dispatch(actions.showInfoPage()) }, - }, 'Info/Help'), - ]) -} - App.prototype.renderLoadingIndicator = function ({ isLoading, isLoadingNetwork, loadMessage }) { const { isMascara } = this.props @@ -478,20 +376,10 @@ App.prototype.renderPrimary = function () { // show unlock screen if (!props.isUnlocked) { - switch (props.currentView.name) { - - case 'restoreVault': - log.debug('rendering restore vault screen') - return h(HDRestoreVaultScreen, {key: 'HDRestoreVaultScreen'}) - - case 'config': - log.debug('rendering config screen from unlock screen.') - return h(ConfigScreen, {key: 'config'}) - - default: - log.debug('rendering locked screen') - return h(UnlockScreen, {key: 'locked'}) - } + return h(MainContainer, { + currentViewName: props.currentView.name, + isUnlocked: props.isUnlocked, + }) } // show seed words screen @@ -504,12 +392,28 @@ App.prototype.renderPrimary = function () { switch (props.currentView.name) { case 'accountDetail': - log.debug('rendering account detail screen') - return h(AccountDetailScreen, {key: 'account-detail'}) + log.debug('rendering main container') + return h(MainContainer, {key: 'account-detail'}) case 'sendTransaction': log.debug('rendering send tx screen') - return h(SendTransactionScreen, {key: 'send-transaction'}) + + // Going to leave this here until we are ready to delete SendTransactionScreen v1 + // const SendComponentToRender = checkFeatureToggle('send-v2') + // ? SendTransactionScreen2 + // : SendTransactionScreen + + return h(SendTransactionScreen2, {key: 'send-transaction'}) + + case 'sendToken': + log.debug('rendering send token screen') + + // Going to leave this here until we are ready to delete SendTransactionScreen v1 + // const SendTokenComponentToRender = checkFeatureToggle('send-v2') + // ? SendTransactionScreen2 + // : SendTokenScreen + + return h(SendTransactionScreen2, {key: 'sendToken'}) case 'newKeychain': log.debug('rendering new keychain screen') @@ -525,19 +429,23 @@ App.prototype.renderPrimary = function () { case 'config': log.debug('rendering config screen') - return h(ConfigScreen, {key: 'config'}) + return h(Settings, {key: 'config'}) case 'import-menu': log.debug('rendering import screen') return h(Import, {key: 'import-menu'}) + case 'new-account-page': + log.debug('rendering new account screen') + return h(NewAccount, {key: 'new-account'}) + case 'reveal-seed-conf': log.debug('rendering reveal seed confirmation screen') return h(RevealSeedConfirmation, {key: 'reveal-seed-conf'}) case 'info': log.debug('rendering info screen') - return h(InfoScreen, {key: 'info'}) + return h(Settings, {key: 'info', tab: 'info'}) case 'buyEth': log.debug('rendering buy ether screen') @@ -577,7 +485,7 @@ App.prototype.renderPrimary = function () { default: log.debug('rendering default, account detail screen') - return h(AccountDetailScreen, {key: 'account-detail'}) + return h(MainContainer, {key: 'account-detail'}) } } @@ -593,40 +501,6 @@ App.prototype.toggleMetamaskActive = function () { } } -App.prototype.renderCustomOption = function (provider) { - const { rpcTarget, type } = provider - const props = this.props - - if (type !== 'rpc') return null - - // Concatenate long URLs - let label = rpcTarget - if (rpcTarget.length > 31) { - label = label.substr(0, 34) + '...' - } - - switch (rpcTarget) { - - case 'http://localhost:8545': - return null - - default: - return h( - DropdownMenuItem, - { - key: rpcTarget, - onClick: () => props.dispatch(actions.setRpcTarget(rpcTarget)), - closeMenu: () => this.setState({ isNetworkMenuOpen: false }), - }, - [ - h('i.fa.fa-question-circle.fa-lg.menu-icon'), - label, - h('.check', '✓'), - ] - ) - } -} - App.prototype.getNetworkName = function () { const { provider } = this.props const providerName = provider.type @@ -647,28 +521,3 @@ App.prototype.getNetworkName = function () { return name } - -App.prototype.renderCommonRpc = function (rpcList, provider) { - const props = this.props - const rpcTarget = provider.rpcTarget - - return rpcList.map((rpc) => { - if ((rpc === 'http://localhost:8545') || (rpc === rpcTarget)) { - return null - } else { - return h( - DropdownMenuItem, - { - key: `common${rpc}`, - closeMenu: () => this.setState({ isNetworkMenuOpen: false }), - onClick: () => props.dispatch(actions.setRpcTarget(rpc)), - }, - [ - h('i.fa.fa-question-circle.fa-lg.menu-icon'), - rpc, - rpcTarget === rpc ? h('.check', '✓') : null, - ] - ) - } - }) -} diff --git a/ui/app/components/account-menu/index.js b/ui/app/components/account-menu/index.js new file mode 100644 index 000000000..aeb8a0b38 --- /dev/null +++ b/ui/app/components/account-menu/index.js @@ -0,0 +1,160 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const actions = require('../../actions') +const { Menu, Item, Divider, CloseArea } = require('../dropdowns/components/menu') +const Identicon = require('../identicon') +const { formatBalance } = require('../../util') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountMenu) + +inherits(AccountMenu, Component) +function AccountMenu () { Component.call(this) } + +function mapStateToProps (state) { + return { + selectedAddress: state.metamask.selectedAddress, + isAccountMenuOpen: state.metamask.isAccountMenuOpen, + keyrings: state.metamask.keyrings, + identities: state.metamask.identities, + accounts: state.metamask.accounts, + + } +} + +function mapDispatchToProps (dispatch) { + return { + toggleAccountMenu: () => dispatch(actions.toggleAccountMenu()), + showAccountDetail: address => { + dispatch(actions.showAccountDetail(address)) + dispatch(actions.hideSidebar()) + dispatch(actions.toggleAccountMenu()) + }, + lockMetamask: () => { + dispatch(actions.lockMetamask()) + dispatch(actions.hideWarning()) + dispatch(actions.hideSidebar()) + dispatch(actions.toggleAccountMenu()) + }, + showConfigPage: () => { + dispatch(actions.showConfigPage()) + dispatch(actions.hideSidebar()) + dispatch(actions.toggleAccountMenu()) + }, + showNewAccountPage: (formToSelect) => { + dispatch(actions.showNewAccountPage(formToSelect)) + dispatch(actions.hideSidebar()) + dispatch(actions.toggleAccountMenu()) + }, + showInfoPage: () => { + dispatch(actions.showInfoPage()) + dispatch(actions.hideSidebar()) + dispatch(actions.toggleAccountMenu()) + }, + } +} + +AccountMenu.prototype.render = function () { + const { + isAccountMenuOpen, + toggleAccountMenu, + showNewAccountPage, + lockMetamask, + showConfigPage, + showInfoPage, + } = this.props + + return h(Menu, { className: 'account-menu', isShowing: isAccountMenuOpen }, [ + h(CloseArea, { onClick: toggleAccountMenu }), + h(Item, { + className: 'account-menu__header', + }, [ + 'My Accounts', + h('button.account-menu__logout-button', { + onClick: lockMetamask, + }, 'Log out'), + ]), + h(Divider), + h('div.account-menu__accounts', this.renderAccounts()), + h(Divider), + h(Item, { + onClick: () => showNewAccountPage('CREATE'), + icon: h('img', { src: 'images/plus-btn-white.svg' }), + text: 'Create Account', + }), + h(Item, { + onClick: () => showNewAccountPage('IMPORT'), + icon: h('img', { src: 'images/import-account.svg' }), + text: 'Import Account', + }), + h(Divider), + h(Item, { + onClick: showInfoPage, + icon: h('img', { src: 'images/mm-info-icon.svg' }), + text: 'Info & Help', + }), + h(Item, { + onClick: showConfigPage, + icon: h('img', { src: 'images/settings.svg' }), + text: 'Settings', + }), + ]) +} + +AccountMenu.prototype.renderAccounts = function () { + const { + identities, + accounts, + selectedAddress, + keyrings, + showAccountDetail, + } = this.props + + return Object.keys(identities).map((key, index) => { + const identity = identities[key] + const isSelected = identity.address === selectedAddress + + const balanceValue = accounts[key] ? accounts[key].balance : '' + const formattedBalance = balanceValue ? formatBalance(balanceValue, 6) : '...' + const simpleAddress = identity.address.substring(2).toLowerCase() + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(simpleAddress) || + kr.accounts.includes(identity.address) + }) + + return h( + 'div.account-menu__account.menu__item--clickable', + { onClick: () => showAccountDetail(identity.address) }, + [ + h('div.account-menu__check-mark', [ + isSelected ? h('div.account-menu__check-mark-icon') : null, + ]), + + h( + Identicon, + { + address: identity.address, + diameter: 24, + }, + ), + + h('div.account-menu__account-info', [ + h('div.account-menu__name', identity.name || ''), + h('div.account-menu__balance', formattedBalance), + ]), + + this.indicateIfLoose(keyring), + ], + ) + }) +} + +AccountMenu.prototype.indicateIfLoose = function (keyring) { + try { // Sometimes keyrings aren't loaded yet: + const type = keyring.type + const isLoose = type !== 'HD Key Tree' + return isLoose ? h('.keyring-label', 'IMPORTED') : null + } catch (e) { return } +} diff --git a/ui/app/components/balance-component.js b/ui/app/components/balance-component.js new file mode 100644 index 000000000..d591ab455 --- /dev/null +++ b/ui/app/components/balance-component.js @@ -0,0 +1,121 @@ +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const inherits = require('util').inherits +const TokenBalance = require('./token-balance') +const Identicon = require('./identicon') + +const { formatBalance, generateBalanceObject } = require('../util') + +module.exports = connect(mapStateToProps)(BalanceComponent) + +function mapStateToProps (state) { + const accounts = state.metamask.accounts + const network = state.metamask.network + const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] + const account = accounts[selectedAddress] + + return { + account, + network, + conversionRate: state.metamask.conversionRate, + currentCurrency: state.metamask.currentCurrency, + } +} + +inherits(BalanceComponent, Component) +function BalanceComponent () { + Component.call(this) +} + +BalanceComponent.prototype.render = function () { + const props = this.props + const { token, network } = props + + return h('div.balance-container', {}, [ + + // TODO: balance icon needs to be passed in + // h('img.balance-icon', { + // src: '../images/eth_logo.svg', + // style: {}, + // }), + h(Identicon, { + diameter: 50, + address: token && token.address, + network, + }), + + token ? this.renderTokenBalance() : this.renderBalance(), + ]) +} + +BalanceComponent.prototype.renderTokenBalance = function () { + const { token } = this.props + + return h('div.flex-column.balance-display', [ + h('div.token-amount', [ h(TokenBalance, { token }) ]), + ]) +} + +BalanceComponent.prototype.renderBalance = function () { + const props = this.props + const { shorten, account } = props + const balanceValue = account && account.balance + const needsParse = 'needsParse' in props ? props.needsParse : true + const formattedBalance = balanceValue ? formatBalance(balanceValue, 6, needsParse) : '...' + const showFiat = 'showFiat' in props ? props.showFiat : true + + if (formattedBalance === 'None' || formattedBalance === '...') { + return h('div.flex-column.balance-display', {}, [ + h('div.token-amount', { + style: {}, + }, formattedBalance), + ]) + } + + return h('div.flex-column.balance-display', {}, [ + h('div.token-amount', { + style: {}, + }, this.getTokenBalance(formattedBalance, shorten)), + + showFiat ? this.renderFiatValue(formattedBalance) : null, + ]) +} + +BalanceComponent.prototype.renderFiatValue = function (formattedBalance) { + + const { conversionRate, currentCurrency } = this.props + + const fiatDisplayNumber = this.getFiatDisplayNumber(formattedBalance, conversionRate) + + const fiatPrefix = currentCurrency === 'USD' ? '$' : '' + + return this.renderFiatAmount(fiatDisplayNumber, currentCurrency, fiatPrefix) +} + +BalanceComponent.prototype.renderFiatAmount = function (fiatDisplayNumber, fiatSuffix, fiatPrefix) { + const shouldNotRenderFiat = fiatDisplayNumber === 'N/A' || Number(fiatDisplayNumber) === 0 + if (shouldNotRenderFiat) return null + + return h('div.fiat-amount', { + style: {}, + }, `${fiatPrefix}${fiatDisplayNumber} ${fiatSuffix}`) +} + +BalanceComponent.prototype.getTokenBalance = function (formattedBalance, shorten) { + const balanceObj = generateBalanceObject(formattedBalance, shorten ? 1 : 3) + + const balanceValue = shorten ? balanceObj.shortBalance : balanceObj.balance + const label = balanceObj.label + + return `${balanceValue} ${label}` +} + +BalanceComponent.prototype.getFiatDisplayNumber = function (formattedBalance, conversionRate) { + if (formattedBalance === 'None') return formattedBalance + if (conversionRate === 0) return 'N/A' + + const splitBalance = formattedBalance.split(' ') + + return (Number(splitBalance[0]) * conversionRate).toFixed(2) +} diff --git a/ui/app/components/buy-button-subview.js b/ui/app/components/buy-button-subview.js index 15281171c..d5958787b 100644 --- a/ui/app/components/buy-button-subview.js +++ b/ui/app/components/buy-button-subview.js @@ -76,7 +76,7 @@ BuyButtonSubview.prototype.headerSubview = function () { paddingTop: '4px', paddingBottom: '4px', }, - }, 'Buy Eth'), + }, 'Deposit Eth'), ]), // loading indication @@ -87,7 +87,7 @@ BuyButtonSubview.prototype.headerSubview = function () { left: '49vw', }, }, [ - h(Loading, { isLoading }), + isLoading && h(Loading), ]), // account panel @@ -245,7 +245,7 @@ BuyButtonSubview.prototype.navigateTo = function (url) { BuyButtonSubview.prototype.backButtonContext = function () { if (this.props.context === 'confTx') { - this.props.dispatch(actions.showConfTxPage(false)) + this.props.dispatch(actions.showConfTxPage({transForward: false})) } else { this.props.dispatch(actions.goHome()) } diff --git a/ui/app/components/currency-input.js b/ui/app/components/currency-input.js new file mode 100644 index 000000000..6f7862e51 --- /dev/null +++ b/ui/app/components/currency-input.js @@ -0,0 +1,103 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits + +module.exports = CurrencyInput + +inherits(CurrencyInput, Component) +function CurrencyInput (props) { + Component.call(this) + + this.state = { + value: sanitizeValue(props.value), + } +} + +function removeNonDigits (str) { + return str.match(/\d|$/g).join('') +} + +// Removes characters that are not digits, then removes leading zeros +function sanitizeInteger (val) { + return String(parseInt(removeNonDigits(val) || '0', 10)) +} + +function sanitizeDecimal (val) { + return removeNonDigits(val) +} + +// Take a single string param and returns a non-negative integer or float as a string. +// Breaks the input into three parts: the integer, the decimal point, and the decimal/fractional part. +// Removes leading zeros from the integer, and non-digits from the integer and decimal +// The integer is returned as '0' in cases where it would be empty. A decimal point is +// included in the returned string if one is included in the param +// Examples: +// sanitizeValue('0') -> '0' +// sanitizeValue('a') -> '0' +// sanitizeValue('010.') -> '10.' +// sanitizeValue('0.005') -> '0.005' +// sanitizeValue('22.200') -> '22.200' +// sanitizeValue('.200') -> '0.200' +// sanitizeValue('a.b.1.c,89.123') -> '0.189123' +function sanitizeValue (value) { + let [ , integer, point, decimal] = (/([^.]*)([.]?)([^.]*)/).exec(value) + + integer = sanitizeInteger(integer) || '0' + decimal = sanitizeDecimal(decimal) + + return `${integer}${point}${decimal}` +} + +CurrencyInput.prototype.handleChange = function (newValue) { + const { onInputChange } = this.props + const { value } = this.state + + let parsedValue = newValue + const newValueLastIndex = newValue.length - 1 + + if (value === '0' && newValue[newValueLastIndex] === '0') { + parsedValue = parsedValue.slice(0, newValueLastIndex) + } + + const sanitizedValue = sanitizeValue(parsedValue) + this.setState({ value: sanitizedValue }) + onInputChange(sanitizedValue) +} + +// If state.value === props.value plus a decimal point, or at least one +// zero or a decimal point and at least one zero, then this returns state.value +// after it is sanitized with getValueParts +CurrencyInput.prototype.getValueToRender = function () { + const { value } = this.props + const { value: stateValue } = this.state + + const trailingStateString = (new RegExp(`^${value}(.+)`)).exec(stateValue) + const trailingDecimalAndZeroes = trailingStateString && (/^[.0]0*/).test(trailingStateString[1]) + + return sanitizeValue(trailingDecimalAndZeroes + ? stateValue + : value) +} + +CurrencyInput.prototype.render = function () { + const { + className, + placeholder, + readOnly, + inputRef, + } = this.props + + const inputSizeMultiplier = readOnly ? 1 : 1.2 + + const valueToRender = this.getValueToRender() + + return h('input', { + className, + value: valueToRender, + placeholder, + size: valueToRender.length * inputSizeMultiplier, + readOnly, + onChange: e => this.handleChange(e.target.value), + ref: inputRef, + }) +} diff --git a/ui/app/components/customize-gas-modal/gas-modal-card.js b/ui/app/components/customize-gas-modal/gas-modal-card.js new file mode 100644 index 000000000..23754d819 --- /dev/null +++ b/ui/app/components/customize-gas-modal/gas-modal-card.js @@ -0,0 +1,54 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const InputNumber = require('../input-number.js') +// const GasSlider = require('./gas-slider.js') + +module.exports = GasModalCard + +inherits(GasModalCard, Component) +function GasModalCard () { + Component.call(this) +} + +GasModalCard.prototype.render = function () { + const { + // memo, + onChange, + unitLabel, + value, + min, + // max, + step, + title, + copy, + } = this.props + + return h('div.send-v2__gas-modal-card', [ + + h('div.send-v2__gas-modal-card__title', {}, title), + + h('div.send-v2__gas-modal-card__copy', {}, copy), + + h(InputNumber, { + unitLabel, + step, + // max, + min, + placeholder: '0', + value, + onChange, + }), + + // h(GasSlider, { + // value, + // step, + // max, + // min, + // onChange, + // }), + + ]) + +} + diff --git a/ui/app/components/customize-gas-modal/gas-slider.js b/ui/app/components/customize-gas-modal/gas-slider.js new file mode 100644 index 000000000..69fd6f985 --- /dev/null +++ b/ui/app/components/customize-gas-modal/gas-slider.js @@ -0,0 +1,50 @@ +// const Component = require('react').Component +// const h = require('react-hyperscript') +// const inherits = require('util').inherits + +// module.exports = GasSlider + +// inherits(GasSlider, Component) +// function GasSlider () { +// Component.call(this) +// } + +// GasSlider.prototype.render = function () { +// const { +// memo, +// identities, +// onChange, +// unitLabel, +// value, +// id, +// step, +// max, +// min, +// } = this.props + +// return h('div.gas-slider', [ + +// h('input.gas-slider__input', { +// type: 'range', +// step, +// max, +// min, +// value, +// id: 'gasSlider', +// onChange: event => onChange(event.target.value), +// }, []), + +// h('div.gas-slider__bar', [ + +// h('div.gas-slider__low'), + +// h('div.gas-slider__mid'), + +// h('div.gas-slider__high'), + +// ]), + +// ]) + +// } + diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js new file mode 100644 index 000000000..826d2cd4b --- /dev/null +++ b/ui/app/components/customize-gas-modal/index.js @@ -0,0 +1,298 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const GasModalCard = require('./gas-modal-card') + +const ethUtil = require('ethereumjs-util') + +const { + MIN_GAS_PRICE_DEC, + MIN_GAS_LIMIT_DEC, + MIN_GAS_PRICE_GWEI, +} = require('../send/send-constants') + +const { + isBalanceSufficient, +} = require('../send/send-utils') + +const { + conversionUtil, + multiplyCurrencies, + conversionGreaterThan, + subtractCurrencies, +} = require('../../conversion-util') + +const { + getGasPrice, + getGasLimit, + conversionRateSelector, + getSendAmount, + getSelectedToken, + getSendFrom, + getCurrentAccountWithSendEtherInfo, + getSelectedTokenToFiatRate, + getSendMaxModeState, +} = require('../../selectors') + +function mapStateToProps (state) { + const selectedToken = getSelectedToken(state) + const currentAccount = getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state) + const conversionRate = conversionRateSelector(state) + + return { + gasPrice: getGasPrice(state), + gasLimit: getGasLimit(state), + conversionRate, + amount: getSendAmount(state), + maxModeOn: getSendMaxModeState(state), + balance: currentAccount.balance, + primaryCurrency: selectedToken && selectedToken.symbol, + selectedToken, + amountConversionRate: selectedToken ? getSelectedTokenToFiatRate(state) : conversionRate, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => dispatch(actions.hideModal()), + updateGasPrice: newGasPrice => dispatch(actions.updateGasPrice(newGasPrice)), + updateGasLimit: newGasLimit => dispatch(actions.updateGasLimit(newGasLimit)), + updateGasTotal: newGasTotal => dispatch(actions.updateGasTotal(newGasTotal)), + updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)), + } +} + +function getOriginalState (props) { + const gasPrice = props.gasPrice || MIN_GAS_PRICE_DEC + const gasLimit = props.gasLimit || MIN_GAS_LIMIT_DEC + + const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) + + return { + gasPrice, + gasLimit, + gasTotal, + error: null, + priceSigZeros: '', + priceSigDec: '', + } +} + +inherits(CustomizeGasModal, Component) +function CustomizeGasModal (props) { + Component.call(this) + + this.state = getOriginalState(props) +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal) + +CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { + const { + updateGasPrice, + updateGasLimit, + hideModal, + updateGasTotal, + maxModeOn, + selectedToken, + balance, + updateSendAmount, + } = this.props + + if (maxModeOn && !selectedToken) { + const maxAmount = subtractCurrencies( + ethUtil.addHexPrefix(balance), + ethUtil.addHexPrefix(gasTotal), + { toNumericBase: 'hex' } + ) + updateSendAmount(maxAmount) + } + + updateGasPrice(gasPrice) + updateGasLimit(gasLimit) + updateGasTotal(gasTotal) + hideModal() +} + +CustomizeGasModal.prototype.revert = function () { + this.setState(getOriginalState(this.props)) +} + +CustomizeGasModal.prototype.validate = function ({ gasTotal, gasLimit }) { + const { + amount, + balance, + selectedToken, + amountConversionRate, + conversionRate, + maxModeOn, + } = this.props + + let error = null + + const balanceIsSufficient = isBalanceSufficient({ + amount: selectedToken || maxModeOn ? '0' : amount, + gasTotal, + balance, + selectedToken, + amountConversionRate, + conversionRate, + }) + + if (!balanceIsSufficient) { + error = 'Insufficient balance for current gas total' + } + + const gasLimitTooLow = gasLimit && conversionGreaterThan( + { + value: MIN_GAS_LIMIT_DEC, + fromNumericBase: 'dec', + conversionRate, + }, + { + value: gasLimit, + fromNumericBase: 'hex', + }, + ) + + if (gasLimitTooLow) { + error = 'Gas limit must be at least 21000' + } + + this.setState({ error }) + return error +} + +CustomizeGasModal.prototype.convertAndSetGasLimit = function (newGasLimit) { + const { gasPrice } = this.state + + const gasLimit = conversionUtil(newGasLimit, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) + + const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) + + this.validate({ gasTotal, gasLimit }) + + this.setState({ gasTotal, gasLimit }) +} + +CustomizeGasModal.prototype.convertAndSetGasPrice = function (newGasPrice) { + const { gasLimit } = this.state + const sigZeros = String(newGasPrice).match(/^\d+[.]\d*?(0+)$/) + const sigDec = String(newGasPrice).match(/^\d+([.])0*$/) + + this.setState({ + priceSigZeros: sigZeros && sigZeros[1] || '', + priceSigDec: sigDec && sigDec[1] || '', + }) + + const gasPrice = conversionUtil(newGasPrice, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + }) + + const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) + + this.validate({ gasTotal }) + + this.setState({ gasTotal, gasPrice }) +} + +CustomizeGasModal.prototype.render = function () { + const { hideModal } = this.props + const { gasPrice, gasLimit, gasTotal, error, priceSigZeros, priceSigDec } = this.state + + let convertedGasPrice = conversionUtil(gasPrice, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + toDenomination: 'GWEI', + }) + + convertedGasPrice += convertedGasPrice.match(/[.]/) ? priceSigZeros : `${priceSigDec}${priceSigZeros}` + + const convertedGasLimit = conversionUtil(gasLimit, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + + return h('div.send-v2__customize-gas', {}, [ + h('div.send-v2__customize-gas__content', { + }, [ + h('div.send-v2__customize-gas__header', {}, [ + + h('div.send-v2__customize-gas__title', 'Customize Gas'), + + h('div.send-v2__customize-gas__close', { + onClick: hideModal, + }), + + ]), + + h('div.send-v2__customize-gas__body', {}, [ + + h(GasModalCard, { + value: convertedGasPrice, + min: MIN_GAS_PRICE_GWEI, + // max: 1000, + step: multiplyCurrencies(MIN_GAS_PRICE_GWEI, 10), + onChange: value => this.convertAndSetGasPrice(value), + title: 'Gas Price (GWEI)', + copy: 'We calculate the suggested gas prices based on network success rates.', + }), + + h(GasModalCard, { + value: convertedGasLimit, + min: 1, + // max: 100000, + step: 1, + onChange: value => this.convertAndSetGasLimit(value), + title: 'Gas Limit', + copy: 'We calculate the suggested gas limit based on network success rates.', + }), + + ]), + + h('div.send-v2__customize-gas__footer', {}, [ + + error && h('div.send-v2__customize-gas__error-message', [ + error, + ]), + + h('div.send-v2__customize-gas__revert', { + onClick: () => this.revert(), + }, ['Revert']), + + h('div.send-v2__customize-gas__buttons', [ + h('div.send-v2__customize-gas__cancel', { + onClick: this.props.hideModal, + }, ['CANCEL']), + + h(`div.send-v2__customize-gas__save${error ? '__error' : ''}`, { + onClick: () => !error && this.save(gasPrice, gasLimit, gasTotal), + }, ['SAVE']), + ]), + + ]), + + ]), + ]) +} diff --git a/ui/app/components/dropdowns/account-dropdown-mini.js b/ui/app/components/dropdowns/account-dropdown-mini.js new file mode 100644 index 000000000..a3d41af90 --- /dev/null +++ b/ui/app/components/dropdowns/account-dropdown-mini.js @@ -0,0 +1,75 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const AccountListItem = require('../send/account-list-item') + +module.exports = AccountDropdownMini + +inherits(AccountDropdownMini, Component) +function AccountDropdownMini () { + Component.call(this) +} + +AccountDropdownMini.prototype.getListItemIcon = function (currentAccount, selectedAccount) { + const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } }) + + return currentAccount.address === selectedAccount.address + ? listItemIcon + : null +} + +AccountDropdownMini.prototype.renderDropdown = function () { + const { + accounts, + selectedAccount, + closeDropdown, + onSelect, + } = this.props + + return h('div', {}, [ + + h('div.account-dropdown-mini__close-area', { + onClick: closeDropdown, + }), + + h('div.account-dropdown-mini__list', {}, [ + + ...accounts.map(account => h(AccountListItem, { + account, + displayBalance: false, + displayAddress: false, + handleClick: () => { + onSelect(account) + closeDropdown() + }, + icon: this.getListItemIcon(account, selectedAccount), + })), + + ]), + + ]) +} + +AccountDropdownMini.prototype.render = function () { + const { + selectedAccount, + openDropdown, + dropdownOpen, + } = this.props + + return h('div.account-dropdown-mini', {}, [ + + h(AccountListItem, { + account: selectedAccount, + handleClick: openDropdown, + displayBalance: false, + displayAddress: false, + icon: h(`i.fa.fa-caret-down.fa-lg`, { style: { color: '#dedede' } }), + }), + + dropdownOpen && this.renderDropdown(), + + ]) + +} + diff --git a/ui/app/components/dropdowns/account-options-dropdown.js b/ui/app/components/dropdowns/account-options-dropdown.js new file mode 100644 index 000000000..f74c0a2d4 --- /dev/null +++ b/ui/app/components/dropdowns/account-options-dropdown.js @@ -0,0 +1,29 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const AccountDropdowns = require('./components/account-dropdowns') + +inherits(AccountOptionsDropdown, Component) +function AccountOptionsDropdown () { + Component.call(this) +} + +module.exports = AccountOptionsDropdown + +// TODO: specify default props and proptypes +// TODO: hook up to state, connect to redux to clean up API +// TODO: selectedAddress is not defined... should we use selected? +AccountOptionsDropdown.prototype.render = function () { + const { selected, network, identities, style, dropdownWrapperStyle, menuItemStyles } = this.props + + return h(AccountDropdowns, { + enableAccountOptions: true, + enableAccountsSelector: false, + selected, + network, + identities, + style: style || {}, + dropdownWrapperStyle: dropdownWrapperStyle || {}, + menuItemStyles: menuItemStyles || {}, + }, []) +} diff --git a/ui/app/components/dropdowns/account-selection-dropdown.js b/ui/app/components/dropdowns/account-selection-dropdown.js new file mode 100644 index 000000000..2f6452b15 --- /dev/null +++ b/ui/app/components/dropdowns/account-selection-dropdown.js @@ -0,0 +1,29 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const AccountDropdowns = require('./components/account-dropdowns') + +inherits(AccountSelectionDropdown, Component) +function AccountSelectionDropdown () { + Component.call(this) +} + +module.exports = AccountSelectionDropdown + +// TODO: specify default props and proptypes +// TODO: hook up to state, connect to redux to clean up API +// TODO: selectedAddress is not defined... should we use selected? +AccountSelectionDropdown.prototype.render = function () { + const { selected, network, identities, style, dropdownWrapperStyle, menuItemStyles } = this.props + + return h(AccountDropdowns, { + enableAccountOptions: false, + enableAccountsSelector: true, + selected, + network, + identities, + style: style || {}, + dropdownWrapperStyle: dropdownWrapperStyle || {}, + menuItemStyles: menuItemStyles || {}, + }, []) +} diff --git a/ui/app/components/dropdowns/components/account-dropdowns.js b/ui/app/components/dropdowns/components/account-dropdowns.js new file mode 100644 index 000000000..f97ac0691 --- /dev/null +++ b/ui/app/components/dropdowns/components/account-dropdowns.js @@ -0,0 +1,482 @@ +const Component = require('react').Component +const PropTypes = require('react').PropTypes +const h = require('react-hyperscript') +const actions = require('../../../actions') +const genAccountLink = require('../../../../lib/account-link.js') +const connect = require('react-redux').connect +const Dropdown = require('./dropdown').Dropdown +const DropdownMenuItem = require('./dropdown').DropdownMenuItem +const Identicon = require('../../identicon') +const ethUtil = require('ethereumjs-util') +const copyToClipboard = require('copy-to-clipboard') +const { formatBalance } = require('../../../util') + +class AccountDropdowns extends Component { + constructor (props) { + super(props) + this.state = { + accountSelectorActive: false, + optionsMenuActive: false, + } + // Used for orangeaccount selector icon + // this.accountSelectorToggleClassName = 'accounts-selector' + this.accountSelectorToggleClassName = 'fa-angle-down' + this.optionsMenuToggleClassName = 'fa-ellipsis-h' + } + + renderAccounts () { + const { identities, accounts, selected, menuItemStyles, actions, keyrings } = this.props + + return Object.keys(identities).map((key, index) => { + const identity = identities[key] + const isSelected = identity.address === selected + + const balanceValue = accounts[key].balance + const formattedBalance = balanceValue ? formatBalance(balanceValue, 6) : '...' + const simpleAddress = identity.address.substring(2).toLowerCase() + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(simpleAddress) || + kr.accounts.includes(identity.address) + }) + + return h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + this.props.actions.showAccountDetail(identity.address) + }, + style: Object.assign( + { + marginTop: index === 0 ? '5px' : '', + fontSize: '24px', + width: '260px', + }, + menuItemStyles, + ), + }, + [ + h('div.flex-row.flex-center', {}, [ + + h('span', { + style: { + flex: '1 1 0', + minWidth: '20px', + minHeight: '30px', + }, + }, [ + h('span', { + style: { + flex: '1 1 auto', + fontSize: '14px', + }, + }, isSelected ? h('i.fa.fa-check') : null), + ]), + + h( + Identicon, + { + address: identity.address, + diameter: 24, + style: { + flex: '1 1 auto', + marginLeft: '10px', + }, + }, + ), + + h('span.flex-column', { + style: { + flex: '10 10 auto', + width: '175px', + alignItems: 'flex-start', + justifyContent: 'center', + marginLeft: '10px', + position: 'relative', + }, + }, [ + this.indicateIfLoose(keyring), + h('span.account-dropdown-name', { + style: { + fontSize: '18px', + maxWidth: '145px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, identity.name || ''), + + h('span.account-dropdown-balance', { + style: { + fontSize: '14px', + fontFamily: 'Avenir', + fontWeight: 500, + }, + }, formattedBalance), + ]), + + h('span', { + style: { + flex: '3 3 auto', + }, + }, [ + h('span.account-dropdown-edit-button', { + style: { + fontSize: '16px', + }, + onClick: () => { + actions.showEditAccountModal(identity) + }, + }, [ + 'Edit', + ]), + ]), + + ]), +// ======= +// }, +// ), +// this.indicateIfLoose(keyring), +// h('span', { +// style: { +// marginLeft: '20px', +// fontSize: '24px', +// maxWidth: '145px', +// whiteSpace: 'nowrap', +// overflow: 'hidden', +// textOverflow: 'ellipsis', +// }, +// }, identity.name || ''), +// h('span', { style: { marginLeft: '20px', fontSize: '24px' } }, isSelected ? h('.check', '✓') : null), +// >>>>>>> master:ui/app/components/account-dropdowns.js + ] + ) + }) + } + + indicateIfLoose (keyring) { + try { // Sometimes keyrings aren't loaded yet: + const type = keyring.type + const isLoose = type !== 'HD Key Tree' + return isLoose ? h('.keyring-label', 'LOOSE') : null + } catch (e) { return } + } + + renderAccountSelector () { + const { actions, useCssTransition, innerStyle, sidebarOpen } = this.props + const { accountSelectorActive, menuItemStyles } = this.state + + return h( + Dropdown, + { + useCssTransition, + style: { + marginLeft: '-185px', + marginTop: '50px', + minWidth: '180px', + overflowY: 'auto', + maxHeight: '300px', + width: '300px', + }, + innerStyle, + isOpen: accountSelectorActive, + onClickOutside: (event) => { + const { classList } = event.target + const isNotToggleElement = !classList.contains(this.accountSelectorToggleClassName) + if (accountSelectorActive && isNotToggleElement) { + this.setState({ accountSelectorActive: false }) + } + }, + }, + [ + ...this.renderAccounts(), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + style: Object.assign( + {}, + menuItemStyles, + ), + onClick: () => actions.showNewAccountPageCreateForm(), + }, + [ + h( + 'i.fa.fa-plus.fa-lg', + { + style: { + marginLeft: '8px', + }, + } + ), + h('span', { + style: { + marginLeft: '14px', + fontFamily: 'DIN OT', + fontSize: '16px', + lineHeight: '23px', + }, + }, 'Create Account'), + ], + ), + h( + DropdownMenuItem, + { + closeMenu: () => { + if (sidebarOpen) { + actions.hideSidebar() + } + }, + onClick: () => actions.showNewAccountPageImportForm(), + style: Object.assign( + {}, + menuItemStyles, + ), + }, + [ + h( + 'i.fa.fa-download.fa-lg', + { + style: { + marginLeft: '8px', + }, + } + ), + h('span', { + style: { + marginLeft: '20px', + marginBottom: '5px', + fontFamily: 'DIN OT', + fontSize: '16px', + lineHeight: '23px', + }, + }, 'Import Account'), + ] + ), + ] + ) + } + + renderAccountOptions () { + const { actions, dropdownWrapperStyle, useCssTransition } = this.props + const { optionsMenuActive, menuItemStyles } = this.state + const dropdownMenuItemStyle = { + fontFamily: 'DIN OT', + fontSize: 16, + lineHeight: '24px', + padding: '8px', + } + + return h( + Dropdown, + { + useCssTransition, + style: Object.assign( + { + marginLeft: '-10px', + position: 'absolute', + width: '29vh', // affects both mobile and laptop views + }, + dropdownWrapperStyle, + ), + isOpen: optionsMenuActive, + onClickOutside: () => { + const { classList } = event.target + const isNotToggleElement = !classList.contains(this.optionsMenuToggleClassName) + if (optionsMenuActive && isNotToggleElement) { + this.setState({ optionsMenuActive: false }) + } + }, + }, + [ + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + this.props.actions.showAccountDetailModal() + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + 'Account Details', + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + const { selected, network } = this.props + const url = genAccountLink(selected, network) + global.platform.openWindow({ url }) + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + 'View account on Etherscan', + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + const { selected } = this.props + const checkSumAddress = selected && ethUtil.toChecksumAddress(selected) + copyToClipboard(checkSumAddress) + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + 'Copy Address to clipboard', + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => this.props.actions.showExportPrivateKeyModal(), + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + 'Export Private Key', + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + actions.hideSidebar() + actions.showAddTokenPage() + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + 'Add Token', + ), + + ] + ) + } + + render () { + const { style, enableAccountsSelector, enableAccountOptions } = this.props + const { optionsMenuActive, accountSelectorActive } = this.state + + return h( + 'span', + { + style: style, + }, + [ + enableAccountsSelector && h( + 'i.fa.fa-angle-down', + { + style: { + cursor: 'pointer', + }, + onClick: (event) => { + event.stopPropagation() + this.setState({ + accountSelectorActive: !accountSelectorActive, + optionsMenuActive: false, + }) + }, + }, + this.renderAccountSelector(), + ), + enableAccountOptions && h( + 'i.fa.fa-ellipsis-h', + { + style: { + fontSize: '135%', + cursor: 'pointer', + }, + onClick: (event) => { + event.stopPropagation() + this.setState({ + accountSelectorActive: false, + optionsMenuActive: !optionsMenuActive, + }) + }, + }, + this.renderAccountOptions() + ), + ] + ) + } +} + +AccountDropdowns.defaultProps = { + enableAccountsSelector: false, + enableAccountOptions: false, +} + +AccountDropdowns.propTypes = { + identities: PropTypes.objectOf(PropTypes.object), + selected: PropTypes.string, + keyrings: PropTypes.array, + accounts: PropTypes.object, + menuItemStyles: PropTypes.object, + actions: PropTypes.object, + // actions.showAccountDetail: , + useCssTransition: PropTypes.bool, + innerStyle: PropTypes.object, + sidebarOpen: PropTypes.bool, + dropdownWrapperStyle: PropTypes.string, + // actions.showAccountDetailModal: , + network: PropTypes.number, + // actions.showExportPrivateKeyModal: , + style: PropTypes.object, + enableAccountsSelector: PropTypes.bool, + enableAccountOption: PropTypes.bool, + enableAccountOptions: PropTypes.bool, +} + +const mapDispatchToProps = (dispatch) => { + return { + actions: { + hideSidebar: () => dispatch(actions.hideSidebar()), + showConfigPage: () => dispatch(actions.showConfigPage()), + showAccountDetail: (address) => dispatch(actions.showAccountDetail(address)), + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + showEditAccountModal: (identity) => { + dispatch(actions.showModal({ + name: 'EDIT_ACCOUNT_NAME', + identity, + })) + }, + showNewAccountPageCreateForm: () => dispatch(actions.showNewAccountPage({ form: 'CREATE' })), + showExportPrivateKeyModal: () => { + dispatch(actions.showModal({ name: 'EXPORT_PRIVATE_KEY' })) + }, + showAddTokenPage: () => { + dispatch(actions.showAddTokenPage()) + }, + addNewAccount: () => dispatch(actions.addNewAccount()), + showNewAccountPageImportForm: () => dispatch(actions.showNewAccountPage({ form: 'IMPORT' })), + showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), + }, + } +} + +function mapStateToProps (state) { + return { + keyrings: state.metamask.keyrings, + sidebarOpen: state.appState.sidebarOpen, + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDropdowns) + diff --git a/ui/app/components/dropdown.js b/ui/app/components/dropdowns/components/dropdown.js index cdd864cc3..15d064be8 100644 --- a/ui/app/components/dropdown.js +++ b/ui/app/components/dropdowns/components/dropdown.js @@ -1,14 +1,22 @@ const Component = require('react').Component const PropTypes = require('react').PropTypes const h = require('react-hyperscript') -const MenuDroppo = require('./menu-droppo') +const MenuDroppo = require('../../menu-droppo') const extend = require('xtend') const noop = () => {} class Dropdown extends Component { render () { - const { isOpen, onClickOutside, style, innerStyle, children, useCssTransition } = this.props + const { + containerClassName, + isOpen, + onClickOutside, + style, + innerStyle, + children, + useCssTransition, + } = this.props const innerStyleDefaults = extend({ borderRadius: '4px', @@ -20,9 +28,10 @@ class Dropdown extends Component { return h( MenuDroppo, { + containerClassName, useCssTransition, isOpen, - zIndex: 11, + zIndex: 55, onClickOutside, style, innerStyle: innerStyleDefaults, @@ -31,8 +40,12 @@ class Dropdown extends Component { h( 'style', ` - li.dropdown-menu-item:hover { color:rgb(225, 225, 225); } - li.dropdown-menu-item { color: rgb(185, 185, 185); position: relative } + li.dropdown-menu-item:hover { + color:rgb(225, 225, 225); + background-color: rgba(255, 255, 255, 0.05); + border-radius: 4px; + } + li.dropdown-menu-item { color: rgb(185, 185, 185); } ` ), ...children, @@ -55,6 +68,7 @@ Dropdown.propTypes = { onClickOutside: PropTypes.func, innerStyle: PropTypes.object, useCssTransition: PropTypes.bool, + containerClassName: PropTypes.string, } class DropdownMenuItem extends Component { @@ -70,7 +84,7 @@ class DropdownMenuItem extends Component { }, style: Object.assign({ listStyle: 'none', - padding: '8px 0px 8px 0px', + padding: '8px 0px', fontSize: '18px', fontStyle: 'normal', fontFamily: 'Montserrat Regular', @@ -78,6 +92,7 @@ class DropdownMenuItem extends Component { display: 'flex', justifyContent: 'flex-start', alignItems: 'center', + color: 'white', }, style), }, children diff --git a/ui/app/components/dropdowns/components/menu.js b/ui/app/components/dropdowns/components/menu.js new file mode 100644 index 000000000..f6d8a139e --- /dev/null +++ b/ui/app/components/dropdowns/components/menu.js @@ -0,0 +1,51 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') + +inherits(Menu, Component) +function Menu () { Component.call(this) } + +Menu.prototype.render = function () { + const { className = '', children, isShowing } = this.props + return isShowing + ? h('div', { className: `menu ${className}` }, children) + : h('noscript') +} + +inherits(Item, Component) +function Item () { Component.call(this) } + +Item.prototype.render = function () { + const { + icon, + children, + text, + className = '', + onClick, + } = this.props + const itemClassName = `menu__item ${className} ${onClick ? 'menu__item--clickable' : ''}` + const iconComponent = icon ? h('div.menu__item__icon', [icon]) : null + const textComponent = text ? h('div.menu__item__text', text) : null + + return children + ? h('div', { className: itemClassName, onClick }, children) + : h('div.menu__item', { className: itemClassName, onClick }, [ iconComponent, textComponent ] + .filter(d => Boolean(d)) + ) +} + +inherits(Divider, Component) +function Divider () { Component.call(this) } + +Divider.prototype.render = function () { + return h('div.menu__divider') +} + +inherits(CloseArea, Component) +function CloseArea () { Component.call(this) } + +CloseArea.prototype.render = function () { + return h('div.menu__close-area', { onClick: this.props.onClick }) +} + +module.exports = { Menu, Item, Divider, CloseArea } diff --git a/ui/app/components/dropdowns/components/network-dropdown-icon.js b/ui/app/components/dropdowns/components/network-dropdown-icon.js new file mode 100644 index 000000000..7e94e0af5 --- /dev/null +++ b/ui/app/components/dropdowns/components/network-dropdown-icon.js @@ -0,0 +1,28 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') + + +inherits(NetworkDropdownIcon, Component) +module.exports = NetworkDropdownIcon + +function NetworkDropdownIcon () { + Component.call(this) +} + +NetworkDropdownIcon.prototype.render = function () { + const { + backgroundColor, + isSelected, + innerBorder = 'none', + } = this.props + + return h(`.menu-icon-circle${isSelected ? '--active' : ''}`, {}, + h('div', { + style: { + background: backgroundColor, + border: innerBorder, + }, + }) + ) +} diff --git a/ui/app/components/dropdowns/index.js b/ui/app/components/dropdowns/index.js new file mode 100644 index 000000000..fa66f5000 --- /dev/null +++ b/ui/app/components/dropdowns/index.js @@ -0,0 +1,17 @@ +// Reusable Dropdown Components +// TODO: Refactor into separate components +const Dropdown = require('./components/dropdown').Dropdown +const AccountDropdowns = require('./components/account-dropdowns') + +// App-Specific Instances +const AccountSelectionDropdown = require('./account-selection-dropdown') +const AccountOptionsDropdown = require('./account-options-dropdown') +const NetworkDropdown = require('./network-dropdown').default + +module.exports = { + AccountSelectionDropdown, + AccountOptionsDropdown, + NetworkDropdown, + Dropdown, + AccountDropdowns, +} diff --git a/ui/app/components/dropdowns/network-dropdown.js b/ui/app/components/dropdowns/network-dropdown.js new file mode 100644 index 000000000..dfaa6b22c --- /dev/null +++ b/ui/app/components/dropdowns/network-dropdown.js @@ -0,0 +1,322 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const Dropdown = require('./components/dropdown').Dropdown +const DropdownMenuItem = require('./components/dropdown').DropdownMenuItem +const NetworkDropdownIcon = require('./components/network-dropdown-icon') +const R = require('ramda') + +// classes from nodes of the toggle element. +const notToggleElementClassnames = [ + 'menu-icon', + 'network-name', + 'network-indicator', + 'network-caret', + 'network-component', +] + +function mapStateToProps (state) { + return { + provider: state.metamask.provider, + frequentRpcList: state.metamask.frequentRpcList || [], + networkDropdownOpen: state.appState.networkDropdownOpen, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + setProviderType: (type) => { + dispatch(actions.setProviderType(type)) + }, + setDefaultRpcTarget: type => { + dispatch(actions.setDefaultRpcTarget(type)) + }, + setRpcTarget: (target) => { + dispatch(actions.setRpcTarget(target)) + }, + showConfigPage: () => { + dispatch(actions.showConfigPage()) + }, + showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), + hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), + } +} + + +inherits(NetworkDropdown, Component) +function NetworkDropdown () { + Component.call(this) +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(NetworkDropdown) + +// TODO: specify default props and proptypes +NetworkDropdown.prototype.render = function () { + const props = this.props + const { provider: { type: providerType, rpcTarget: activeNetwork } } = props + const rpcList = props.frequentRpcList + const isOpen = this.props.networkDropdownOpen + const dropdownMenuItemStyle = { + fontFamily: 'DIN OT', + fontSize: '16px', + lineHeight: '20px', + padding: '12px 0', + } + + return h(Dropdown, { + isOpen, + onClickOutside: (event) => { + const { classList } = event.target + const isInClassList = className => classList.contains(className) + const notToggleElementIndex = R.findIndex(isInClassList)(notToggleElementClassnames) + + if (notToggleElementIndex === -1) { + this.props.hideNetworkDropdown() + } + }, + containerClassName: 'network-droppo', + zIndex: 55, + style: { + position: 'absolute', + top: '58px', + minWidth: '309px', + zIndex: '55px', + }, + innerStyle: { + padding: '18px 8px', + }, + }, [ + + h('div.network-dropdown-header', {}, [ + h('div.network-dropdown-title', {}, 'Networks'), + + h('div.network-dropdown-divider'), + + h('div.network-dropdown-content', + {}, + 'The default network for Ether transactions is Main Net.' + ), + ]), + + h( + DropdownMenuItem, + { + key: 'main', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => props.setProviderType('mainnet'), + style: { ...dropdownMenuItemStyle, borderColor: '#038789' }, + }, + [ + providerType === 'mainnet' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#038789', // $blue-lagoon + isSelected: providerType === 'mainnet', + }), + h('span.network-name-item', { + style: { + color: providerType === 'mainnet' ? '#ffffff' : '#9b9b9b', + }, + }, 'Main Ethereum Network'), + ] + ), + + h( + DropdownMenuItem, + { + key: 'ropsten', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => props.setProviderType('ropsten'), + style: dropdownMenuItemStyle, + }, + [ + providerType === 'ropsten' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#e91550', // $crimson + isSelected: providerType === 'ropsten', + }), + h('span.network-name-item', { + style: { + color: providerType === 'ropsten' ? '#ffffff' : '#9b9b9b', + }, + }, 'Ropsten Test Network'), + ] + ), + + h( + DropdownMenuItem, + { + key: 'kovan', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => props.setProviderType('kovan'), + style: dropdownMenuItemStyle, + }, + [ + providerType === 'kovan' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#690496', // $purple + isSelected: providerType === 'kovan', + }), + h('span.network-name-item', { + style: { + color: providerType === 'kovan' ? '#ffffff' : '#9b9b9b', + }, + }, 'Kovan Test Network'), + ] + ), + + h( + DropdownMenuItem, + { + key: 'rinkeby', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => props.setProviderType('rinkeby'), + style: dropdownMenuItemStyle, + }, + [ + providerType === 'rinkeby' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#ebb33f', // $tulip-tree + isSelected: providerType === 'rinkeby', + }), + h('span.network-name-item', { + style: { + color: providerType === 'rinkeby' ? '#ffffff' : '#9b9b9b', + }, + }, 'Rinkeby Test Network'), + ] + ), + + h( + DropdownMenuItem, + { + key: 'default', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => props.setRpcTarget('http://localhost:8545'), + style: dropdownMenuItemStyle, + }, + [ + activeNetwork === 'http://localhost:8545' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + isSelected: activeNetwork === 'http://localhost:8545', + innerBorder: '1px solid #9b9b9b', + }), + h('span.network-name-item', { + style: { + color: activeNetwork === 'http://localhost:8545' ? '#ffffff' : '#9b9b9b', + }, + }, 'Localhost 8545'), + ] + ), + + this.renderCustomOption(props.provider), + this.renderCommonRpc(rpcList, props.provider), + + h( + DropdownMenuItem, + { + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => this.props.showConfigPage(), + style: dropdownMenuItemStyle, + }, + [ + activeNetwork === 'custom' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + isSelected: activeNetwork === 'custom', + innerBorder: '1px solid #9b9b9b', + }), + h('span.network-name-item', { + style: { + color: activeNetwork === 'custom' ? '#ffffff' : '#9b9b9b', + }, + }, 'Custom RPC'), + ] + ), + + ]) +} + + +NetworkDropdown.prototype.getNetworkName = function () { + const { provider } = this.props + const providerName = provider.type + + let name + + if (providerName === 'mainnet') { + name = 'Main Ethereum Network' + } else if (providerName === 'ropsten') { + name = 'Ropsten Test Network' + } else if (providerName === 'kovan') { + name = 'Kovan Test Network' + } else if (providerName === 'rinkeby') { + name = 'Rinkeby Test Network' + } else { + name = 'Unknown Private Network' + } + + return name +} + +NetworkDropdown.prototype.renderCommonRpc = function (rpcList, provider) { + const props = this.props + const rpcTarget = provider.rpcTarget + + return rpcList.map((rpc) => { + if ((rpc === 'http://localhost:8545') || (rpc === rpcTarget)) { + return null + } else { + return h( + DropdownMenuItem, + { + key: `common${rpc}`, + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => props.setRpcTarget(rpc), + }, + [ + h('i.fa.fa-question-circle.fa-lg.menu-icon'), + rpc, + rpcTarget === rpc ? h('.check', '✓') : null, + ] + ) + } + }) +} + +NetworkDropdown.prototype.renderCustomOption = function (provider) { + const { rpcTarget, type } = provider + const props = this.props + + if (type !== 'rpc') return null + + // Concatenate long URLs + let label = rpcTarget + if (rpcTarget.length > 31) { + label = label.substr(0, 34) + '...' + } + + switch (rpcTarget) { + + case 'http://localhost:8545': + return null + + default: + return h( + DropdownMenuItem, + { + key: rpcTarget, + onClick: () => props.setRpcTarget(rpcTarget), + closeMenu: () => this.props.hideNetworkDropdown(), + }, + [ + h('i.fa.fa-question-circle.fa-lg.menu-icon'), + label, + h('.check', '✓'), + ] + ) + } +} diff --git a/ui/app/components/dropdowns/simple-dropdown.js b/ui/app/components/dropdowns/simple-dropdown.js new file mode 100644 index 000000000..7bb48e57b --- /dev/null +++ b/ui/app/components/dropdowns/simple-dropdown.js @@ -0,0 +1,92 @@ +const { Component } = require('react') +const PropTypes = require('react').PropTypes +const h = require('react-hyperscript') +const classnames = require('classnames') +const R = require('ramda') + +class SimpleDropdown extends Component { + constructor (props) { + super(props) + this.state = { + isOpen: false, + } + } + + getDisplayValue () { + const { selectedOption, options } = this.props + const matchesOption = option => option.value === selectedOption + const matchingOption = R.find(matchesOption)(options) + return matchingOption + ? matchingOption.displayValue || matchingOption.value + : selectedOption + } + + handleClose () { + this.setState({ isOpen: false }) + } + + toggleOpen () { + const { isOpen } = this.state + this.setState({ isOpen: !isOpen }) + } + + renderOptions () { + const { options, onSelect, selectedOption } = this.props + + return h('div', [ + h('div.simple-dropdown__close-area', { + onClick: event => { + event.stopPropagation() + this.handleClose() + }, + }), + h('div.simple-dropdown__options', [ + ...options.map(option => { + return h( + 'div.simple-dropdown__option', + { + className: classnames({ + 'simple-dropdown__option--selected': option.value === selectedOption, + }), + key: option.value, + onClick: () => { + if (option.value !== selectedOption) { + onSelect(option.value) + } + + this.handleClose() + }, + }, + option.displayValue || option.value, + ) + }), + ]), + ]) + } + + render () { + const { placeholder } = this.props + const { isOpen } = this.state + + return h( + 'div.simple-dropdown', + { + onClick: () => this.toggleOpen(), + }, + [ + h('div.simple-dropdown__selected', this.getDisplayValue() || placeholder || 'Select'), + h('i.fa.fa-caret-down.fa-lg.simple-dropdown__caret'), + isOpen && this.renderOptions(), + ] + ) + } +} + +SimpleDropdown.propTypes = { + options: PropTypes.array.isRequired, + placeholder: PropTypes.string, + onSelect: PropTypes.func, + selectedOption: PropTypes.string, +} + +module.exports = SimpleDropdown diff --git a/ui/app/components/dropdowns/token-menu-dropdown.js b/ui/app/components/dropdowns/token-menu-dropdown.js new file mode 100644 index 000000000..dc7a985e3 --- /dev/null +++ b/ui/app/components/dropdowns/token-menu-dropdown.js @@ -0,0 +1,51 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') + +module.exports = connect(null, mapDispatchToProps)(TokenMenuDropdown) + +function mapDispatchToProps (dispatch) { + return { + showHideTokenConfirmationModal: (token) => { + dispatch(actions.showModal({ name: 'HIDE_TOKEN_CONFIRMATION', token })) + }, + } +} + + +inherits(TokenMenuDropdown, Component) +function TokenMenuDropdown () { + Component.call(this) + + this.onClose = this.onClose.bind(this) +} + +TokenMenuDropdown.prototype.onClose = function (e) { + e.stopPropagation() + this.props.onClose() +} + +TokenMenuDropdown.prototype.render = function () { + const { showHideTokenConfirmationModal } = this.props + + return h('div.token-menu-dropdown', {}, [ + h('div.token-menu-dropdown__close-area', { + onClick: this.onClose, + }), + h('div.token-menu-dropdown__container', {}, [ + h('div.token-menu-dropdown__options', {}, [ + + h('div.token-menu-dropdown__option', { + onClick: (e) => { + e.stopPropagation() + showHideTokenConfirmationModal(this.props.token) + this.props.onClose() + }, + }, 'Hide Token'), + + ]), + ]), + ]) +} diff --git a/ui/app/components/editable-label.js b/ui/app/components/editable-label.js index 8a5c8954f..eb41ec50c 100644 --- a/ui/app/components/editable-label.js +++ b/ui/app/components/editable-label.js @@ -1,57 +1,88 @@ -const Component = require('react').Component +const { Component } = require('react') +const PropTypes = require('prop-types') const h = require('react-hyperscript') -const inherits = require('util').inherits -const findDOMNode = require('react-dom').findDOMNode +const classnames = require('classnames') -module.exports = EditableLabel +class EditableLabel extends Component { + constructor (props) { + super(props) -inherits(EditableLabel, Component) -function EditableLabel () { - Component.call(this) -} + this.state = { + isEditing: false, + value: props.defaultValue || '', + } + } + + handleSubmit () { + const { value } = this.state + + if (value === '') { + return + } + + Promise.resolve(this.props.onSubmit(value)) + .then(() => this.setState({ isEditing: false })) + } + + saveIfEnter (event) { + if (event.key === 'Enter') { + this.handleSubmit() + } + } -EditableLabel.prototype.render = function () { - const props = this.props - const state = this.state + renderEditing () { + const { value } = this.state - if (state && state.isEditingLabel) { - return h('div.editable-label', [ - h('input.sizing-input', { - defaultValue: props.textValue, - maxLength: '20', + return ([ + h('input.large-input.editable-label__input', { + type: 'text', + required: true, + value: this.state.value, onKeyPress: (event) => { - this.saveIfEnter(event) + if (event.key === 'Enter') { + this.handleSubmit() + } }, + onChange: event => this.setState({ value: event.target.value }), + className: classnames({ 'editable-label__input--error': value === '' }), }), - h('button.editable-button', { - onClick: () => this.saveText(), - }, 'Save'), + h('div.editable-label__icon-wrapper', [ + h('i.fa.fa-check.editable-label__icon', { + onClick: () => this.handleSubmit(), + }), + ]), ]) - } else { - return h('div.name-label', { - onClick: (event) => { - const nameAttribute = event.target.getAttribute('name') - // checks for class to handle smaller CTA above the account name - const classAttribute = event.target.getAttribute('class') - if (nameAttribute === 'edit' || classAttribute === 'edit-text') { - this.setState({ isEditingLabel: true }) - } - }, - }, this.props.children) } -} -EditableLabel.prototype.saveIfEnter = function (event) { - if (event.key === 'Enter') { - this.saveText() + renderReadonly () { + return ([ + h('div.editable-label__value', this.state.value), + h('div.editable-label__icon-wrapper', [ + h('i.fa.fa-pencil.editable-label__icon', { + onClick: () => this.setState({ isEditing: true }), + }), + ]), + ]) + } + + render () { + const { isEditing } = this.state + const { className } = this.props + + return ( + h('div.editable-label', { className: classnames(className) }, + isEditing + ? this.renderEditing() + : this.renderReadonly() + ) + ) } } -EditableLabel.prototype.saveText = function () { - // eslint-disable-next-line react/no-find-dom-node - var container = findDOMNode(this) - var text = container.querySelector('.editable-label input').value - var truncatedText = text.substring(0, 20) - this.props.saveText(truncatedText) - this.setState({ isEditingLabel: false, textLabel: truncatedText }) +EditableLabel.propTypes = { + onSubmit: PropTypes.func.isRequired, + defaultValue: PropTypes.string, + className: PropTypes.string, } + +module.exports = EditableLabel diff --git a/ui/app/components/ens-input.js b/ui/app/components/ens-input.js index c85a23514..6553053f7 100644 --- a/ui/app/components/ens-input.js +++ b/ui/app/components/ens-input.js @@ -44,7 +44,7 @@ EnsInput.prototype.render = function () { return h('div', { style: { width: '100%' }, }, [ - h('input.large-input', opts), + h('input.large-input.send-screen-input', opts), // The address book functionality. h('datalist#addresses', [ @@ -125,7 +125,7 @@ EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) { EnsInput.prototype.ensIcon = function (recipient) { const { hoverText } = this.state || {} - return h('span', { + return h('span.#ensIcon', { title: hoverText, style: { position: 'absolute', diff --git a/ui/app/components/eth-balance.js b/ui/app/components/eth-balance.js index 4f538fd31..1be8c9731 100644 --- a/ui/app/components/eth-balance.js +++ b/ui/app/components/eth-balance.js @@ -1,8 +1,10 @@ -const Component = require('react').Component +const { Component } = require('react') const h = require('react-hyperscript') -const inherits = require('util').inherits -const formatBalance = require('../util').formatBalance -const generateBalanceObject = require('../util').generateBalanceObject +const { inherits } = require('util') +const { + formatBalance, + generateBalanceObject, +} = require('../util') const Tooltip = require('./tooltip.js') const FiatValue = require('./fiat-value.js') @@ -14,11 +16,10 @@ function EthBalanceComponent () { } EthBalanceComponent.prototype.render = function () { - var props = this.props - let { value } = props - const { style, width } = props - var needsParse = this.props.needsParse !== undefined ? this.props.needsParse : true - value = value ? formatBalance(value, 6, needsParse) : '...' + const props = this.props + const { value, style, width, needsParse = true } = props + + const formattedValue = value ? formatBalance(value, 6, needsParse) : '...' return ( @@ -30,60 +31,66 @@ EthBalanceComponent.prototype.render = function () { display: 'inline', width, }, - }, this.renderBalance(value)), + }, this.renderBalance(formattedValue)), ]) ) } EthBalanceComponent.prototype.renderBalance = function (value) { - var props = this.props - const { conversionRate, shorten, incoming, currentCurrency } = props if (value === 'None') return value if (value === '...') return value - var balanceObj = generateBalanceObject(value, shorten ? 1 : 3) - var balance - var splitBalance = value.split(' ') - var ethNumber = splitBalance[0] - var ethSuffix = splitBalance[1] - const showFiat = 'showFiat' in props ? props.showFiat : true - if (shorten) { - balance = balanceObj.shortBalance - } else { - balance = balanceObj.balance - } + const { + conversionRate, + shorten, + incoming, + currentCurrency, + hideTooltip, + styleOveride, + showFiat = true, + } = this.props + const { fontSize, color, fontFamily, lineHeight } = styleOveride - var label = balanceObj.label + const { shortBalance, balance, label } = generateBalanceObject(value, shorten ? 1 : 3) + const balanceToRender = shorten ? shortBalance : balance + + const [ethNumber, ethSuffix] = value.split(' ') + const containerProps = hideTooltip ? {} : { + position: 'bottom', + title: `${ethNumber} ${ethSuffix}`, + } return ( - h(Tooltip, { - position: 'bottom', - title: `${ethNumber} ${ethSuffix}`, - }, h('div.flex-column', [ - h('.flex-row', { - style: { - alignItems: 'flex-end', - lineHeight: '13px', - fontFamily: 'Montserrat Light', - textRendering: 'geometricPrecision', - }, - }, [ - h('div', { - style: { - width: '100%', - textAlign: 'right', - }, - }, incoming ? `+${balance}` : balance), - h('div', { + h(hideTooltip ? 'div' : Tooltip, + containerProps, + h('div.flex-column', [ + h('.flex-row', { style: { - color: ' #AEAEAE', - fontSize: '12px', - marginLeft: '5px', + alignItems: 'flex-end', + lineHeight: lineHeight || '13px', + fontFamily: fontFamily || 'Montserrat Light', + textRendering: 'geometricPrecision', }, - }, label), - ]), + }, [ + h('div', { + style: { + width: '100%', + textAlign: 'right', + fontSize: fontSize || 'inherit', + color: color || 'inherit', + }, + }, incoming ? `+${balanceToRender}` : balanceToRender), + h('div', { + style: { + color: color || '#AEAEAE', + fontSize: fontSize || '12px', + marginLeft: '5px', + }, + }, label), + ]), - showFiat ? h(FiatValue, { value: props.value, conversionRate, currentCurrency }) : null, - ])) + showFiat ? h(FiatValue, { value: this.props.value, conversionRate, currentCurrency }) : null, + ]) + ) ) } diff --git a/ui/app/components/fiat-value.js b/ui/app/components/fiat-value.js index d69f41d11..56465fc9d 100644 --- a/ui/app/components/fiat-value.js +++ b/ui/app/components/fiat-value.js @@ -12,7 +12,7 @@ function FiatValue () { FiatValue.prototype.render = function () { const props = this.props - const { conversionRate, currentCurrency } = props + const { conversionRate, currentCurrency, style } = props const renderedCurrency = currentCurrency || '' const value = formatBalance(props.value, 6) @@ -29,16 +29,18 @@ FiatValue.prototype.render = function () { fiatTooltipNumber = 'Unknown' } - return fiatDisplay(fiatDisplayNumber, renderedCurrency.toUpperCase()) + return fiatDisplay(fiatDisplayNumber, renderedCurrency.toUpperCase(), style) } -function fiatDisplay (fiatDisplayNumber, fiatSuffix) { +function fiatDisplay (fiatDisplayNumber, fiatSuffix, styleOveride = {}) { + const { fontSize, color, fontFamily, lineHeight } = styleOveride + if (fiatDisplayNumber !== 'N/A') { return h('.flex-row', { style: { alignItems: 'flex-end', - lineHeight: '13px', - fontFamily: 'Montserrat Light', + lineHeight: lineHeight || '13px', + fontFamily: fontFamily || 'Montserrat Light', textRendering: 'geometricPrecision', }, }, [ @@ -46,15 +48,15 @@ function fiatDisplay (fiatDisplayNumber, fiatSuffix) { style: { width: '100%', textAlign: 'right', - fontSize: '12px', - color: '#333333', + fontSize: fontSize || '12px', + color: color || '#333333', }, }, fiatDisplayNumber), h('div', { style: { - color: '#AEAEAE', + color: color || '#AEAEAE', marginLeft: '5px', - fontSize: '12px', + fontSize: fontSize || '12px', }, }, fiatSuffix), ]) diff --git a/ui/app/components/identicon.js b/ui/app/components/identicon.js index bb476ca7b..b803b7ceb 100644 --- a/ui/app/components/identicon.js +++ b/ui/app/components/identicon.js @@ -1,13 +1,15 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits +const connect = require('react-redux').connect const isNode = require('detect-node') const findDOMNode = require('react-dom').findDOMNode const jazzicon = require('jazzicon') const iconFactoryGen = require('../../lib/icon-factory') const iconFactory = iconFactoryGen(jazzicon) +const { toDataUrl } = require('../../lib/blockies') -module.exports = IdenticonComponent +module.exports = connect(mapStateToProps)(IdenticonComponent) inherits(IdenticonComponent, Component) function IdenticonComponent () { @@ -16,59 +18,100 @@ function IdenticonComponent () { this.defaultDiameter = 46 } +function mapStateToProps (state) { + return { + useBlockie: state.metamask.useBlockie, + } +} + IdenticonComponent.prototype.render = function () { var props = this.props + const { className = '', address } = props var diameter = props.diameter || this.defaultDiameter - return ( - h('div', { - key: 'identicon-' + this.props.address, - style: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: diameter, - width: diameter, - borderRadius: diameter / 2, - overflow: 'hidden', - }, - }) - ) + + return address + ? ( + h('div', { + className: `${className} identicon`, + key: 'identicon-' + address, + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: diameter, + width: diameter, + borderRadius: diameter / 2, + overflow: 'hidden', + }, + }) + ) + : ( + h('img.balance-icon', { + src: '../images/eth_logo.svg', + style: { + height: diameter, + width: diameter, + borderRadius: diameter / 2, + }, + }) + ) } IdenticonComponent.prototype.componentDidMount = function () { var props = this.props - const { address } = props + const { address, useBlockie } = props if (!address) return - // eslint-disable-next-line react/no-find-dom-node - var container = findDOMNode(this) - - var diameter = props.diameter || this.defaultDiameter if (!isNode) { - var img = iconFactory.iconForAddress(address, diameter) - container.appendChild(img) + // eslint-disable-next-line react/no-find-dom-node + var container = findDOMNode(this) + + const diameter = props.diameter || this.defaultDiameter + + if (useBlockie) { + _generateBlockie(container, address, diameter) + } else { + _generateJazzicon(container, address, diameter) + } } } IdenticonComponent.prototype.componentDidUpdate = function () { var props = this.props - const { address } = props + const { address, useBlockie } = props if (!address) return - // eslint-disable-next-line react/no-find-dom-node - var container = findDOMNode(this) + if (!isNode) { + // eslint-disable-next-line react/no-find-dom-node + var container = findDOMNode(this) - var children = container.children - for (var i = 0; i < children.length; i++) { - container.removeChild(children[i]) - } + var children = container.children + for (var i = 0; i < children.length; i++) { + container.removeChild(children[i]) + } - var diameter = props.diameter || this.defaultDiameter - if (!isNode) { - var img = iconFactory.iconForAddress(address, diameter) - container.appendChild(img) + const diameter = props.diameter || this.defaultDiameter + + if (useBlockie) { + _generateBlockie(container, address, diameter) + } else { + _generateJazzicon(container, address, diameter) + } } } +function _generateBlockie (container, address, diameter) { + const img = new Image() + img.src = toDataUrl(address) + const dia = !diameter || diameter < 50 ? 50 : diameter + img.height = dia * 1.25 + img.width = dia * 1.25 + container.appendChild(img) +} + +function _generateJazzicon (container, address, diameter) { + const img = iconFactory.iconForAddress(address, diameter) + container.appendChild(img) +} diff --git a/ui/app/components/input-number.js b/ui/app/components/input-number.js new file mode 100644 index 000000000..fd8c5c309 --- /dev/null +++ b/ui/app/components/input-number.js @@ -0,0 +1,73 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const CurrencyInput = require('./currency-input') +const { + addCurrencies, + conversionGTE, + conversionLTE, + subtractCurrencies, +} = require('../conversion-util') + +module.exports = InputNumber + +inherits(InputNumber, Component) +function InputNumber () { + Component.call(this) + + this.setValue = this.setValue.bind(this) +} + +function isValidInput (text) { + const re = /^([1-9]\d*|0)(\.|\.\d*)?$/ + return re.test(text) +} + +InputNumber.prototype.setValue = function (newValue) { + if (newValue && !isValidInput(newValue)) return + const { fixed, min = -1, max = Infinity, onChange } = this.props + + newValue = fixed ? newValue.toFixed(4) : newValue + + const newValueGreaterThanMin = conversionGTE( + { value: newValue || '0', fromNumericBase: 'dec' }, + { value: min, fromNumericBase: 'hex' }, + ) + + const newValueLessThanMax = conversionLTE( + { value: newValue || '0', fromNumericBase: 'dec' }, + { value: max, fromNumericBase: 'hex' }, + ) + if (newValueGreaterThanMin && newValueLessThanMax) { + onChange(newValue) + } else if (!newValueGreaterThanMin) { + onChange(min) + } else if (!newValueLessThanMax) { + onChange(max) + } +} + +InputNumber.prototype.render = function () { + const { unitLabel, step = 1, placeholder, value = 0 } = this.props + + return h('div.customize-gas-input-wrapper', {}, [ + h(CurrencyInput, { + className: 'customize-gas-input', + value, + placeholder, + onInputChange: newValue => { + this.setValue(newValue) + }, + }), + h('span.gas-tooltip-input-detail', {}, [unitLabel]), + h('div.gas-tooltip-input-arrows', {}, [ + h('i.fa.fa-angle-up', { + onClick: () => this.setValue(addCurrencies(value, step)), + }), + h('i.fa.fa-angle-down', { + style: { cursor: 'pointer' }, + onClick: () => this.setValue(subtractCurrencies(value, step)), + }), + ]), + ]) +} diff --git a/ui/app/components/loading.js b/ui/app/components/loading.js index 163792584..9442121fe 100644 --- a/ui/app/components/loading.js +++ b/ui/app/components/loading.js @@ -1,45 +1,30 @@ -const inherits = require('util').inherits -const Component = require('react').Component +const { Component } = require('react') const h = require('react-hyperscript') - - -inherits(LoadingIndicator, Component) -module.exports = LoadingIndicator - -function LoadingIndicator () { - Component.call(this) +const PropTypes = require('react').PropTypes + +class LoadingIndicator extends Component { + renderMessage () { + const { loadingMessage } = this.props + return loadingMessage && h('span', loadingMessage) + } + + render () { + return ( + h('.full-flex-height.loading-overlay', {}, [ + h('img', { + src: 'images/loading.svg', + }), + + h('br'), + + this.renderMessage(), + ]) + ) + } } -LoadingIndicator.prototype.render = function () { - const { isLoading, loadingMessage } = this.props - - return ( - isLoading ? h('.full-flex-height', { - style: { - left: '0px', - zIndex: 10, - position: 'absolute', - flexDirection: 'column', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - height: '100%', - width: '100%', - background: 'rgba(255, 255, 255, 0.8)', - }, - }, [ - h('img', { - src: 'images/loading.svg', - }), - - h('br'), - - showMessageIfAny(loadingMessage), - ]) : null - ) +LoadingIndicator.propTypes = { + loadingMessage: PropTypes.string, } -function showMessageIfAny (loadingMessage) { - if (!loadingMessage) return null - return h('span', loadingMessage) -} +module.exports = LoadingIndicator diff --git a/ui/app/components/mascot.js b/ui/app/components/mascot.js index 973ec2cad..3b0d3e31b 100644 --- a/ui/app/components/mascot.js +++ b/ui/app/components/mascot.js @@ -7,13 +7,13 @@ const debounce = require('debounce') module.exports = Mascot inherits(Mascot, Component) -function Mascot () { +function Mascot ({width = '200', height = '200'}) { Component.call(this) this.logo = metamaskLogo({ followMouse: true, pxNotRatio: true, - width: 200, - height: 200, + width, + height, }) this.refollowMouse = debounce(this.logo.setFollowMouse.bind(this.logo, true), 1000) diff --git a/ui/app/components/menu-droppo.js b/ui/app/components/menu-droppo.js index e6276f3b1..c80bee2be 100644 --- a/ui/app/components/menu-droppo.js +++ b/ui/app/components/menu-droppo.js @@ -13,6 +13,7 @@ function MenuDroppoComponent () { } MenuDroppoComponent.prototype.render = function () { + const { containerClassName = '' } = this.props const speed = this.props.speed || '300ms' const useCssTransition = this.props.useCssTransition const zIndex = ('zIndex' in this.props) ? this.props.zIndex : 0 @@ -26,8 +27,9 @@ MenuDroppoComponent.prototype.render = function () { style.zIndex = zIndex return ( - h('.menu-droppo-container', { + h('div', { style, + className: `.menu-droppo-container ${containerClassName}`, }, [ h('style', ` .menu-droppo-enter { diff --git a/ui/app/components/modals/account-details-modal.js b/ui/app/components/modals/account-details-modal.js new file mode 100644 index 000000000..c1f7a3236 --- /dev/null +++ b/ui/app/components/modals/account-details-modal.js @@ -0,0 +1,75 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const AccountModalContainer = require('./account-modal-container') +const { getSelectedIdentity } = require('../../selectors') +const genAccountLink = require('../../../lib/account-link.js') +const QrView = require('../qr-code') +const EditableLabel = require('../editable-label') + +function mapStateToProps (state) { + return { + network: state.metamask.network, + selectedIdentity: getSelectedIdentity(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + // Is this supposed to be used somewhere? + showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), + showExportPrivateKeyModal: () => { + dispatch(actions.showModal({ name: 'EXPORT_PRIVATE_KEY' })) + }, + hideModal: () => dispatch(actions.hideModal()), + saveAccountLabel: (address, label) => dispatch(actions.saveAccountLabel(address, label)), + } +} + +inherits(AccountDetailsModal, Component) +function AccountDetailsModal () { + Component.call(this) +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDetailsModal) + +// Not yet pixel perfect todos: + // fonts of qr-header + +AccountDetailsModal.prototype.render = function () { + const { + selectedIdentity, + network, + showExportPrivateKeyModal, + saveAccountLabel, + } = this.props + const { name, address } = selectedIdentity + + return h(AccountModalContainer, {}, [ + h(EditableLabel, { + className: 'account-modal__name', + defaultValue: name, + onSubmit: label => saveAccountLabel(address, label), + }), + + h(QrView, { + Qr: { + data: address, + }, + }), + + h('div.account-modal-divider'), + + h('button.btn-clear.account-modal__button', { + onClick: () => global.platform.openWindow({ url: genAccountLink(address, network) }), + }, 'View account on Etherscan'), + + // Holding on redesign for Export Private Key functionality + h('button.btn-clear.account-modal__button', { + onClick: () => showExportPrivateKeyModal(), + }, 'Export private key'), + + ]) +} diff --git a/ui/app/components/modals/account-modal-container.js b/ui/app/components/modals/account-modal-container.js new file mode 100644 index 000000000..c548fb7b3 --- /dev/null +++ b/ui/app/components/modals/account-modal-container.js @@ -0,0 +1,74 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const { getSelectedIdentity } = require('../../selectors') +const Identicon = require('../identicon') + +function mapStateToProps (state) { + return { + selectedIdentity: getSelectedIdentity(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +inherits(AccountModalContainer, Component) +function AccountModalContainer () { + Component.call(this) +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountModalContainer) + +AccountModalContainer.prototype.render = function () { + const { + selectedIdentity, + showBackButton = false, + backButtonAction, + } = this.props + let { children } = this.props + + if (children.constructor !== Array) { + children = [children] + } + + return h('div', { style: { borderRadius: '4px' }}, [ + h('div.account-modal-container', [ + + h('div', [ + + // Needs a border; requires changes to svg + h(Identicon, { + address: selectedIdentity.address, + diameter: 64, + style: {}, + }), + + ]), + + showBackButton && h('div.account-modal-back', { + onClick: backButtonAction, + }, [ + + h('i.fa.fa-angle-left.fa-lg'), + + h('span.account-modal-back__text', ' Back'), + + ]), + + h('div.account-modal-close', { + onClick: this.props.hideModal, + }), + + ...children, + + ]), + ]) +} diff --git a/ui/app/components/modals/buy-options-modal.js b/ui/app/components/modals/buy-options-modal.js new file mode 100644 index 000000000..74a7a847e --- /dev/null +++ b/ui/app/components/modals/buy-options-modal.js @@ -0,0 +1,95 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const networkNames = require('../../../../app/scripts/config.js').networkNames + +function mapStateToProps (state) { + return { + network: state.metamask.network, + address: state.metamask.selectedAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + toCoinbase: (address) => { + dispatch(actions.buyEth({ network: '1', address, amount: 0 })) + }, + hideModal: () => { + dispatch(actions.hideModal()) + }, + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + toFaucet: network => dispatch(actions.buyEth({ network })), + } +} + +inherits(BuyOptions, Component) +function BuyOptions () { + Component.call(this) +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(BuyOptions) + +BuyOptions.prototype.renderModalContentOption = function (title, header, onClick) { + return h('div.buy-modal-content-option', { + onClick, + }, [ + h('div.buy-modal-content-option-title', {}, title), + h('div.buy-modal-content-option-subtitle', {}, header), + ]) +} + +BuyOptions.prototype.render = function () { + const { network, toCoinbase, address, toFaucet } = this.props + const isTestNetwork = ['3', '4', '42'].find(n => n === network) + const networkName = networkNames[network] + + return h('div', {}, [ + h('div.buy-modal-content.transfers-subview', { + }, [ + h('div.buy-modal-content-title-wrapper.flex-column.flex-center', { + style: {}, + }, [ + h('div.buy-modal-content-title', { + style: {}, + }, 'Transfers'), + h('div', {}, 'How would you like to deposit Ether?'), + ]), + + h('div.buy-modal-content-options.flex-column.flex-center', {}, [ + + isTestNetwork + ? this.renderModalContentOption(networkName, 'Test Faucet', () => toFaucet(network)) + : this.renderModalContentOption('Coinbase', 'Deposit with Fiat', () => toCoinbase(address)), + + // h('div.buy-modal-content-option', {}, [ + // h('div.buy-modal-content-option-title', {}, 'Shapeshift'), + // h('div.buy-modal-content-option-subtitle', {}, 'Trade any digital asset for any other'), + // ]),, + + this.renderModalContentOption( + 'Direct Deposit', + 'Deposit from another account', + () => this.goToAccountDetailsModal() + ), + + ]), + + h('button', { + style: { + background: 'white', + }, + onClick: () => { this.props.hideModal() }, + }, h('div.buy-modal-content-footer#buy-modal-content-footer-text', {}, 'Cancel')), + ]), + ]) +} + +BuyOptions.prototype.goToAccountDetailsModal = function () { + this.props.hideModal() + this.props.showAccountDetailModal() +} diff --git a/ui/app/components/modals/deposit-ether-modal.js b/ui/app/components/modals/deposit-ether-modal.js new file mode 100644 index 000000000..532d66653 --- /dev/null +++ b/ui/app/components/modals/deposit-ether-modal.js @@ -0,0 +1,184 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const networkNames = require('../../../../app/scripts/config.js').networkNames +const ShapeshiftForm = require('../shapeshift-form') + +const DIRECT_DEPOSIT_ROW_TITLE = 'Directly Deposit Ether' +const DIRECT_DEPOSIT_ROW_TEXT = `If you already have some Ether, the quickest way to get Ether in +your new wallet by direct deposit.` +const COINBASE_ROW_TITLE = 'Buy on Coinbase' +const COINBASE_ROW_TEXT = `Coinbase is the world’s most popular way to buy and sell bitcoin, +ethereum, and litecoin.` +const SHAPESHIFT_ROW_TITLE = 'Deposit with ShapeShift' +const SHAPESHIFT_ROW_TEXT = `If you own other cryptocurrencies, you can trade and deposit Ether +directly into your MetaMask wallet. No Account Needed.` +const FAUCET_ROW_TITLE = 'Test Faucet' +const facuetRowText = networkName => `Get Ether from a faucet for the ${networkName}` + +function mapStateToProps (state) { + return { + network: state.metamask.network, + address: state.metamask.selectedAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + toCoinbase: (address) => { + dispatch(actions.buyEth({ network: '1', address, amount: 0 })) + }, + hideModal: () => { + dispatch(actions.hideModal()) + }, + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + toFaucet: network => dispatch(actions.buyEth({ network })), + } +} + +inherits(DepositEtherModal, Component) +function DepositEtherModal () { + Component.call(this) + + this.state = { + buyingWithShapeshift: false, + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(DepositEtherModal) + +DepositEtherModal.prototype.renderRow = function ({ + logo, + title, + text, + buttonLabel, + onButtonClick, + hide, + className, + hideButton, + hideTitle, + onBackClick, + showBackButton, +}) { + if (hide) { + return null + } + + return h('div', { + className: className || 'deposit-ether-modal__buy-row', + }, [ + + onBackClick && showBackButton && h('div.deposit-ether-modal__buy-row__back', { + onClick: onBackClick, + }, [ + + h('i.fa.fa-arrow-left.cursor-pointer'), + + ]), + + h('div.deposit-ether-modal__buy-row__logo', [logo]), + + h('div.deposit-ether-modal__buy-row__description', [ + + !hideTitle && h('div.deposit-ether-modal__buy-row__description__title', [title]), + + h('div.deposit-ether-modal__buy-row__description__text', [text]), + + ]), + + !hideButton && h('div.deposit-ether-modal__buy-row__button', [ + h('button.deposit-ether-modal__deposit-button', { + onClick: onButtonClick, + }, [buttonLabel]), + ]), + + ]) +} + +DepositEtherModal.prototype.render = function () { + const { network, toCoinbase, address, toFaucet } = this.props + const { buyingWithShapeshift } = this.state + + const isTestNetwork = ['3', '4', '42'].find(n => n === network) + const networkName = networkNames[network] + + return h('div.deposit-ether-modal', {}, [ + + h('div.deposit-ether-modal__header', [ + + h('div.deposit-ether-modal__header__title', ['Deposit Ether']), + + h('div.deposit-ether-modal__header__description', [ + 'To interact with decentralized applications using MetaMask, you’ll need Ether in your wallet.', + ]), + + h('div.deposit-ether-modal__header__close', { + onClick: () => { + this.setState({ buyingWithShapeshift: false }) + this.props.hideModal() + }, + }), + + ]), + + h('div.deposit-ether-modal__buy-rows', [ + + this.renderRow({ + logo: h('img.deposit-ether-modal__buy-row__eth-logo', { src: '../../../images/eth_logo.svg' }), + title: DIRECT_DEPOSIT_ROW_TITLE, + text: DIRECT_DEPOSIT_ROW_TEXT, + buttonLabel: 'View Account', + onButtonClick: () => this.goToAccountDetailsModal(), + hide: buyingWithShapeshift, + }), + + this.renderRow({ + logo: h('i.fa.fa-tint.fa-2x'), + title: FAUCET_ROW_TITLE, + text: facuetRowText(networkName), + buttonLabel: 'Get Ether', + onButtonClick: () => toFaucet(network), + hide: !isTestNetwork || buyingWithShapeshift, + }), + + this.renderRow({ + logo: h('img.deposit-ether-modal__buy-row__coinbase-logo', { + src: '../../../images/coinbase logo.png', + }), + title: COINBASE_ROW_TITLE, + text: COINBASE_ROW_TEXT, + buttonLabel: 'Continue to Coinbase', + onButtonClick: () => toCoinbase(address), + hide: isTestNetwork || buyingWithShapeshift, + }), + + this.renderRow({ + logo: h('img.deposit-ether-modal__buy-row__shapeshift-logo', { + src: '../../../images/shapeshift logo.png', + }), + title: SHAPESHIFT_ROW_TITLE, + text: SHAPESHIFT_ROW_TEXT, + buttonLabel: 'Buy with Shapeshift', + onButtonClick: () => this.setState({ buyingWithShapeshift: true }), + hide: isTestNetwork, + hideButton: buyingWithShapeshift, + hideTitle: buyingWithShapeshift, + onBackClick: () => this.setState({ buyingWithShapeshift: false }), + showBackButton: this.state.buyingWithShapeshift, + className: buyingWithShapeshift && 'deposit-ether-modal__buy-row__shapeshift-buy', + }), + + buyingWithShapeshift && h(ShapeshiftForm), + + ]), + ]) +} + +DepositEtherModal.prototype.goToAccountDetailsModal = function () { + this.props.hideModal() + this.props.showAccountDetailModal() +} diff --git a/ui/app/components/modals/edit-account-name-modal.js b/ui/app/components/modals/edit-account-name-modal.js new file mode 100644 index 000000000..e2361140d --- /dev/null +++ b/ui/app/components/modals/edit-account-name-modal.js @@ -0,0 +1,77 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const { getSelectedAccount } = require('../../selectors') + +function mapStateToProps (state) { + return { + selectedAccount: getSelectedAccount(state), + identity: state.appState.modal.modalState.identity, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + saveAccountLabel: (account, label) => { + dispatch(actions.saveAccountLabel(account, label)) + }, + } +} + +inherits(EditAccountNameModal, Component) +function EditAccountNameModal (props) { + Component.call(this) + + this.state = { + inputText: props.identity.name, + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(EditAccountNameModal) + +EditAccountNameModal.prototype.render = function () { + const { hideModal, saveAccountLabel, identity } = this.props + + return h('div', {}, [ + h('div.flex-column.edit-account-name-modal-content', { + }, [ + + h('div.edit-account-name-modal-cancel', { + onClick: () => { + hideModal() + }, + }, [ + h('i.fa.fa-times'), + ]), + + h('div.edit-account-name-modal-title', { + }, ['Edit Account Name']), + + h('input.edit-account-name-modal-input', { + placeholder: identity.name, + onChange: (event) => { + this.setState({ inputText: event.target.value }) + }, + value: this.state.inputText, + }, []), + + h('button.btn-clear.edit-account-name-modal-save-button', { + onClick: () => { + if (this.state.inputText.length !== 0) { + saveAccountLabel(identity.address, this.state.inputText) + hideModal() + } + }, + disabled: this.state.inputText.length === 0, + }, [ + 'SAVE', + ]), + + ]), + ]) +} diff --git a/ui/app/components/modals/export-private-key-modal.js b/ui/app/components/modals/export-private-key-modal.js new file mode 100644 index 000000000..422f23f44 --- /dev/null +++ b/ui/app/components/modals/export-private-key-modal.js @@ -0,0 +1,141 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const ethUtil = require('ethereumjs-util') +const actions = require('../../actions') +const AccountModalContainer = require('./account-modal-container') +const { getSelectedIdentity } = require('../../selectors') +const ReadOnlyInput = require('../readonly-input') +const copyToClipboard = require('copy-to-clipboard') + +function mapStateToProps (state) { + return { + warning: state.appState.warning, + privateKey: state.appState.accountDetail.privateKey, + network: state.metamask.network, + selectedIdentity: getSelectedIdentity(state), + previousModalState: state.appState.modal.previousModalState.name, + } +} + +function mapDispatchToProps (dispatch) { + return { + exportAccount: (password, address) => dispatch(actions.exportAccount(password, address)), + showAccountDetailModal: () => dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })), + hideModal: () => dispatch(actions.hideModal()), + } +} + +inherits(ExportPrivateKeyModal, Component) +function ExportPrivateKeyModal () { + Component.call(this) + + this.state = { + password: '', + privateKey: null, + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ExportPrivateKeyModal) + +ExportPrivateKeyModal.prototype.exportAccountAndGetPrivateKey = function (password, address) { + const { exportAccount } = this.props + + exportAccount(password, address) + .then(privateKey => this.setState({ privateKey })) +} + +ExportPrivateKeyModal.prototype.renderPasswordLabel = function (privateKey) { + return h('span.private-key-password-label', privateKey + ? 'This is your private key (click to copy)' + : 'Type Your Password' + ) +} + +ExportPrivateKeyModal.prototype.renderPasswordInput = function (privateKey) { + const plainKey = privateKey && ethUtil.stripHexPrefix(privateKey) + + return privateKey + ? h(ReadOnlyInput, { + wrapperClass: 'private-key-password-display-wrapper', + inputClass: 'private-key-password-display-textarea', + textarea: true, + value: plainKey, + onClick: () => copyToClipboard(plainKey), + }) + : h('input.private-key-password-input', { + type: 'password', + onChange: event => this.setState({ password: event.target.value }), + }) +} + +ExportPrivateKeyModal.prototype.renderButton = function (className, onClick, label) { + return h('button', { + className, + onClick, + }, label) +} + +ExportPrivateKeyModal.prototype.renderButtons = function (privateKey, password, address, hideModal) { + return h('div.export-private-key-buttons', {}, [ + !privateKey && this.renderButton( + 'btn-cancel export-private-key__button export-private-key__button--cancel', + () => hideModal(), + 'Cancel' + ), + + (privateKey + ? this.renderButton('btn-clear export-private-key__button', () => hideModal(), 'Done') + : this.renderButton('btn-clear export-private-key__button', () => this.exportAccountAndGetPrivateKey(this.state.password, address), 'Confirm') + ), + + ]) +} + +ExportPrivateKeyModal.prototype.render = function () { + const { + selectedIdentity, + warning, + showAccountDetailModal, + hideModal, + previousModalState, + } = this.props + const { name, address } = selectedIdentity + + const { privateKey } = this.state + + return h(AccountModalContainer, { + showBackButton: previousModalState === 'ACCOUNT_DETAILS', + backButtonAction: () => showAccountDetailModal(), + }, [ + + h('span.account-name', name), + + h(ReadOnlyInput, { + wrapperClass: 'ellip-address-wrapper', + inputClass: 'qr-ellip-address ellip-address', + value: address, + }), + + h('div.account-modal-divider'), + + h('span.modal-body-title', 'Show Private Keys'), + + h('div.private-key-password', {}, [ + this.renderPasswordLabel(privateKey), + + this.renderPasswordInput(privateKey), + + !warning ? null : h('span.private-key-password-error', warning), + ]), + + h('div.private-key-password-warning', `Warning: Never disclose this key. + Anyone with your private keys can take steal any assets held in your + account.` + ), + + this.renderButtons(privateKey, this.state.password, address, hideModal), + + ]) +} diff --git a/ui/app/components/modals/hide-token-confirmation-modal.js b/ui/app/components/modals/hide-token-confirmation-modal.js new file mode 100644 index 000000000..56c7ba299 --- /dev/null +++ b/ui/app/components/modals/hide-token-confirmation-modal.js @@ -0,0 +1,74 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const Identicon = require('../identicon') + +function mapStateToProps (state) { + return { + network: state.metamask.network, + token: state.appState.modal.modalState.token, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => dispatch(actions.hideModal()), + hideToken: address => { + dispatch(actions.removeToken(address)) + .then(() => { + dispatch(actions.hideModal()) + }) + }, + } +} + +inherits(HideTokenConfirmationModal, Component) +function HideTokenConfirmationModal () { + Component.call(this) + + this.state = {} +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(HideTokenConfirmationModal) + +HideTokenConfirmationModal.prototype.render = function () { + const { token, network, hideToken, hideModal } = this.props + const { symbol, address } = token + + return h('div.hide-token-confirmation', {}, [ + h('div.hide-token-confirmation__container', { + }, [ + h('div.hide-token-confirmation__title', {}, [ + 'Hide Token?', + ]), + + h(Identicon, { + className: 'hide-token-confirmation__identicon', + diameter: 45, + address, + network, + }), + + h('div.hide-token-confirmation__symbol', {}, symbol), + + h('div.hide-token-confirmation__copy', {}, [ + 'You can add this token back in the future by going go to “Add token” in your accounts options menu.', + ]), + + h('div.hide-token-confirmation__buttons', {}, [ + h('button.btn-cancel.hide-token-confirmation__button', { + onClick: () => hideModal(), + }, [ + 'CANCEL', + ]), + h('button.btn-clear.hide-token-confirmation__button', { + onClick: () => hideToken(address), + }, [ + 'HIDE', + ]), + ]), + ]), + ]) +} diff --git a/ui/app/components/modals/index.js b/ui/app/components/modals/index.js new file mode 100644 index 000000000..1db1d33d4 --- /dev/null +++ b/ui/app/components/modals/index.js @@ -0,0 +1,5 @@ +const Modal = require('./modal') + +module.exports = { + Modal, +} diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js new file mode 100644 index 000000000..afb2a2175 --- /dev/null +++ b/ui/app/components/modals/modal.js @@ -0,0 +1,331 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const FadeModal = require('boron').FadeModal +const actions = require('../../actions') +const isMobileView = require('../../../lib/is-mobile-view') +const isPopupOrNotification = require('../../../../app/scripts/lib/is-popup-or-notification') + +// Modal Components +const BuyOptions = require('./buy-options-modal') +const DepositEtherModal = require('./deposit-ether-modal') +const AccountDetailsModal = require('./account-details-modal') +const EditAccountNameModal = require('./edit-account-name-modal') +const ExportPrivateKeyModal = require('./export-private-key-modal') +const NewAccountModal = require('./new-account-modal') +const ShapeshiftDepositTxModal = require('./shapeshift-deposit-tx-modal.js') +const HideTokenConfirmationModal = require('./hide-token-confirmation-modal') +const CustomizeGasModal = require('../customize-gas-modal') +const NotifcationModal = require('./notification-modal') + +const accountModalStyle = { + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + borderRadius: '4px', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '360px', + // top: 'calc(33% + 45px)', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + borderRadius: '4px', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + contentStyle: { + borderRadius: '4px', + }, +} + +const MODALS = { + BUY: { + contents: [ + h(BuyOptions, {}, []), + ], + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '48vh' : '36.5vh', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', + top: '10%', + }, + laptopModalStyle: { + width: '66%', + maxWidth: '550px', + top: 'calc(10% + 10px)', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', + transform: 'none', + }, + }, + + DEPOSIT_ETHER: { + contents: [ + h(DepositEtherModal, {}, []), + ], + mobileModalStyle: { + width: '100%', + height: '100%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', + top: '0', + display: 'flex', + }, + laptopModalStyle: { + width: '900px', + maxWidth: '900px', + top: 'calc(10% + 10px)', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 6px 0 rgba(0,0,0,0.3)', + borderRadius: '8px', + transform: 'none', + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + EDIT_ACCOUNT_NAME: { + contents: [ + h(EditAccountNameModal, {}, []), + ], + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '48vh' : '36.5vh', + top: '10%', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '375px', + // top: 'calc(30% + 10px)', + top: '10%', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + + ACCOUNT_DETAILS: { + contents: [ + h(AccountDetailsModal, {}, []), + ], + ...accountModalStyle, + }, + + EXPORT_PRIVATE_KEY: { + contents: [ + h(ExportPrivateKeyModal, {}, []), + ], + ...accountModalStyle, + }, + + SHAPESHIFT_DEPOSIT_TX: { + contents: [ + h(ShapeshiftDepositTxModal), + ], + ...accountModalStyle, + }, + + HIDE_TOKEN_CONFIRMATION: { + contents: [ + h(HideTokenConfirmationModal, {}, []), + ], + mobileModalStyle: { + width: '95%', + top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + + BETA_UI_NOTIFICATION_MODAL: { + contents: [ + h(NotifcationModal, { + header: 'Welcome to the New UI (Beta)', + message: `You are now using the new Metamask UI. Take a look around, try out new features like sending tokens, + and let us know if you have any issues.`, + }), + ], + mobileModalStyle: { + width: '95%', + top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + + OLD_UI_NOTIFICATION_MODAL: { + contents: [ + h(NotifcationModal, { + header: 'Old UI', + message: `You have returned to the old UI. You can switch back to the New UI through the option in the top + right dropdown menu.`, + }), + ], + mobileModalStyle: { + width: '95%', + top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + + NEW_ACCOUNT: { + contents: [ + h(NewAccountModal, {}, []), + ], + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '449px', + // top: 'calc(33% + 45px)', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + + CUSTOMIZE_GAS: { + contents: [ + h(CustomizeGasModal, {}, []), + ], + mobileModalStyle: { + width: '100vw', + height: '100vh', + top: '0', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '720px', + height: '377px', + top: '80px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + + DEFAULT: { + contents: [], + mobileModalStyle: {}, + laptopModalStyle: {}, + }, +} + +const BACKDROPSTYLE = { + backgroundColor: 'rgba(0, 0, 0, 0.5)', +} + +function mapStateToProps (state) { + return { + active: state.appState.modal.open, + modalState: state.appState.modal.modalState, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +// Global Modal Component +inherits(Modal, Component) +function Modal () { + Component.call(this) +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(Modal) + +Modal.prototype.render = function () { + const modal = MODALS[this.props.modalState.name || 'DEFAULT'] + + const children = modal.contents + const modalStyle = modal[isMobileView() ? 'mobileModalStyle' : 'laptopModalStyle'] + const contentStyle = modal.contentStyle || {} + + return h(FadeModal, + { + className: 'modal', + keyboard: false, + onHide: () => { this.onHide() }, + ref: (ref) => { + this.modalRef = ref + }, + modalStyle, + contentStyle, + backdropStyle: BACKDROPSTYLE, + }, + children, + ) +} + +Modal.prototype.componentWillReceiveProps = function (nextProps) { + if (nextProps.active) { + this.show() + } else if (this.props.active) { + this.hide() + } +} + +Modal.prototype.onHide = function () { + if (this.props.onHideCallback) { + this.props.onHideCallback() + } + this.props.hideModal() +} + +Modal.prototype.hide = function () { + this.modalRef.hide() +} + +Modal.prototype.show = function () { + this.modalRef.show() +} diff --git a/ui/app/components/modals/new-account-modal.js b/ui/app/components/modals/new-account-modal.js new file mode 100644 index 000000000..fc1fd413d --- /dev/null +++ b/ui/app/components/modals/new-account-modal.js @@ -0,0 +1,106 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const { connect } = require('react-redux') +const actions = require('../../actions') + +class NewAccountModal extends Component { + constructor (props) { + super(props) + const { numberOfExistingAccounts = 0 } = props + const newAccountNumber = numberOfExistingAccounts + 1 + + this.state = { + newAccountName: `Account ${newAccountNumber}`, + } + } + + render () { + const { newAccountName } = this.state + + return h('div', [ + h('div.new-account-modal-wrapper', { + }, [ + h('div.new-account-modal-header', {}, [ + 'New Account', + ]), + + h('div.modal-close-x', { + onClick: this.props.hideModal, + }), + + h('div.new-account-modal-content', {}, [ + 'Account Name', + ]), + + h('div.new-account-input-wrapper', {}, [ + h('input.new-account-input', { + value: this.state.newAccountName, + placeholder: 'E.g. My new account', + onChange: event => this.setState({ newAccountName: event.target.value }), + }, []), + ]), + + h('div.new-account-modal-content.after-input', {}, [ + 'or', + ]), + + h('div.new-account-modal-content.after-input.pointer', { + onClick: () => { + this.props.hideModal() + this.props.showImportPage() + }, + }, 'Import an account'), + + h('div.new-account-modal-content.button', {}, [ + h('button.btn-clear', { + onClick: () => this.props.createAccount(newAccountName), + }, [ + 'SAVE', + ]), + ]), + ]), + ]) + } +} + +NewAccountModal.propTypes = { + hideModal: PropTypes.func, + showImportPage: PropTypes.func, + createAccount: PropTypes.func, + numberOfExistingAccounts: PropTypes.number, +} + +const mapStateToProps = state => { + const { metamask: { network, selectedAddress, identities = {} } } = state + const numberOfExistingAccounts = Object.keys(identities).length + + return { + network, + address: selectedAddress, + numberOfExistingAccounts, + } +} + +const mapDispatchToProps = dispatch => { + return { + toCoinbase: (address) => { + dispatch(actions.buyEth({ network: '1', address, amount: 0 })) + }, + hideModal: () => { + dispatch(actions.hideModal()) + }, + createAccount: (newAccountName) => { + dispatch(actions.addNewAccount()) + .then((newAccountAddress) => { + if (newAccountName) { + dispatch(actions.saveAccountLabel(newAccountAddress, newAccountName)) + } + dispatch(actions.hideModal()) + }) + }, + showImportPage: () => dispatch(actions.showImportPage()), + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(NewAccountModal) diff --git a/ui/app/components/modals/notification-modal.js b/ui/app/components/modals/notification-modal.js new file mode 100644 index 000000000..239144b0c --- /dev/null +++ b/ui/app/components/modals/notification-modal.js @@ -0,0 +1,51 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const { connect } = require('react-redux') +const actions = require('../../actions') + +class NotificationModal extends Component { + render () { + const { + header, + message, + } = this.props + + return h('div', [ + h('div.notification-modal-wrapper', { + }, [ + + h('div.notification-modal-header', {}, [ + header, + ]), + + h('div.notification-modal-message-wrapper', {}, [ + h('div.notification-modal-message', {}, [ + message, + ]), + ]), + + h('div.modal-close-x', { + onClick: this.props.hideModal, + }), + + ]), + ]) + } +} + +NotificationModal.propTypes = { + hideModal: PropTypes.func, + header: PropTypes.string, + message: PropTypes.string, +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +module.exports = connect(null, mapDispatchToProps)(NotificationModal) diff --git a/ui/app/components/modals/shapeshift-deposit-tx-modal.js b/ui/app/components/modals/shapeshift-deposit-tx-modal.js new file mode 100644 index 000000000..24af5a0de --- /dev/null +++ b/ui/app/components/modals/shapeshift-deposit-tx-modal.js @@ -0,0 +1,40 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const QrView = require('../qr-code') +const AccountModalContainer = require('./account-modal-container') + +function mapStateToProps (state) { + return { + Qr: state.appState.modal.modalState.Qr, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +inherits(ShapeshiftDepositTxModal, Component) +function ShapeshiftDepositTxModal () { + Component.call(this) + +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ShapeshiftDepositTxModal) + +ShapeshiftDepositTxModal.prototype.render = function () { + const { Qr } = this.props + + return h(AccountModalContainer, { + }, [ + h('div', {}, [ + h(QrView, {key: 'qr', Qr}), + ]), + ]) +} diff --git a/ui/app/components/network.js b/ui/app/components/network.js index 18fca1db7..3e91fa807 100644 --- a/ui/app/components/network.js +++ b/ui/app/components/network.js @@ -1,6 +1,8 @@ const Component = require('react').Component const h = require('react-hyperscript') +const classnames = require('classnames') const inherits = require('util').inherits +const NetworkDropdownIcon = require('./dropdowns/components/network-dropdown-icon') module.exports = Network @@ -37,7 +39,6 @@ Network.prototype.render = function () { }, src: 'images/loading.svg', }), - h('i.fa.fa-caret-down'), ]) } else if (providerName === 'mainnet') { hoverText = 'Main Ethereum Network' @@ -60,51 +61,58 @@ Network.prototype.render = function () { } return ( - h('#network_component.pointer', { + h('div.network-component.pointer', { + className: classnames({ + 'network-component--disabled': this.props.disabled, + 'ethereum-network': providerName === 'mainnet', + 'ropsten-test-network': providerName === 'ropsten' || parseInt(networkNumber) === 3, + 'kovan-test-network': providerName === 'kovan', + 'rinkeby-test-network': providerName === 'rinkeby', + }), title: hoverText, - onClick: (event) => this.props.onClick(event), + onClick: (event) => { + if (!this.props.disabled) { + this.props.onClick(event) + } + }, }, [ (function () { switch (iconName) { case 'ethereum-network': return h('.network-indicator', [ - h('.menu-icon.diamond'), - h('.network-name', { - style: { - color: '#039396', - }}, - 'Main Network'), - h('i.fa.fa-caret-down.fa-lg'), + h(NetworkDropdownIcon, { + backgroundColor: '#038789', // $blue-lagoon + nonSelectBackgroundColor: '#15afb2', + }), + h('.network-name', 'Main Network'), + h('i.fa.fa-chevron-down.fa-lg.network-caret'), ]) case 'ropsten-test-network': return h('.network-indicator', [ - h('.menu-icon.red-dot'), - h('.network-name', { - style: { - color: '#ff6666', - }}, - 'Ropsten Test Net'), - h('i.fa.fa-caret-down.fa-lg'), + h(NetworkDropdownIcon, { + backgroundColor: '#e91550', // $crimson + nonSelectBackgroundColor: '#ec2c50', + }), + h('.network-name', 'Ropsten Test Net'), + h('i.fa.fa-chevron-down.fa-lg.network-caret'), ]) case 'kovan-test-network': return h('.network-indicator', [ - h('.menu-icon.hollow-diamond'), - h('.network-name', { - style: { - color: '#690496', - }}, - 'Kovan Test Net'), - h('i.fa.fa-caret-down.fa-lg'), + h(NetworkDropdownIcon, { + backgroundColor: '#690496', // $purple + nonSelectBackgroundColor: '#b039f3', + }), + h('.network-name', 'Kovan Test Net'), + h('i.fa.fa-chevron-down.fa-lg.network-caret'), ]) case 'rinkeby-test-network': return h('.network-indicator', [ - h('.menu-icon.golden-square'), - h('.network-name', { - style: { - color: '#e7a218', - }}, - 'Rinkeby Test Net'), - h('i.fa.fa-caret-down.fa-lg'), + h(NetworkDropdownIcon, { + backgroundColor: '#ebb33f', // $tulip-tree + nonSelectBackgroundColor: '#ecb23e', + }), + h('.network-name', 'Rinkeby Test Net'), + h('i.fa.fa-chevron-down.fa-lg.network-caret'), ]) default: return h('.network-indicator', [ @@ -115,12 +123,8 @@ Network.prototype.render = function () { }, }), - h('.network-name', { - style: { - color: '#AEAEAE', - }}, - 'Private Network'), - h('i.fa.fa-caret-down.fa-lg'), + h('.network-name', 'Private Network'), + h('i.fa.fa-chevron-down.fa-lg.network-caret'), ]) } })(), diff --git a/ui/app/components/notice.js b/ui/app/components/notice.js index 09d461c7b..941ac33e6 100644 --- a/ui/app/components/notice.js +++ b/ui/app/components/notice.js @@ -102,7 +102,7 @@ Notice.prototype.render = function () { }), ]), - h('button', { + h('button.primary', { disabled, onClick: () => { this.setState({disclaimerDisabled: true}) diff --git a/ui/app/components/pending-personal-msg.js b/ui/app/components/pending-personal-msg.js deleted file mode 100644 index 4542adb28..000000000 --- a/ui/app/components/pending-personal-msg.js +++ /dev/null @@ -1,47 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const PendingTxDetails = require('./pending-personal-msg-details') - -module.exports = PendingMsg - -inherits(PendingMsg, Component) -function PendingMsg () { - Component.call(this) -} - -PendingMsg.prototype.render = function () { - var state = this.props - var msgData = state.txData - - return ( - - h('div', { - key: msgData.id, - }, [ - - // header - h('h3', { - style: { - fontWeight: 'bold', - textAlign: 'center', - }, - }, 'Sign Message'), - - // message details - h(PendingTxDetails, state), - - // sign + cancel - h('.flex-row.flex-space-around', [ - h('button', { - onClick: state.cancelPersonalMessage, - }, 'Cancel'), - h('button', { - onClick: state.signPersonalMessage, - }, 'Sign'), - ]), - ]) - - ) -} - diff --git a/ui/app/components/pending-tx.js b/ui/app/components/pending-tx.js deleted file mode 100644 index 32d54902e..000000000 --- a/ui/app/components/pending-tx.js +++ /dev/null @@ -1,510 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const actions = require('../actions') -const clone = require('clone') - -const ethUtil = require('ethereumjs-util') -const BN = ethUtil.BN -const hexToBn = require('../../../app/scripts/lib/hex-to-bn') -const util = require('../util') -const MiniAccountPanel = require('./mini-account-panel') -const Copyable = require('./copyable') -const EthBalance = require('./eth-balance') -const addressSummary = util.addressSummary -const nameForAddress = require('../../lib/contract-namer') -const BNInput = require('./bn-as-decimal-input') - -// corresponds with 0.1 GWEI -const MIN_GAS_PRICE_BN = new BN('100000000') -const MIN_GAS_LIMIT_BN = new BN('21000') - -module.exports = PendingTx -inherits(PendingTx, Component) -function PendingTx () { - Component.call(this) - this.state = { - valid: true, - txData: null, - submitting: false, - } -} - -PendingTx.prototype.render = function () { - const props = this.props - const { currentCurrency, blockGasLimit } = props - - const conversionRate = props.conversionRate - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - // Allow retry txs - const { lastGasPrice } = txMeta - let forceGasMin - if (lastGasPrice) { - const stripped = ethUtil.stripHexPrefix(lastGasPrice) - const lastGas = new BN(stripped, 16) - const priceBump = lastGas.divn('10') - forceGasMin = lastGas.add(priceBump) - } - - // Account Details - const address = txParams.from || props.selectedAddress - const identity = props.identities[address] || { address: address } - const account = props.accounts[address] - const balance = account ? account.balance : '0x0' - - // recipient check - const isValidAddress = !txParams.to || util.isValidAddress(txParams.to) - - // Gas - const gas = txParams.gas - const gasBn = hexToBn(gas) - const gasLimit = new BN(parseInt(blockGasLimit)) - const safeGasLimitBN = this.bnMultiplyByFraction(gasLimit, 19, 20) - const saferGasLimitBN = this.bnMultiplyByFraction(gasLimit, 18, 20) - const safeGasLimit = safeGasLimitBN.toString(10) - - // Gas Price - const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_BN.toString(16) - const gasPriceBn = hexToBn(gasPrice) - - const txFeeBn = gasBn.mul(gasPriceBn) - const valueBn = hexToBn(txParams.value) - const maxCost = txFeeBn.add(valueBn) - - const dataLength = txParams.data ? (txParams.data.length - 2) / 2 : 0 - - const balanceBn = hexToBn(balance) - const insufficientBalance = balanceBn.lt(maxCost) - const dangerousGasLimit = gasBn.gte(saferGasLimitBN) - const gasLimitSpecified = txMeta.gasLimitSpecified - const buyDisabled = insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting - const showRejectAll = props.unconfTxListLength > 1 - - this.inputs = [] - - return ( - - h('div', { - key: txMeta.id, - }, [ - - h('form#pending-tx-form', { - onSubmit: this.onSubmit.bind(this), - - }, [ - - // tx info - h('div', [ - - h('.flex-row.flex-center', { - style: { - maxWidth: '100%', - }, - }, [ - - h(MiniAccountPanel, { - imageSeed: address, - picOrder: 'right', - }, [ - h('span.font-small', { - style: { - fontFamily: 'Montserrat Bold, Montserrat, sans-serif', - }, - }, identity.name), - - h(Copyable, { - value: ethUtil.toChecksumAddress(address), - }, [ - h('span.font-small', { - style: { - fontFamily: 'Montserrat Light, Montserrat, sans-serif', - }, - }, addressSummary(address, 6, 4, false)), - ]), - - h('span.font-small', { - style: { - fontFamily: 'Montserrat Light, Montserrat, sans-serif', - }, - }, [ - h(EthBalance, { - value: balance, - conversionRate, - currentCurrency, - inline: true, - labelColor: '#F7861C', - }), - ]), - ]), - - forwardCarrat(), - - this.miniAccountPanelForRecipient(), - ]), - - h('style', ` - .table-box { - margin: 7px 0px 0px 0px; - width: 100%; - } - .table-box .row { - margin: 0px; - background: rgb(236,236,236); - display: flex; - justify-content: space-between; - font-family: Montserrat Light, sans-serif; - font-size: 13px; - padding: 5px 25px; - } - .table-box .row .value { - font-family: Montserrat Regular; - } - `), - - h('.table-box', [ - - // Ether Value - // Currently not customizable, but easily modified - // in the way that gas and gasLimit currently are. - h('.row', [ - h('.cell.label', 'Amount'), - h(EthBalance, { value: txParams.value, currentCurrency, conversionRate }), - ]), - - // Gas Limit (customizable) - h('.cell.row', [ - h('.cell.label', 'Gas Limit'), - h('.cell.value', { - }, [ - h(BNInput, { - name: 'Gas Limit', - value: gasBn, - precision: 0, - scale: 0, - // The hard lower limit for gas. - min: MIN_GAS_LIMIT_BN, - max: safeGasLimit, - suffix: 'UNITS', - style: { - position: 'relative', - top: '5px', - }, - onChange: this.gasLimitChanged.bind(this), - - ref: (hexInput) => { this.inputs.push(hexInput) }, - }), - ]), - ]), - - // Gas Price (customizable) - h('.cell.row', [ - h('.cell.label', 'Gas Price'), - h('.cell.value', { - }, [ - h(BNInput, { - name: 'Gas Price', - value: gasPriceBn, - precision: 9, - scale: 9, - suffix: 'GWEI', - min: forceGasMin || MIN_GAS_PRICE_BN, - style: { - position: 'relative', - top: '5px', - }, - onChange: this.gasPriceChanged.bind(this), - ref: (hexInput) => { this.inputs.push(hexInput) }, - }), - ]), - ]), - - // Max Transaction Fee (calculated) - h('.cell.row', [ - h('.cell.label', 'Max Transaction Fee'), - h(EthBalance, { value: txFeeBn.toString(16), currentCurrency, conversionRate }), - ]), - - h('.cell.row', { - style: { - fontFamily: 'Montserrat Regular', - background: 'white', - padding: '10px 25px', - }, - }, [ - h('.cell.label', 'Max Total'), - h('.cell.value', { - style: { - display: 'flex', - alignItems: 'center', - }, - }, [ - h(EthBalance, { - value: maxCost.toString(16), - currentCurrency, - conversionRate, - inline: true, - labelColor: 'black', - fontSize: '16px', - }), - ]), - ]), - - // Data size row: - h('.cell.row', { - style: { - background: '#f7f7f7', - paddingBottom: '0px', - }, - }, [ - h('.cell.label'), - h('.cell.value', { - style: { - fontFamily: 'Montserrat Light', - fontSize: '11px', - }, - }, `Data included: ${dataLength} bytes`), - ]), - ]), // End of Table - - ]), - - h('style', ` - .conf-buttons button { - margin-left: 10px; - text-transform: uppercase; - } - `), - h('.cell.row', { - style: { - textAlign: 'center', - }, - }, [ - txMeta.simulationFails ? - h('.error', { - style: { - fontSize: '0.9em', - }, - }, 'Transaction Error. Exception thrown in contract code.') - : null, - - !isValidAddress ? - h('.error', { - style: { - fontSize: '0.9em', - }, - }, 'Recipient address is invalid. Sending this transaction will result in a loss of ETH.') - : null, - - insufficientBalance ? - h('span.error', { - style: { - fontSize: '0.9em', - }, - }, 'Insufficient balance for transaction') - : null, - - (dangerousGasLimit && !gasLimitSpecified) ? - h('span.error', { - style: { - fontSize: '0.9em', - }, - }, 'Gas limit set dangerously high. Approving this transaction is likely to fail.') - : null, - ]), - - - // send + cancel - h('.flex-row.flex-space-around.conf-buttons', { - style: { - display: 'flex', - justifyContent: 'flex-end', - margin: '14px 25px', - }, - }, [ - h('button', { - onClick: (event) => { - this.resetGasFields() - event.preventDefault() - }, - }, 'Reset'), - - // Accept Button or Buy Button - insufficientBalance ? h('button.btn-green', { onClick: props.buyEth }, 'Buy Ether') : - h('input.confirm.btn-green', { - type: 'submit', - value: 'SUBMIT', - style: { marginLeft: '10px' }, - disabled: buyDisabled, - }), - - h('button.cancel.btn-red', { - onClick: props.cancelTransaction, - }, 'Reject'), - ]), - showRejectAll ? h('.flex-row.flex-space-around.conf-buttons', { - style: { - display: 'flex', - justifyContent: 'flex-end', - margin: '14px 25px', - }, - }, [ - h('button.cancel.btn-red', { - onClick: props.cancelAllTransactions, - }, 'Reject All'), - ]) : null, - ]), - ]) - ) -} - -PendingTx.prototype.miniAccountPanelForRecipient = function () { - const props = this.props - const txData = props.txData - const txParams = txData.txParams || {} - const isContractDeploy = !('to' in txParams) - - // If it's not a contract deploy, send to the account - if (!isContractDeploy) { - return h(MiniAccountPanel, { - imageSeed: txParams.to, - picOrder: 'left', - }, [ - - h('span.font-small', { - style: { - fontFamily: 'Montserrat Bold, Montserrat, sans-serif', - }, - }, nameForAddress(txParams.to, props.identities)), - - h(Copyable, { - value: ethUtil.toChecksumAddress(txParams.to), - }, [ - h('span.font-small', { - style: { - fontFamily: 'Montserrat Light, Montserrat, sans-serif', - }, - }, addressSummary(txParams.to, 6, 4, false)), - ]), - - ]) - } else { - return h(MiniAccountPanel, { - picOrder: 'left', - }, [ - - h('span.font-small', { - style: { - fontFamily: 'Montserrat Bold, Montserrat, sans-serif', - }, - }, 'New Contract'), - - ]) - } -} - -PendingTx.prototype.gasPriceChanged = function (newBN, valid) { - log.info(`Gas price changed to: ${newBN.toString(10)}`) - const txMeta = this.gatherTxMeta() - txMeta.txParams.gasPrice = '0x' + newBN.toString('hex') - this.setState({ - txData: clone(txMeta), - valid, - }) -} - -PendingTx.prototype.gasLimitChanged = function (newBN, valid) { - log.info(`Gas limit changed to ${newBN.toString(10)}`) - const txMeta = this.gatherTxMeta() - txMeta.txParams.gas = '0x' + newBN.toString('hex') - this.setState({ - txData: clone(txMeta), - valid, - }) -} - -PendingTx.prototype.resetGasFields = function () { - log.debug(`pending-tx resetGasFields`) - - this.inputs.forEach((hexInput) => { - if (hexInput) { - hexInput.setValid() - } - }) - - this.setState({ - txData: null, - valid: true, - }) -} - -PendingTx.prototype.onSubmit = function (event) { - event.preventDefault() - const txMeta = this.gatherTxMeta() - const valid = this.checkValidity() - this.setState({ valid, submitting: true }) - if (valid && this.verifyGasParams()) { - this.props.sendTransaction(txMeta, event) - } else { - this.props.dispatch(actions.displayWarning('Invalid Gas Parameters')) - this.setState({ submitting: false }) - } -} - -PendingTx.prototype.checkValidity = function () { - const form = this.getFormEl() - const valid = form.checkValidity() - return valid -} - -PendingTx.prototype.getFormEl = function () { - const form = document.querySelector('form#pending-tx-form') - // Stub out form for unit tests: - if (!form) { - return { checkValidity () { return true } } - } - return form -} - -// After a customizable state value has been updated, -PendingTx.prototype.gatherTxMeta = function () { - log.debug(`pending-tx gatherTxMeta`) - const props = this.props - const state = this.state - const txData = clone(state.txData) || clone(props.txData) - - log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) - return txData -} - -PendingTx.prototype.verifyGasParams = function () { - // We call this in case the gas has not been modified at all - if (!this.state) { return true } - return ( - this._notZeroOrEmptyString(this.state.gas) && - this._notZeroOrEmptyString(this.state.gasPrice) - ) -} - -PendingTx.prototype._notZeroOrEmptyString = function (obj) { - return obj !== '' && obj !== '0x0' -} - -PendingTx.prototype.bnMultiplyByFraction = function (targetBN, numerator, denominator) { - const numBN = new BN(numerator) - const denomBN = new BN(denominator) - return targetBN.mul(numBN).div(denomBN) -} - -function forwardCarrat () { - return ( - h('img', { - src: 'images/forward-carrat.svg', - style: { - padding: '5px 6px 0px 10px', - height: '37px', - }, - }) - ) -} diff --git a/ui/app/components/pending-tx/confirm-deploy-contract.js b/ui/app/components/pending-tx/confirm-deploy-contract.js new file mode 100644 index 000000000..ae6c6ef7b --- /dev/null +++ b/ui/app/components/pending-tx/confirm-deploy-contract.js @@ -0,0 +1,348 @@ +const Component = require('react').Component +const { connect } = require('react-redux') +const h = require('react-hyperscript') +const inherits = require('util').inherits +const actions = require('../../actions') +const clone = require('clone') +const Identicon = require('../identicon') +const ethUtil = require('ethereumjs-util') +const BN = ethUtil.BN +const hexToBn = require('../../../../app/scripts/lib/hex-to-bn') +const { conversionUtil } = require('../../conversion-util') + +const { MIN_GAS_PRICE_HEX } = require('../send/send-constants') + + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ConfirmDeployContract) + +function mapStateToProps (state) { + const { + conversionRate, + identities, + currentCurrency, + } = state.metamask + const accounts = state.metamask.accounts + const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] + return { + currentCurrency, + conversionRate, + identities, + selectedAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), + cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), + } +} + + +inherits(ConfirmDeployContract, Component) +function ConfirmDeployContract () { + Component.call(this) + this.state = {} + this.onSubmit = this.onSubmit.bind(this) +} + +ConfirmDeployContract.prototype.onSubmit = function (event) { + event.preventDefault() + const txMeta = this.gatherTxMeta() + const valid = this.checkValidity() + this.setState({ valid, submitting: true }) + + if (valid && this.verifyGasParams()) { + this.props.sendTransaction(txMeta, event) + } else { + this.props.dispatch(actions.displayWarning('Invalid Gas Parameters')) + this.setState({ submitting: false }) + } +} + +ConfirmDeployContract.prototype.cancel = function (event, txMeta) { + event.preventDefault() + this.props.cancelTransaction(txMeta) +} + +ConfirmDeployContract.prototype.checkValidity = function () { + const form = this.getFormEl() + const valid = form.checkValidity() + return valid +} + +ConfirmDeployContract.prototype.getFormEl = function () { + const form = document.querySelector('form#pending-tx-form') + // Stub out form for unit tests: + if (!form) { + return { checkValidity () { return true } } + } + return form +} + +// After a customizable state value has been updated, +ConfirmDeployContract.prototype.gatherTxMeta = function () { + const props = this.props + const state = this.state + const txData = clone(state.txData) || clone(props.txData) + + // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) + return txData +} + +ConfirmDeployContract.prototype.verifyGasParams = function () { + // We call this in case the gas has not been modified at all + if (!this.state) { return true } + return ( + this._notZeroOrEmptyString(this.state.gas) && + this._notZeroOrEmptyString(this.state.gasPrice) + ) +} + +ConfirmDeployContract.prototype._notZeroOrEmptyString = function (obj) { + return obj !== '' && obj !== '0x0' +} + +ConfirmDeployContract.prototype.bnMultiplyByFraction = function (targetBN, numerator, denominator) { + const numBN = new BN(numerator) + const denomBN = new BN(denominator) + return targetBN.mul(numBN).div(denomBN) +} + +ConfirmDeployContract.prototype.getData = function () { + const { identities } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + return { + from: { + address: txParams.from, + name: identities[txParams.from].name, + }, + memo: txParams.memo || '', + } +} + +ConfirmDeployContract.prototype.getAmount = function () { + const { conversionRate, currentCurrency } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + const FIAT = conversionUtil(txParams.value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency: currentCurrency, + numberOfDecimals: 2, + fromDenomination: 'WEI', + conversionRate, + }) + const ETH = conversionUtil(txParams.value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency: 'ETH', + fromDenomination: 'WEI', + conversionRate, + numberOfDecimals: 6, + }) + + return { + fiat: Number(FIAT), + token: Number(ETH), + } + +} + +ConfirmDeployContract.prototype.getGasFee = function () { + const { conversionRate, currentCurrency } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + // Gas + const gas = txParams.gas + const gasBn = hexToBn(gas) + + // Gas Price + const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_HEX + const gasPriceBn = hexToBn(gasPrice) + + const txFeeBn = gasBn.mul(gasPriceBn) + + const FIAT = conversionUtil(txFeeBn, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate, + }) + const ETH = conversionUtil(txFeeBn, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency: 'ETH', + numberOfDecimals: 6, + conversionRate, + }) + + return { + fiat: Number(FIAT), + eth: Number(ETH), + } +} + +ConfirmDeployContract.prototype.renderGasFee = function () { + const { currentCurrency } = this.props + const { fiat: fiatGas, eth: ethGas } = this.getGasFee() + + return ( + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'Gas Fee' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', `${fiatGas} ${currentCurrency.toUpperCase()}`), + + h( + 'div.confirm-screen-row-detail', + `${ethGas} ETH` + ), + ]), + ]) + ) +} + +ConfirmDeployContract.prototype.renderHeroAmount = function () { + const { currentCurrency } = this.props + const { fiat: fiatAmount } = this.getAmount() + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + const { memo = '' } = txParams + + return ( + h('div.confirm-send-token__hero-amount-wrapper', [ + h('h3.flex-center.confirm-screen-send-amount', `${fiatAmount}`), + h('h3.flex-center.confirm-screen-send-amount-currency', currentCurrency.toUpperCase()), + h('div.flex-center.confirm-memo-wrapper', [ + h('h3.confirm-screen-send-memo', memo), + ]), + ]) + ) +} + +ConfirmDeployContract.prototype.renderTotalPlusGas = function () { + const { currentCurrency } = this.props + const { fiat: fiatAmount, token: tokenAmount } = this.getAmount() + const { fiat: fiatGas, eth: ethGas } = this.getGasFee() + + return ( + h('section.flex-row.flex-center.confirm-screen-total-box ', [ + h('div.confirm-screen-section-column', [ + h('span.confirm-screen-label', [ 'Total ' ]), + h('div.confirm-screen-total-box__subtitle', [ 'Amount + Gas' ]), + ]), + + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', `${fiatAmount + fiatGas} ${currentCurrency.toUpperCase()}`), + h('div.confirm-screen-row-detail', `${tokenAmount + ethGas} ETH`), + ]), + ]) + ) +} + +ConfirmDeployContract.prototype.render = function () { + const { backToAccountDetail, selectedAddress } = this.props + const txMeta = this.gatherTxMeta() + + const { + from: { + address: fromAddress, + name: fromName, + }, + } = this.getData() + + this.inputs = [] + + return ( + h('div.flex-column.flex-grow.confirm-screen-container', { + style: { minWidth: '355px' }, + }, [ + // Main Send token Card + h('div.confirm-screen-wrapper.flex-column.flex-grow', [ + h('h3.flex-center.confirm-screen-header', [ + h('button.confirm-screen-back-button', { + onClick: () => backToAccountDetail(selectedAddress), + }, 'BACK'), + h('div.confirm-screen-title', 'Confirm Contract'), + h('div.confirm-screen-header-tip'), + ]), + h('div.flex-row.flex-center.confirm-screen-identicons', [ + h('div.confirm-screen-account-wrapper', [ + h( + Identicon, + { + address: fromAddress, + diameter: 60, + }, + ), + h('span.confirm-screen-account-name', fromName), + // h('span.confirm-screen-account-number', fromAddress.slice(fromAddress.length - 4)), + ]), + h('i.fa.fa-arrow-right.fa-lg'), + h('div.confirm-screen-account-wrapper', [ + h('i.fa.fa-file-text-o'), + h('span.confirm-screen-account-name', 'New Contract'), + h('span.confirm-screen-account-number', ' '), + ]), + ]), + + // h('h3.flex-center.confirm-screen-sending-to-message', { + // style: { + // textAlign: 'center', + // fontSize: '16px', + // }, + // }, [ + // `You're deploying a new contract.`, + // ]), + + this.renderHeroAmount(), + + h('div.confirm-screen-rows', [ + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'From' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', fromName), + h('div.confirm-screen-row-detail', `...${fromAddress.slice(fromAddress.length - 4)}`), + ]), + ]), + + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'To' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', 'New Contract'), + ]), + ]), + + this.renderGasFee(), + + this.renderTotalPlusGas(), + + ]), + ]), + + h('form#pending-tx-form', { + onSubmit: this.onSubmit, + }, [ + // Cancel Button + h('div.cancel.btn-light.confirm-screen-cancel-button', { + onClick: (event) => this.cancel(event, txMeta), + }, 'CANCEL'), + + // Accept Button + h('button.confirm-screen-confirm-button', ['CONFIRM']), + + ]), + ]) + ) +} diff --git a/ui/app/components/pending-tx/confirm-send-ether.js b/ui/app/components/pending-tx/confirm-send-ether.js new file mode 100644 index 000000000..652300c94 --- /dev/null +++ b/ui/app/components/pending-tx/confirm-send-ether.js @@ -0,0 +1,471 @@ +const Component = require('react').Component +const { connect } = require('react-redux') +const h = require('react-hyperscript') +const inherits = require('util').inherits +const actions = require('../../actions') +const clone = require('clone') +const Identicon = require('../identicon') +const ethUtil = require('ethereumjs-util') +const BN = ethUtil.BN +const hexToBn = require('../../../../app/scripts/lib/hex-to-bn') +const { conversionUtil, addCurrencies } = require('../../conversion-util') + +const { MIN_GAS_PRICE_HEX } = require('../send/send-constants') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ConfirmSendEther) + +function mapStateToProps (state) { + const { + conversionRate, + identities, + currentCurrency, + send, + } = state.metamask + const accounts = state.metamask.accounts + const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] + return { + conversionRate, + identities, + selectedAddress, + currentCurrency, + send, + } +} + +function mapDispatchToProps (dispatch) { + return { + clearSend: () => dispatch(actions.clearSend()), + editTransaction: txMeta => { + const { id, txParams } = txMeta + const { + gas: gasLimit, + gasPrice, + to, + value: amount, + } = txParams + dispatch(actions.updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount, + errors: { to: null, amount: null }, + editingTransactionId: id, + })) + dispatch(actions.showSendPage()) + }, + cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), + } +} + +inherits(ConfirmSendEther, Component) +function ConfirmSendEther () { + Component.call(this) + this.state = {} + this.onSubmit = this.onSubmit.bind(this) +} + +ConfirmSendEther.prototype.getAmount = function () { + const { conversionRate, currentCurrency } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + const FIAT = conversionUtil(txParams.value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency: currentCurrency, + numberOfDecimals: 2, + fromDenomination: 'WEI', + conversionRate, + }) + const ETH = conversionUtil(txParams.value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency: 'ETH', + fromDenomination: 'WEI', + conversionRate, + numberOfDecimals: 6, + }) + + return { + FIAT, + ETH, + } + +} + +ConfirmSendEther.prototype.getGasFee = function () { + const { conversionRate, currentCurrency } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + // Gas + const gas = txParams.gas + const gasBn = hexToBn(gas) + + // From latest master +// const gasLimit = new BN(parseInt(blockGasLimit)) +// const safeGasLimitBN = this.bnMultiplyByFraction(gasLimit, 19, 20) +// const saferGasLimitBN = this.bnMultiplyByFraction(gasLimit, 18, 20) +// const safeGasLimit = safeGasLimitBN.toString(10) + + // Gas Price + const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_HEX + const gasPriceBn = hexToBn(gasPrice) + + const txFeeBn = gasBn.mul(gasPriceBn) + + const FIAT = conversionUtil(txFeeBn, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate, + }) + const ETH = conversionUtil(txFeeBn, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency: 'ETH', + numberOfDecimals: 6, + conversionRate, + }) + + return { + FIAT, + ETH, + } +} + +ConfirmSendEther.prototype.getData = function () { + const { identities } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + const { FIAT: gasFeeInFIAT, ETH: gasFeeInETH } = this.getGasFee() + const { FIAT: amountInFIAT, ETH: amountInETH } = this.getAmount() + + const totalInFIAT = addCurrencies(gasFeeInFIAT, amountInFIAT, { + toNumericBase: 'dec', + numberOfDecimals: 2, + }) + const totalInETH = addCurrencies(gasFeeInETH, amountInETH, { + toNumericBase: 'dec', + numberOfDecimals: 6, + }) + + return { + from: { + address: txParams.from, + name: identities[txParams.from].name, + }, + to: { + address: txParams.to, + name: identities[txParams.to] ? identities[txParams.to].name : 'New Recipient', + }, + memo: txParams.memo || '', + gasFeeInFIAT, + gasFeeInETH, + amountInFIAT, + amountInETH, + totalInFIAT, + totalInETH, + } +} + +ConfirmSendEther.prototype.render = function () { + const { editTransaction, currentCurrency, clearSend } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + const { + from: { + address: fromAddress, + name: fromName, + }, + to: { + address: toAddress, + name: toName, + }, + memo, + gasFeeInFIAT, + gasFeeInETH, + amountInFIAT, + totalInFIAT, + totalInETH, + } = this.getData() + + // This is from the latest master + // It handles some of the errors that we are not currently handling + // Leaving as comments fo reference + + // const balanceBn = hexToBn(balance) + // const insufficientBalance = balanceBn.lt(maxCost) + // const buyDisabled = insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting + // const showRejectAll = props.unconfTxListLength > 1 +// const dangerousGasLimit = gasBn.gte(saferGasLimitBN) +// const gasLimitSpecified = txMeta.gasLimitSpecified + + this.inputs = [] + + return ( + h('div.confirm-screen-container.confirm-send-ether', { + style: { minWidth: '355px' }, + }, [ + // Main Send token Card + h('div.confirm-screen-wrapper.flex-column.flex-grow', [ + h('h3.flex-center.confirm-screen-header', [ + h('button.btn-clear.confirm-screen-back-button', { + onClick: () => editTransaction(txMeta), + }, 'EDIT'), + h('div.confirm-screen-title', 'Confirm Transaction'), + h('div.confirm-screen-header-tip'), + ]), + h('div.flex-row.flex-center.confirm-screen-identicons', [ + h('div.confirm-screen-account-wrapper', [ + h( + Identicon, + { + address: fromAddress, + diameter: 60, + }, + ), + h('span.confirm-screen-account-name', fromName), + // h('span.confirm-screen-account-number', fromAddress.slice(fromAddress.length - 4)), + ]), + h('i.fa.fa-arrow-right.fa-lg'), + h('div.confirm-screen-account-wrapper', [ + h( + Identicon, + { + address: txParams.to, + diameter: 60, + }, + ), + h('span.confirm-screen-account-name', toName), + // h('span.confirm-screen-account-number', toAddress.slice(toAddress.length - 4)), + ]), + ]), + + // h('h3.flex-center.confirm-screen-sending-to-message', { + // style: { + // textAlign: 'center', + // fontSize: '16px', + // }, + // }, [ + // `You're sending to Recipient ...${toAddress.slice(toAddress.length - 4)}`, + // ]), + + h('h3.flex-center.confirm-screen-send-amount', [`${amountInFIAT}`]), + h('h3.flex-center.confirm-screen-send-amount-currency', [ currentCurrency.toUpperCase() ]), + h('div.flex-center.confirm-memo-wrapper', [ + h('h3.confirm-screen-send-memo', [ memo ? `"${memo}"` : '' ]), + ]), + + h('div.confirm-screen-rows', [ + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'From' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', fromName), + h('div.confirm-screen-row-detail', `...${fromAddress.slice(fromAddress.length - 4)}`), + ]), + ]), + + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'To' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', toName), + h('div.confirm-screen-row-detail', `...${toAddress.slice(toAddress.length - 4)}`), + ]), + ]), + + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'Gas Fee' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', `${gasFeeInFIAT} ${currentCurrency.toUpperCase()}`), + + h('div.confirm-screen-row-detail', `${gasFeeInETH} ETH`), + ]), + ]), + + + h('section.flex-row.flex-center.confirm-screen-total-box ', [ + h('div.confirm-screen-section-column', [ + h('span.confirm-screen-label', [ 'Total ' ]), + h('div.confirm-screen-total-box__subtitle', [ 'Amount + Gas' ]), + ]), + + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', `${totalInFIAT} ${currentCurrency.toUpperCase()}`), + h('div.confirm-screen-row-detail', `${totalInETH} ETH`), + ]), + ]), + ]), + +// These are latest errors handling from master +// Leaving as comments as reference when we start implementing error handling +// h('style', ` +// .conf-buttons button { +// margin-left: 10px; +// text-transform: uppercase; +// } +// `), + +// txMeta.simulationFails ? +// h('.error', { +// style: { +// marginLeft: 50, +// fontSize: '0.9em', +// }, +// }, 'Transaction Error. Exception thrown in contract code.') +// : null, + +// !isValidAddress ? +// h('.error', { +// style: { +// marginLeft: 50, +// fontSize: '0.9em', +// }, +// }, 'Recipient address is invalid. Sending this transaction will result in a loss of ETH.') +// : null, + +// insufficientBalance ? +// h('span.error', { +// style: { +// marginLeft: 50, +// fontSize: '0.9em', +// }, +// }, 'Insufficient balance for transaction') +// : null, + +// // send + cancel +// h('.flex-row.flex-space-around.conf-buttons', { +// style: { +// display: 'flex', +// justifyContent: 'flex-end', +// margin: '14px 25px', +// }, +// }, [ +// h('button', { +// onClick: (event) => { +// this.resetGasFields() +// event.preventDefault() +// }, +// }, 'Reset'), + +// // Accept Button or Buy Button +// insufficientBalance ? h('button.btn-green', { onClick: props.buyEth }, 'Buy Ether') : +// h('input.confirm.btn-green', { +// type: 'submit', +// value: 'SUBMIT', +// style: { marginLeft: '10px' }, +// disabled: buyDisabled, +// }), + +// h('button.cancel.btn-red', { +// onClick: props.cancelTransaction, +// }, 'Reject'), +// ]), +// showRejectAll ? h('.flex-row.flex-space-around.conf-buttons', { +// style: { +// display: 'flex', +// justifyContent: 'flex-end', +// margin: '14px 25px', +// }, +// }, [ +// h('button.cancel.btn-red', { +// onClick: props.cancelAllTransactions, +// }, 'Reject All'), +// ]) : null, +// ]), +// ]) +// ) +// } + ]), + + h('form#pending-tx-form', { + onSubmit: this.onSubmit, + }, [ + // Cancel Button + h('div.cancel.btn-light.confirm-screen-cancel-button', { + onClick: (event) => { + clearSend() + this.cancel(event, txMeta) + }, + }, 'CANCEL'), + + // Accept Button + h('button.confirm-screen-confirm-button', ['CONFIRM']), + ]), + ]) + ) +} + +ConfirmSendEther.prototype.onSubmit = function (event) { + event.preventDefault() + const txMeta = this.gatherTxMeta() + const valid = this.checkValidity() + this.setState({ valid, submitting: true }) + + if (valid && this.verifyGasParams()) { + this.props.sendTransaction(txMeta, event) + } else { + this.props.dispatch(actions.displayWarning('Invalid Gas Parameters')) + this.setState({ submitting: false }) + } +} + +ConfirmSendEther.prototype.cancel = function (event, txMeta) { + event.preventDefault() + const { cancelTransaction } = this.props + + cancelTransaction(txMeta) +} + +ConfirmSendEther.prototype.checkValidity = function () { + const form = this.getFormEl() + const valid = form.checkValidity() + return valid +} + +ConfirmSendEther.prototype.getFormEl = function () { + const form = document.querySelector('form#pending-tx-form') + // Stub out form for unit tests: + if (!form) { + return { checkValidity () { return true } } + } + return form +} + +// After a customizable state value has been updated, +ConfirmSendEther.prototype.gatherTxMeta = function () { + const props = this.props + const state = this.state + const txData = clone(state.txData) || clone(props.txData) + + // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) + return txData +} + +ConfirmSendEther.prototype.verifyGasParams = function () { + // We call this in case the gas has not been modified at all + if (!this.state) { return true } + return ( + this._notZeroOrEmptyString(this.state.gas) && + this._notZeroOrEmptyString(this.state.gasPrice) + ) +} + +ConfirmSendEther.prototype._notZeroOrEmptyString = function (obj) { + return obj !== '' && obj !== '0x0' +} + +ConfirmSendEther.prototype.bnMultiplyByFraction = function (targetBN, numerator, denominator) { + const numBN = new BN(numerator) + const denomBN = new BN(denominator) + return targetBN.mul(numBN).div(denomBN) +} diff --git a/ui/app/components/pending-tx/confirm-send-token.js b/ui/app/components/pending-tx/confirm-send-token.js new file mode 100644 index 000000000..ad489c3e9 --- /dev/null +++ b/ui/app/components/pending-tx/confirm-send-token.js @@ -0,0 +1,464 @@ +const Component = require('react').Component +const { connect } = require('react-redux') +const h = require('react-hyperscript') +const inherits = require('util').inherits +const tokenAbi = require('human-standard-token-abi') +const abiDecoder = require('abi-decoder') +abiDecoder.addABI(tokenAbi) +const actions = require('../../actions') +const clone = require('clone') +const Identicon = require('../identicon') +const ethUtil = require('ethereumjs-util') +const BN = ethUtil.BN +const { + conversionUtil, + multiplyCurrencies, + addCurrencies, +} = require('../../conversion-util') +const { + calcTokenAmount, +} = require('../../token-util') + +const { MIN_GAS_PRICE_HEX } = require('../send/send-constants') + +const { + getTokenExchangeRate, + getSelectedAddress, + getSelectedTokenContract, +} = require('../../selectors') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ConfirmSendToken) + +function mapStateToProps (state, ownProps) { + const { token: { symbol }, txData } = ownProps + const { txParams } = txData || {} + const tokenData = txParams.data && abiDecoder.decodeMethod(txParams.data) + + const { + conversionRate, + identities, + currentCurrency, + } = state.metamask + const selectedAddress = getSelectedAddress(state) + const tokenExchangeRate = getTokenExchangeRate(state, symbol) + + return { + conversionRate, + identities, + selectedAddress, + tokenExchangeRate, + tokenData: tokenData || {}, + currentCurrency: currentCurrency.toUpperCase(), + send: state.metamask.send, + tokenContract: getSelectedTokenContract(state), + } +} + +function mapDispatchToProps (dispatch, ownProps) { + const { token: { symbol } } = ownProps + + return { + backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), + cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), + updateTokenExchangeRate: () => dispatch(actions.updateTokenExchangeRate(symbol)), + editTransaction: txMeta => { + const { token: { address } } = ownProps + const { txParams, id } = txMeta + const tokenData = txParams.data && abiDecoder.decodeMethod(txParams.data) + const { params = [] } = tokenData + const { value: to } = params[0] || {} + const { value: tokenAmountInDec } = params[1] || {} + const tokenAmountInHex = conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) + const { + gas: gasLimit, + gasPrice, + } = txParams + dispatch(actions.setSelectedToken(address)) + dispatch(actions.updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount: tokenAmountInHex, + errors: { to: null, amount: null }, + editingTransactionId: id, + })) + dispatch(actions.showSendTokenPage()) + }, + } +} + +inherits(ConfirmSendToken, Component) +function ConfirmSendToken () { + Component.call(this) + this.state = {} + this.onSubmit = this.onSubmit.bind(this) +} + +ConfirmSendToken.prototype.componentWillMount = function () { + const { tokenContract, selectedAddress } = this.props + tokenContract && tokenContract + .balanceOf(selectedAddress) + .then(usersToken => { + }) + this.props.updateTokenExchangeRate() +} + +ConfirmSendToken.prototype.getAmount = function () { + const { + conversionRate, + tokenExchangeRate, + token, + tokenData, + send: { amount, editingTransactionId }, + } = this.props + const { params = [] } = tokenData + let { value } = params[1] || {} + const { decimals } = token + + if (editingTransactionId) { + value = conversionUtil(amount, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + } + + const sendTokenAmount = calcTokenAmount(value, decimals) + + return { + fiat: tokenExchangeRate + ? +(sendTokenAmount * tokenExchangeRate * conversionRate).toFixed(2) + : null, + token: typeof value === 'undefined' + ? 'Unknown' + : +sendTokenAmount.toFixed(decimals), + } + +} + +ConfirmSendToken.prototype.getGasFee = function () { + const { conversionRate, tokenExchangeRate, token, currentCurrency } = this.props + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + const { decimals } = token + + const gas = txParams.gas + const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_HEX + const gasTotal = multiplyCurrencies(gas, gasPrice, { + multiplicandBase: 16, + multiplierBase: 16, + }) + + const FIAT = conversionUtil(gasTotal, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate, + }) + const ETH = conversionUtil(gasTotal, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency: 'ETH', + numberOfDecimals: 6, + conversionRate, + }) + const tokenGas = multiplyCurrencies(gas, gasPrice, { + toNumericBase: 'dec', + multiplicandBase: 16, + multiplierBase: 16, + toCurrency: 'BAT', + conversionRate: tokenExchangeRate, + invertConversionRate: true, + fromDenomination: 'WEI', + numberOfDecimals: decimals || 4, + }) + + return { + fiat: +Number(FIAT).toFixed(2), + eth: ETH, + token: tokenExchangeRate + ? tokenGas + : null, + } +} + +ConfirmSendToken.prototype.getData = function () { + const { identities, tokenData } = this.props + const { params = [] } = tokenData + const { value } = params[0] || {} + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + return { + from: { + address: txParams.from, + name: identities[txParams.from].name, + }, + to: { + address: value, + name: identities[value] ? identities[value].name : 'New Recipient', + }, + memo: txParams.memo || '', + } +} + +ConfirmSendToken.prototype.renderHeroAmount = function () { + const { token: { symbol }, currentCurrency } = this.props + const { fiat: fiatAmount, token: tokenAmount } = this.getAmount() + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + const { memo = '' } = txParams + + return fiatAmount + ? ( + h('div.confirm-send-token__hero-amount-wrapper', [ + h('h3.flex-center.confirm-screen-send-amount', `${fiatAmount}`), + h('h3.flex-center.confirm-screen-send-amount-currency', currentCurrency), + h('div.flex-center.confirm-memo-wrapper', [ + h('h3.confirm-screen-send-memo', [ memo ? `"${memo}"` : '' ]), + ]), + ]) + ) + : ( + h('div.confirm-send-token__hero-amount-wrapper', [ + h('h3.flex-center.confirm-screen-send-amount', tokenAmount), + h('h3.flex-center.confirm-screen-send-amount-currency', symbol), + h('div.flex-center.confirm-memo-wrapper', [ + h('h3.confirm-screen-send-memo', [ memo ? `"${memo}"` : '' ]), + ]), + ]) + ) +} + +ConfirmSendToken.prototype.renderGasFee = function () { + const { token: { symbol }, currentCurrency } = this.props + const { fiat: fiatGas, token: tokenGas, eth: ethGas } = this.getGasFee() + + return ( + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'Gas Fee' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', `${fiatGas} ${currentCurrency}`), + + h( + 'div.confirm-screen-row-detail', + tokenGas ? `${tokenGas} ${symbol}` : `${ethGas} ETH` + ), + ]), + ]) + ) +} + +ConfirmSendToken.prototype.renderTotalPlusGas = function () { + const { token: { symbol }, currentCurrency } = this.props + const { fiat: fiatAmount, token: tokenAmount } = this.getAmount() + const { fiat: fiatGas, token: tokenGas } = this.getGasFee() + + return fiatAmount && fiatGas + ? ( + h('section.flex-row.flex-center.confirm-screen-total-box ', [ + h('div.confirm-screen-section-column', [ + h('span.confirm-screen-label', [ 'Total ' ]), + h('div.confirm-screen-total-box__subtitle', [ 'Amount + Gas' ]), + ]), + + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', `${addCurrencies(fiatAmount, fiatGas)} ${currentCurrency}`), + h('div.confirm-screen-row-detail', `${addCurrencies(tokenAmount, tokenGas || '0')} ${symbol}`), + ]), + ]) + ) + : ( + h('section.flex-row.flex-center.confirm-screen-total-box ', [ + h('div.confirm-screen-section-column', [ + h('span.confirm-screen-label', [ 'Total ' ]), + h('div.confirm-screen-total-box__subtitle', [ 'Amount + Gas' ]), + ]), + + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', `${tokenAmount} ${symbol}`), + h('div.confirm-screen-row-detail', `+ ${fiatGas} ${currentCurrency} Gas`), + ]), + ]) + ) +} + +ConfirmSendToken.prototype.render = function () { + const { editTransaction } = this.props + const txMeta = this.gatherTxMeta() + const { + from: { + address: fromAddress, + name: fromName, + }, + to: { + address: toAddress, + name: toName, + }, + } = this.getData() + + this.inputs = [] + + return ( + h('div.confirm-screen-container.confirm-send-token', { + style: { minWidth: '355px' }, + }, [ + // Main Send token Card + h('div.confirm-screen-wrapper.flex-column.flex-grow', [ + h('h3.flex-center.confirm-screen-header', [ + h('button.btn-clear.confirm-screen-back-button', { + onClick: () => editTransaction(txMeta), + }, 'EDIT'), + h('div.confirm-screen-title', 'Confirm Transaction'), + h('div.confirm-screen-header-tip'), + ]), + h('div.flex-row.flex-center.confirm-screen-identicons', [ + h('div.confirm-screen-account-wrapper', [ + h( + Identicon, + { + address: fromAddress, + diameter: 60, + }, + ), + h('span.confirm-screen-account-name', fromName), + // h('span.confirm-screen-account-number', fromAddress.slice(fromAddress.length - 4)), + ]), + h('i.fa.fa-arrow-right.fa-lg'), + h('div.confirm-screen-account-wrapper', [ + h( + Identicon, + { + address: toAddress, + diameter: 60, + }, + ), + h('span.confirm-screen-account-name', toName), + // h('span.confirm-screen-account-number', toAddress.slice(toAddress.length - 4)), + ]), + ]), + + // h('h3.flex-center.confirm-screen-sending-to-message', { + // style: { + // textAlign: 'center', + // fontSize: '16px', + // }, + // }, [ + // `You're sending to Recipient ...${toAddress.slice(toAddress.length - 4)}`, + // ]), + + this.renderHeroAmount(), + + h('div.confirm-screen-rows', [ + h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'From' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', fromName), + h('div.confirm-screen-row-detail', `...${fromAddress.slice(fromAddress.length - 4)}`), + ]), + ]), + + toAddress && h('section.flex-row.flex-center.confirm-screen-row', [ + h('span.confirm-screen-label.confirm-screen-section-column', [ 'To' ]), + h('div.confirm-screen-section-column', [ + h('div.confirm-screen-row-info', toName), + h('div.confirm-screen-row-detail', `...${toAddress.slice(toAddress.length - 4)}`), + ]), + ]), + + this.renderGasFee(), + + this.renderTotalPlusGas(), + + ]), + ]), + + h('form#pending-tx-form', { + onSubmit: this.onSubmit, + }, [ + // Cancel Button + h('div.cancel.btn-light.confirm-screen-cancel-button', { + onClick: (event) => this.cancel(event, txMeta), + }, 'CANCEL'), + + // Accept Button + h('button.confirm-screen-confirm-button', ['CONFIRM']), + ]), + + + ]) + ) +} + +ConfirmSendToken.prototype.onSubmit = function (event) { + event.preventDefault() + const txMeta = this.gatherTxMeta() + const valid = this.checkValidity() + this.setState({ valid, submitting: true }) + + if (valid && this.verifyGasParams()) { + this.props.sendTransaction(txMeta, event) + } else { + this.props.dispatch(actions.displayWarning('Invalid Gas Parameters')) + this.setState({ submitting: false }) + } +} + +ConfirmSendToken.prototype.cancel = function (event, txMeta) { + event.preventDefault() + const { cancelTransaction } = this.props + + cancelTransaction(txMeta) +} + +ConfirmSendToken.prototype.checkValidity = function () { + const form = this.getFormEl() + const valid = form.checkValidity() + return valid +} + +ConfirmSendToken.prototype.getFormEl = function () { + const form = document.querySelector('form#pending-tx-form') + // Stub out form for unit tests: + if (!form) { + return { checkValidity () { return true } } + } + return form +} + +// After a customizable state value has been updated, +ConfirmSendToken.prototype.gatherTxMeta = function () { + const props = this.props + const state = this.state + const txData = clone(state.txData) || clone(props.txData) + + // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) + return txData +} + +ConfirmSendToken.prototype.verifyGasParams = function () { + // We call this in case the gas has not been modified at all + if (!this.state) { return true } + return ( + this._notZeroOrEmptyString(this.state.gas) && + this._notZeroOrEmptyString(this.state.gasPrice) + ) +} + +ConfirmSendToken.prototype._notZeroOrEmptyString = function (obj) { + return obj !== '' && obj !== '0x0' +} + +ConfirmSendToken.prototype.bnMultiplyByFraction = function (targetBN, numerator, denominator) { + const numBN = new BN(numerator) + const denomBN = new BN(denominator) + return targetBN.mul(numBN).div(denomBN) +} diff --git a/ui/app/components/pending-tx/index.js b/ui/app/components/pending-tx/index.js new file mode 100644 index 000000000..f4f6afb8f --- /dev/null +++ b/ui/app/components/pending-tx/index.js @@ -0,0 +1,145 @@ +const Component = require('react').Component +const { connect } = require('react-redux') +const h = require('react-hyperscript') +const clone = require('clone') +const abi = require('human-standard-token-abi') +const abiDecoder = require('abi-decoder') +abiDecoder.addABI(abi) +const inherits = require('util').inherits +const actions = require('../../actions') +const util = require('../../util') +const ConfirmSendEther = require('./confirm-send-ether') +const ConfirmSendToken = require('./confirm-send-token') +const ConfirmDeployContract = require('./confirm-deploy-contract') + +const TX_TYPES = { + DEPLOY_CONTRACT: 'deploy_contract', + SEND_ETHER: 'send_ether', + SEND_TOKEN: 'send_token', +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(PendingTx) + +function mapStateToProps (state) { + const { + conversionRate, + identities, + } = state.metamask + const accounts = state.metamask.accounts + const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] + return { + conversionRate, + identities, + selectedAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), + cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), + } +} + +inherits(PendingTx, Component) +function PendingTx () { + Component.call(this) + this.state = { + isFetching: true, + transactionType: '', + tokenAddress: '', + tokenSymbol: '', + tokenDecimals: '', + } +} + +PendingTx.prototype.componentWillMount = async function () { + const txMeta = this.gatherTxMeta() + const txParams = txMeta.txParams || {} + + if (!txParams.to) { + return this.setState({ + transactionType: TX_TYPES.DEPLOY_CONTRACT, + isFetching: false, + }) + } + + try { + const token = util.getContractAtAddress(txParams.to) + const results = await Promise.all([ + token.symbol(), + token.decimals(), + ]) + + const [ symbol, decimals ] = results + + if (symbol[0] && decimals[0]) { + this.setState({ + transactionType: TX_TYPES.SEND_TOKEN, + tokenAddress: txParams.to, + tokenSymbol: symbol[0], + tokenDecimals: decimals[0], + isFetching: false, + }) + } else { + this.setState({ + transactionType: TX_TYPES.SEND_ETHER, + isFetching: false, + }) + } + } catch (e) { + this.setState({ + transactionType: TX_TYPES.SEND_ETHER, + isFetching: false, + }) + } +} + +PendingTx.prototype.gatherTxMeta = function () { + const props = this.props + const state = this.state + const txData = clone(state.txData) || clone(props.txData) + + return txData +} + +PendingTx.prototype.render = function () { + const { + isFetching, + transactionType, + tokenAddress, + tokenSymbol, + tokenDecimals, + } = this.state + + const { sendTransaction } = this.props + + if (isFetching) { + return h('noscript') + } + + switch (transactionType) { + case TX_TYPES.SEND_ETHER: + return h(ConfirmSendEther, { + txData: this.gatherTxMeta(), + sendTransaction, + }) + case TX_TYPES.SEND_TOKEN: + return h(ConfirmSendToken, { + txData: this.gatherTxMeta(), + sendTransaction, + token: { + address: tokenAddress, + symbol: tokenSymbol, + decimals: tokenDecimals, + }, + }) + case TX_TYPES.DEPLOY_CONTRACT: + return h(ConfirmDeployContract, { + txData: this.gatherTxMeta(), + sendTransaction, + }) + default: + return h('noscript') + } +} diff --git a/ui/app/components/qr-code.js b/ui/app/components/qr-code.js index 06b9aed9b..83885539c 100644 --- a/ui/app/components/qr-code.js +++ b/ui/app/components/qr-code.js @@ -4,13 +4,13 @@ const qrCode = require('qrcode-npm').qrcode const inherits = require('util').inherits const connect = require('react-redux').connect const isHexPrefixed = require('ethereumjs-util').isHexPrefixed -const CopyButton = require('./copyButton') +const ReadOnlyInput = require('./readonly-input') module.exports = connect(mapStateToProps)(QrCodeView) function mapStateToProps (state) { return { - Qr: state.appState.Qr, + // Qr code is not fetched from state. 'message' and 'data' props are passed instead. buyView: state.appState.buyView, warning: state.appState.warning, } @@ -29,46 +29,29 @@ QrCodeView.prototype.render = function () { const qrImage = qrCode(4, 'M') qrImage.addData(address) qrImage.make() - return h('.main-container.flex-column', { - key: 'qr', - style: { - justifyContent: 'center', - paddingBottom: '45px', - paddingLeft: '45px', - paddingRight: '45px', - alignItems: 'center', - }, - }, [ - Array.isArray(Qr.message) ? h('.message-container', this.renderMultiMessage()) : h('.qr-header', Qr.message), + + return h('.div.flex-column.flex-center', [ + Array.isArray(Qr.message) + ? h('.message-container', this.renderMultiMessage()) + : Qr.message && h('.qr-header', Qr.message), this.props.warning ? this.props.warning && h('span.error.flex-center', { style: { - textAlign: 'center', - width: '229px', - height: '82px', }, }, this.props.warning) : null, - h('#qr-container.flex-column', { - style: { - marginTop: '25px', - marginBottom: '15px', - }, + h('.div.qr-wrapper', { + style: {}, dangerouslySetInnerHTML: { __html: qrImage.createTableTag(4), }, }), - h('.flex-row', [ - h('h3.ellip-address', { - style: { - width: '247px', - }, - }, Qr.data), - h(CopyButton, { - value: Qr.data, - }), - ]), + h(ReadOnlyInput, { + wrapperClass: 'ellip-address-wrapper', + inputClass: 'qr-ellip-address', + value: Qr.data, + }), ]) } diff --git a/ui/app/components/readonly-input.js b/ui/app/components/readonly-input.js new file mode 100644 index 000000000..fcf05fb9e --- /dev/null +++ b/ui/app/components/readonly-input.js @@ -0,0 +1,33 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits + +module.exports = ReadOnlyInput + +inherits(ReadOnlyInput, Component) +function ReadOnlyInput () { + Component.call(this) +} + +ReadOnlyInput.prototype.render = function () { + const { + wrapperClass = '', + inputClass = '', + value, + textarea, + onClick, + } = this.props + + const inputType = textarea ? 'textarea' : 'input' + + return h('div', {className: wrapperClass}, [ + h(inputType, { + className: inputClass, + value, + readOnly: true, + onFocus: event => event.target.select(), + onClick, + }), + ]) +} + diff --git a/ui/app/components/send-token/index.js b/ui/app/components/send-token/index.js new file mode 100644 index 000000000..99d078251 --- /dev/null +++ b/ui/app/components/send-token/index.js @@ -0,0 +1,439 @@ +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const classnames = require('classnames') +const abi = require('ethereumjs-abi') +const inherits = require('util').inherits +const actions = require('../../actions') +const selectors = require('../../selectors') +const { isValidAddress, allNull } = require('../../util') + +// const BalanceComponent = require('./balance-component') +const Identicon = require('../identicon') +const TokenBalance = require('../token-balance') +const CurrencyToggle = require('../send/currency-toggle') +const GasTooltip = require('../send/gas-tooltip') +const GasFeeDisplay = require('../send/gas-fee-display') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(SendTokenScreen) + +function mapStateToProps (state) { + // const sidebarOpen = state.appState.sidebarOpen + + const { warning } = state.appState + const identities = state.metamask.identities + const addressBook = state.metamask.addressBook + const conversionRate = state.metamask.conversionRate + const currentBlockGasLimit = state.metamask.currentBlockGasLimit + const accounts = state.metamask.accounts + const selectedTokenAddress = state.metamask.selectedTokenAddress + const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] + const selectedToken = selectors.getSelectedToken(state) + const tokenExchangeRates = state.metamask.tokenExchangeRates + const pair = `${selectedToken.symbol.toLowerCase()}_eth` + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} + + return { + selectedAddress, + selectedTokenAddress, + identities, + addressBook, + conversionRate, + tokenExchangeRate, + currentBlockGasLimit, + selectedToken, + warning, + } +} + +function mapDispatchToProps (dispatch) { + return { + backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), + hideWarning: () => dispatch(actions.hideWarning()), + addToAddressBook: (recipient, nickname) => dispatch( + actions.addToAddressBook(recipient, nickname) + ), + signTx: txParams => dispatch(actions.signTx(txParams)), + signTokenTx: (tokenAddress, toAddress, amount, txData) => ( + dispatch(actions.signTokenTx(tokenAddress, toAddress, amount, txData)) + ), + updateTokenExchangeRate: token => dispatch(actions.updateTokenExchangeRate(token)), + estimateGas: params => dispatch(actions.estimateGas(params)), + getGasPrice: () => dispatch(actions.getGasPrice()), + } +} + +inherits(SendTokenScreen, Component) +function SendTokenScreen () { + Component.call(this) + this.state = { + to: '', + amount: '0x0', + amountToSend: '0x0', + selectedCurrency: 'USD', + isGasTooltipOpen: false, + gasPrice: null, + gasLimit: null, + errors: {}, + } +} + +SendTokenScreen.prototype.componentWillMount = function () { + const { + updateTokenExchangeRate, + selectedToken: { symbol }, + getGasPrice, + estimateGas, + selectedAddress, + } = this.props + + updateTokenExchangeRate(symbol) + + const data = Array.prototype.map.call( + abi.rawEncode(['address', 'uint256'], [selectedAddress, '0x0']), + x => ('00' + x.toString(16)).slice(-2) + ).join('') + + console.log(data) + Promise.all([ + getGasPrice(), + estimateGas({ + from: selectedAddress, + value: '0x0', + gas: '746a528800', + data, + }), + ]) + .then(([blockGasPrice, estimatedGas]) => { + console.log({ blockGasPrice, estimatedGas}) + this.setState({ + gasPrice: blockGasPrice, + gasLimit: estimatedGas, + }) + }) +} + +SendTokenScreen.prototype.validate = function () { + const { + to, + amount: stringAmount, + gasPrice: hexGasPrice, + gasLimit: hexGasLimit, + } = this.state + + const gasPrice = parseInt(hexGasPrice, 16) + const gasLimit = parseInt(hexGasLimit, 16) / 1000000000 + const amount = Number(stringAmount) + + const errors = { + to: !to ? 'Required' : null, + amount: !amount ? 'Required' : null, + gasPrice: !gasPrice ? 'Gas Price Required' : null, + gasLimit: !gasLimit ? 'Gas Limit Required' : null, + } + + if (to && !isValidAddress(to)) { + errors.to = 'Invalid address' + } + + const isValid = Object.entries(errors).every(([key, value]) => value === null) + return { + isValid, + errors: isValid ? {} : errors, + } +} + +SendTokenScreen.prototype.setErrorsFor = function (field) { + const { errors: previousErrors } = this.state + + const { + isValid, + errors: newErrors, + } = this.validate() + + const nextErrors = Object.assign({}, previousErrors, { + [field]: newErrors[field] || null, + }) + + if (!isValid) { + this.setState({ + errors: nextErrors, + isValid, + }) + } +} + +SendTokenScreen.prototype.clearErrorsFor = function (field) { + const { errors: previousErrors } = this.state + const nextErrors = Object.assign({}, previousErrors, { + [field]: null, + }) + + this.setState({ + errors: nextErrors, + isValid: allNull(nextErrors), + }) +} + +SendTokenScreen.prototype.getAmountToSend = function (amount, selectedToken) { + const { decimals } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + const sendAmount = '0x' + Number(amount * multiplier).toString(16) + return sendAmount +} + +SendTokenScreen.prototype.submit = function () { + const { + to, + amount, + gasPrice, + gasLimit, + } = this.state + + const { + identities, + selectedAddress, + selectedTokenAddress, + hideWarning, + addToAddressBook, + signTokenTx, + selectedToken, + } = this.props + + const { nickname = ' ' } = identities[to] || {} + + hideWarning() + addToAddressBook(to, nickname) + + const txParams = { + from: selectedAddress, + value: '0', + gas: gasLimit, + gasPrice: gasPrice, + } + + const sendAmount = this.getAmountToSend(amount, selectedToken) + + signTokenTx(selectedTokenAddress, to, sendAmount, txParams) +} + +SendTokenScreen.prototype.renderToAddressInput = function () { + const { + identities, + addressBook, + } = this.props + + const { + to, + errors: { to: errorMessage }, + } = this.state + + return h('div', { + className: classnames('send-screen-input-wrapper', { + 'send-screen-input-wrapper--error': errorMessage, + }), + }, [ + h('div', ['To:']), + h('input.large-input.send-screen-input', { + name: 'address', + list: 'addresses', + placeholder: 'Address', + value: to, + onChange: e => this.setState({ + to: e.target.value, + errors: {}, + }), + onBlur: () => { + this.setErrorsFor('to') + }, + onFocus: event => { + if (to) event.target.select() + this.clearErrorsFor('to') + }, + }), + h('datalist#addresses', [ + // Corresponds to the addresses owned. + Object.entries(identities).map(([key, { address, name }]) => { + return h('option', { + value: address, + label: name, + key: address, + }) + }), + addressBook.map(({ address, name }) => { + return h('option', { + value: address, + label: name, + key: address, + }) + }), + ]), + h('div.send-screen-input-wrapper__error-message', [ errorMessage ]), + ]) +} + +SendTokenScreen.prototype.renderAmountInput = function () { + const { + selectedCurrency, + amount, + errors: { amount: errorMessage }, + } = this.state + + const { + tokenExchangeRate, + selectedToken: {symbol}, + } = this.props + + return h('div.send-screen-input-wrapper', { + className: classnames('send-screen-input-wrapper', { + 'send-screen-input-wrapper--error': errorMessage, + }), + }, [ + h('div.send-screen-amount-labels', [ + h('span', ['Amount']), + h(CurrencyToggle, { + currentCurrency: tokenExchangeRate ? selectedCurrency : 'USD', + currencies: tokenExchangeRate ? [ symbol, 'USD' ] : [], + onClick: currency => this.setState({ selectedCurrency: currency }), + }), + ]), + h('input.large-input.send-screen-input', { + placeholder: `0 ${symbol}`, + type: 'number', + value: amount, + onChange: e => this.setState({ + amount: e.target.value, + }), + onBlur: () => { + this.setErrorsFor('amount') + }, + onFocus: () => this.clearErrorsFor('amount'), + }), + h('div.send-screen-input-wrapper__error-message', [ errorMessage ]), + ]) +} + +SendTokenScreen.prototype.renderGasInput = function () { + const { + isGasTooltipOpen, + gasPrice, + gasLimit, + selectedCurrency, + errors: { + gasPrice: gasPriceErrorMessage, + gasLimit: gasLimitErrorMessage, + }, + } = this.state + + const { + conversionRate, + tokenExchangeRate, + currentBlockGasLimit, + } = this.props + + return h('div.send-screen-input-wrapper', { + className: classnames('send-screen-input-wrapper', { + 'send-screen-input-wrapper--error': gasPriceErrorMessage || gasLimitErrorMessage, + }), + }, [ + isGasTooltipOpen && h(GasTooltip, { + className: 'send-tooltip', + gasPrice: gasPrice || '0x0', + gasLimit: gasLimit || '0x0', + onClose: () => this.setState({ isGasTooltipOpen: false }), + onFeeChange: ({ gasLimit, gasPrice }) => { + this.setState({ gasLimit, gasPrice, errors: {} }) + }, + onBlur: () => { + this.setErrorsFor('gasLimit') + this.setErrorsFor('gasPrice') + }, + onFocus: () => { + this.clearErrorsFor('gasLimit') + this.clearErrorsFor('gasPrice') + }, + }), + + h('div.send-screen-gas-labels', {}, [ + h('span', [ h('i.fa.fa-bolt'), 'Gas fee:']), + h('span', ['What\'s this?']), + ]), + h('div.large-input.send-screen-gas-input', [ + h(GasFeeDisplay, { + conversionRate, + tokenExchangeRate, + gasPrice: gasPrice || '0x0', + activeCurrency: selectedCurrency, + gas: gasLimit || '0x0', + blockGasLimit: currentBlockGasLimit, + }), + h( + 'div.send-screen-gas-input-customize', + { onClick: () => this.setState({ isGasTooltipOpen: !isGasTooltipOpen }) }, + ['Customize'] + ), + ]), + h('div.send-screen-input-wrapper__error-message', [ + gasPriceErrorMessage || gasLimitErrorMessage, + ]), + ]) +} + +SendTokenScreen.prototype.renderMemoInput = function () { + return h('div.send-screen-input-wrapper', [ + h('div', {}, ['Transaction memo (optional)']), + h( + 'input.large-input.send-screen-input', + { onChange: e => this.setState({ memo: e.target.value }) } + ), + ]) +} + +SendTokenScreen.prototype.renderButtons = function () { + const { selectedAddress, backToAccountDetail } = this.props + const { isValid } = this.validate() + + return h('div.send-token__button-group', [ + h('button.send-token__button-next.btn-secondary', { + className: !isValid && 'send-screen__send-button__disabled', + onClick: () => isValid && this.submit(), + }, ['Next']), + h('button.send-token__button-cancel.btn-tertiary', { + onClick: () => backToAccountDetail(selectedAddress), + }, ['Cancel']), + ]) +} + +SendTokenScreen.prototype.render = function () { + const { + selectedTokenAddress, + selectedToken, + warning, + } = this.props + + return h('div.send-token', [ + h('div.send-token__content', [ + h(Identicon, { + diameter: 75, + address: selectedTokenAddress, + }), + h('div.send-token__title', ['Send Tokens']), + h('div.send-token__description', ['Send Tokens to anyone with an Ethereum account']), + h('div.send-token__balance-text', ['Your Token Balance is:']), + h('div.send-token__token-balance', [ + h(TokenBalance, { token: selectedToken, balanceOnly: true }), + ]), + h('div.send-token__token-symbol', [selectedToken.symbol]), + this.renderToAddressInput(), + this.renderAmountInput(), + this.renderGasInput(), + this.renderMemoInput(), + warning && h('div.send-screen-input-wrapper--error', {}, + h('div.send-screen-input-wrapper__error-message', [ + warning, + ]) + ), + ]), + this.renderButtons(), + ]) +} diff --git a/ui/app/components/send/account-list-item.js b/ui/app/components/send/account-list-item.js new file mode 100644 index 000000000..1ad3f69c1 --- /dev/null +++ b/ui/app/components/send/account-list-item.js @@ -0,0 +1,73 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const Identicon = require('../identicon') +const CurrencyDisplay = require('./currency-display') +const { conversionRateSelector, getCurrentCurrency } = require('../../selectors') + +inherits(AccountListItem, Component) +function AccountListItem () { + Component.call(this) +} + +function mapStateToProps (state) { + return { + conversionRate: conversionRateSelector(state), + currentCurrency: getCurrentCurrency(state), + } +} + +module.exports = connect(mapStateToProps)(AccountListItem) + +AccountListItem.prototype.render = function () { + const { + className, + account, + handleClick, + icon = null, + conversionRate, + currentCurrency, + displayBalance = true, + displayAddress = false, + } = this.props + + const { name, address, balance } = account || {} + + return h('div.account-list-item', { + className, + onClick: () => handleClick({ name, address, balance }), + }, [ + + h('div.account-list-item__top-row', {}, [ + + h( + Identicon, + { + address, + diameter: 18, + className: 'account-list-item__identicon', + }, + ), + + h('div.account-list-item__account-name', {}, name || address), + + icon && h('div.account-list-item__icon', [icon]), + + ]), + + displayAddress && name && h('div.account-list-item__account-address', address), + + displayBalance && h(CurrencyDisplay, { + primaryCurrency: 'ETH', + convertedCurrency: currentCurrency, + value: balance, + conversionRate, + readOnly: true, + className: 'account-list-item__account-balances', + primaryBalanceClassName: 'account-list-item__account-primary-balance', + convertedBalanceClassName: 'account-list-item__account-secondary-balance', + }, name), + + ]) +} diff --git a/ui/app/components/send/currency-display.js b/ui/app/components/send/currency-display.js new file mode 100644 index 000000000..819fee0a0 --- /dev/null +++ b/ui/app/components/send/currency-display.js @@ -0,0 +1,116 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const CurrencyInput = require('../currency-input') +const { conversionUtil, multiplyCurrencies } = require('../../conversion-util') + +module.exports = CurrencyDisplay + +inherits(CurrencyDisplay, Component) +function CurrencyDisplay () { + Component.call(this) +} + +function toHexWei (value) { + return conversionUtil(value, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + toDenomination: 'WEI', + }) +} + +CurrencyDisplay.prototype.getAmount = function (value) { + const { selectedToken } = this.props + const { decimals } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + + const sendAmount = multiplyCurrencies(value, multiplier, {toNumericBase: 'hex'}) + + return selectedToken + ? sendAmount + : toHexWei(value) +} + +CurrencyDisplay.prototype.getValueToRender = function () { + const { selectedToken, conversionRate, value } = this.props + + const { decimals, symbol } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + + return selectedToken + ? conversionUtil(value, { + fromNumericBase: 'hex', + toCurrency: symbol, + conversionRate: multiplier, + invertConversionRate: true, + }) + : conversionUtil(value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + numberOfDecimals: 6, + conversionRate, + }) +} + +CurrencyDisplay.prototype.render = function () { + const { + className = 'currency-display', + primaryBalanceClassName = 'currency-display__input', + convertedBalanceClassName = 'currency-display__converted-value', + conversionRate, + primaryCurrency, + convertedCurrency, + readOnly = false, + inError = false, + handleChange, + } = this.props + + const valueToRender = this.getValueToRender() + + let convertedValue = conversionUtil(valueToRender, { + fromNumericBase: 'dec', + fromCurrency: primaryCurrency, + toCurrency: convertedCurrency, + numberOfDecimals: 2, + conversionRate, + }) + convertedValue = Number(convertedValue).toFixed(2) + + return h('div', { + className, + style: { + borderColor: inError ? 'red' : null, + }, + onClick: () => this.currencyInput.focus(), + }, [ + + h('div.currency-display__primary-row', [ + + h('div.currency-display__input-wrapper', [ + + h(CurrencyInput, { + className: primaryBalanceClassName, + value: `${valueToRender}`, + placeholder: '0', + readOnly, + onInputChange: newValue => { + handleChange(this.getAmount(newValue)) + }, + inputRef: input => { this.currencyInput = input }, + }), + + h('span.currency-display__currency-symbol', primaryCurrency), + + ]), + + ]), + + h('div', { + className: convertedBalanceClassName, + }, `${convertedValue} ${convertedCurrency.toUpperCase()}`), + + ]) + +} + diff --git a/ui/app/components/send/currency-toggle.js b/ui/app/components/send/currency-toggle.js new file mode 100644 index 000000000..7aaccd490 --- /dev/null +++ b/ui/app/components/send/currency-toggle.js @@ -0,0 +1,44 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const classnames = require('classnames') + +module.exports = CurrencyToggle + +inherits(CurrencyToggle, Component) +function CurrencyToggle () { + Component.call(this) +} + +const defaultCurrencies = [ 'ETH', 'USD' ] + +CurrencyToggle.prototype.renderToggles = function () { + const { onClick, activeCurrency } = this.props + const [currencyA, currencyB] = this.props.currencies || defaultCurrencies + + return [ + h('span', { + className: classnames('currency-toggle__item', { + 'currency-toggle__item--selected': currencyA === activeCurrency, + }), + onClick: () => onClick(currencyA), + }, [ currencyA ]), + '<>', + h('span', { + className: classnames('currency-toggle__item', { + 'currency-toggle__item--selected': currencyB === activeCurrency, + }), + onClick: () => onClick(currencyB), + }, [ currencyB ]), + ] +} + +CurrencyToggle.prototype.render = function () { + const currencies = this.props.currencies || defaultCurrencies + + return h('span.currency-toggle', currencies.length + ? this.renderToggles() + : [] + ) +} + diff --git a/ui/app/components/send/eth-fee-display.js b/ui/app/components/send/eth-fee-display.js new file mode 100644 index 000000000..9eda5ec62 --- /dev/null +++ b/ui/app/components/send/eth-fee-display.js @@ -0,0 +1,37 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const EthBalance = require('../eth-balance') +const { getTxFeeBn } = require('../../util') + +module.exports = EthFeeDisplay + +inherits(EthFeeDisplay, Component) +function EthFeeDisplay () { + Component.call(this) +} + +EthFeeDisplay.prototype.render = function () { + const { + activeCurrency, + conversionRate, + gas, + gasPrice, + blockGasLimit, + } = this.props + + return h(EthBalance, { + value: getTxFeeBn(gas, gasPrice, blockGasLimit), + currentCurrency: activeCurrency, + conversionRate, + showFiat: false, + hideTooltip: true, + styleOveride: { + color: '#5d5d5d', + fontSize: '16px', + fontFamily: 'DIN OT', + lineHeight: '22.4px', + }, + }) +} + diff --git a/ui/app/components/send/from-dropdown.js b/ui/app/components/send/from-dropdown.js new file mode 100644 index 000000000..0686fbe73 --- /dev/null +++ b/ui/app/components/send/from-dropdown.js @@ -0,0 +1,72 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const AccountListItem = require('./account-list-item') + +module.exports = FromDropdown + +inherits(FromDropdown, Component) +function FromDropdown () { + Component.call(this) +} + +FromDropdown.prototype.getListItemIcon = function (currentAccount, selectedAccount) { + const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } }) + + return currentAccount.address === selectedAccount.address + ? listItemIcon + : null +} + +FromDropdown.prototype.renderDropdown = function () { + const { + accounts, + selectedAccount, + closeDropdown, + onSelect, + } = this.props + + return h('div', {}, [ + + h('div.send-v2__from-dropdown__close-area', { + onClick: closeDropdown, + }), + + h('div.send-v2__from-dropdown__list', {}, [ + + ...accounts.map(account => h(AccountListItem, { + className: 'account-list-item__dropdown', + account, + handleClick: () => { + onSelect(account) + closeDropdown() + }, + icon: this.getListItemIcon(account, selectedAccount), + })), + + ]), + + ]) +} + +FromDropdown.prototype.render = function () { + const { + selectedAccount, + openDropdown, + dropdownOpen, + } = this.props + + return h('div.send-v2__from-dropdown', {}, [ + + h(AccountListItem, { + account: selectedAccount, + handleClick: openDropdown, + icon: h(`i.fa.fa-caret-down.fa-lg`, { style: { color: '#dedede' } }), + }), + + dropdownOpen && this.renderDropdown(), + + ]) + +} + diff --git a/ui/app/components/send/gas-fee-display-v2.js b/ui/app/components/send/gas-fee-display-v2.js new file mode 100644 index 000000000..0c4c3f7a9 --- /dev/null +++ b/ui/app/components/send/gas-fee-display-v2.js @@ -0,0 +1,44 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const CurrencyDisplay = require('./currency-display') + +module.exports = GasFeeDisplay + +inherits(GasFeeDisplay, Component) +function GasFeeDisplay () { + Component.call(this) +} + +GasFeeDisplay.prototype.render = function () { + const { + conversionRate, + gasTotal, + onClick, + primaryCurrency = 'ETH', + convertedCurrency, + } = this.props + + return h('div.send-v2__gas-fee-display', [ + + gasTotal + ? h(CurrencyDisplay, { + primaryCurrency, + convertedCurrency, + value: gasTotal, + conversionRate, + convertedPrefix: '$', + readOnly: true, + }) + : h('div.currency-display', 'Loading...'), + + h('button.send-v2__sliders-icon-container', { + onClick, + disabled: !gasTotal, + }, [ + h('i.fa.fa-sliders.send-v2__sliders-icon'), + ]), + + ]) +} + diff --git a/ui/app/components/send/gas-fee-display.js b/ui/app/components/send/gas-fee-display.js new file mode 100644 index 000000000..a9a3f3f49 --- /dev/null +++ b/ui/app/components/send/gas-fee-display.js @@ -0,0 +1,62 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const USDFeeDisplay = require('./usd-fee-display') +const EthFeeDisplay = require('./eth-fee-display') +const { getTxFeeBn, formatBalance, shortenBalance } = require('../../util') + +module.exports = GasFeeDisplay + +inherits(GasFeeDisplay, Component) +function GasFeeDisplay () { + Component.call(this) +} + +GasFeeDisplay.prototype.getTokenValue = function () { + const { + tokenExchangeRate, + gas, + gasPrice, + blockGasLimit, + } = this.props + + const value = formatBalance(getTxFeeBn(gas, gasPrice, blockGasLimit), 6, true) + const [ethNumber] = value.split(' ') + + return shortenBalance(Number(ethNumber) / tokenExchangeRate, 6) +} + +GasFeeDisplay.prototype.render = function () { + const { + activeCurrency, + conversionRate, + gas, + gasPrice, + blockGasLimit, + } = this.props + + switch (activeCurrency) { + case 'USD': + return h(USDFeeDisplay, { + activeCurrency, + conversionRate, + gas, + gasPrice, + blockGasLimit, + }) + case 'ETH': + return h(EthFeeDisplay, { + activeCurrency, + conversionRate, + gas, + gasPrice, + blockGasLimit, + }) + default: + return h('div.token-gas', [ + h('div.token-gas__amount', this.getTokenValue()), + h('div.token-gas__symbol', activeCurrency), + ]) + } +} + diff --git a/ui/app/components/send/gas-tooltip.js b/ui/app/components/send/gas-tooltip.js new file mode 100644 index 000000000..46aff3499 --- /dev/null +++ b/ui/app/components/send/gas-tooltip.js @@ -0,0 +1,100 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const InputNumber = require('../input-number.js') + +module.exports = GasTooltip + +inherits(GasTooltip, Component) +function GasTooltip () { + Component.call(this) + this.state = { + gasLimit: 0, + gasPrice: 0, + } + + this.updateGasPrice = this.updateGasPrice.bind(this) + this.updateGasLimit = this.updateGasLimit.bind(this) + this.onClose = this.onClose.bind(this) +} + +GasTooltip.prototype.componentWillMount = function () { + const { gasPrice = 0, gasLimit = 0} = this.props + + this.setState({ + gasPrice: parseInt(gasPrice, 16) / 1000000000, + gasLimit: parseInt(gasLimit, 16), + }) +} + +GasTooltip.prototype.updateGasPrice = function (newPrice) { + const { onFeeChange } = this.props + const { gasLimit } = this.state + + this.setState({ gasPrice: newPrice }) + onFeeChange({ + gasLimit: gasLimit.toString(16), + gasPrice: (newPrice * 1000000000).toString(16), + }) +} + +GasTooltip.prototype.updateGasLimit = function (newLimit) { + const { onFeeChange } = this.props + const { gasPrice } = this.state + + this.setState({ gasLimit: newLimit }) + onFeeChange({ + gasLimit: newLimit.toString(16), + gasPrice: (gasPrice * 1000000000).toString(16), + }) +} + +GasTooltip.prototype.onClose = function (e) { + e.stopPropagation() + this.props.onClose() +} + +GasTooltip.prototype.render = function () { + const { gasPrice, gasLimit } = this.state + + return h('div.gas-tooltip', {}, [ + h('div.gas-tooltip-close-area', { + onClick: this.onClose, + }), + h('div.customize-gas-tooltip-container', {}, [ + h('div.customize-gas-tooltip', {}, [ + h('div.gas-tooltip-header.gas-tooltip-label', {}, ['Customize Gas']), + h('div.gas-tooltip-input-label', {}, [ + h('span.gas-tooltip-label', {}, ['Gas Price']), + h('i.fa.fa-info-circle'), + ]), + h(InputNumber, { + unitLabel: 'GWEI', + step: 1, + min: 0, + placeholder: '0', + value: gasPrice, + onChange: (newPrice) => this.updateGasPrice(newPrice), + }), + h('div.gas-tooltip-input-label', { + style: { + 'marginTop': '81px', + }, + }, [ + h('span.gas-tooltip-label', {}, ['Gas Limit']), + h('i.fa.fa-info-circle'), + ]), + h(InputNumber, { + unitLabel: 'UNITS', + step: 1, + min: 0, + placeholder: '0', + value: gasLimit, + onChange: (newLimit) => this.updateGasLimit(newLimit), + }), + ]), + h('div.gas-tooltip-arrow', {}), + ]), + ]) +} + diff --git a/ui/app/components/send/memo-textarea.js b/ui/app/components/send/memo-textarea.js new file mode 100644 index 000000000..f4bb24bf8 --- /dev/null +++ b/ui/app/components/send/memo-textarea.js @@ -0,0 +1,33 @@ +// const Component = require('react').Component +// const h = require('react-hyperscript') +// const inherits = require('util').inherits +// const Identicon = require('../identicon') + +// module.exports = MemoTextArea + +// inherits(MemoTextArea, Component) +// function MemoTextArea () { +// Component.call(this) +// } + +// MemoTextArea.prototype.render = function () { +// const { memo, identities, onChange } = this.props + +// return h('div.send-v2__memo-text-area', [ + +// h('textarea.send-v2__memo-text-area__input', { +// placeholder: 'Optional', +// value: memo, +// onChange, +// // onBlur: () => { +// // this.setErrorsFor('memo') +// // }, +// onFocus: event => { +// // this.clearErrorsFor('memo') +// }, +// }), + +// ]) + +// } + diff --git a/ui/app/components/send/send-constants.js b/ui/app/components/send/send-constants.js new file mode 100644 index 000000000..b3ee0899a --- /dev/null +++ b/ui/app/components/send/send-constants.js @@ -0,0 +1,33 @@ +const ethUtil = require('ethereumjs-util') +const { conversionUtil, multiplyCurrencies } = require('../../conversion-util') + +const MIN_GAS_PRICE_HEX = (100000000).toString(16) +const MIN_GAS_PRICE_DEC = '100000000' +const MIN_GAS_LIMIT_DEC = '21000' +const MIN_GAS_LIMIT_HEX = (parseInt(MIN_GAS_LIMIT_DEC)).toString(16) + +const MIN_GAS_PRICE_GWEI = ethUtil.addHexPrefix(conversionUtil(MIN_GAS_PRICE_HEX, { + fromDenomination: 'WEI', + toDenomination: 'GWEI', + fromNumericBase: 'hex', + toNumericBase: 'hex', + numberOfDecimals: 1, +})) + +const MIN_GAS_TOTAL = multiplyCurrencies(MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_HEX, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, +}) + +const TOKEN_TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb' + +module.exports = { + MIN_GAS_PRICE_GWEI, + MIN_GAS_PRICE_HEX, + MIN_GAS_PRICE_DEC, + MIN_GAS_LIMIT_HEX, + MIN_GAS_LIMIT_DEC, + MIN_GAS_TOTAL, + TOKEN_TRANSFER_FUNCTION_SIGNATURE, +} diff --git a/ui/app/components/send/send-utils.js b/ui/app/components/send/send-utils.js new file mode 100644 index 000000000..d8211930d --- /dev/null +++ b/ui/app/components/send/send-utils.js @@ -0,0 +1,68 @@ +const { + addCurrencies, + conversionUtil, + conversionGTE, +} = require('../../conversion-util') +const { + calcTokenAmount, +} = require('../../token-util') + +function isBalanceSufficient ({ + amount = '0x0', + gasTotal = '0x0', + balance, + primaryCurrency, + amountConversionRate, + conversionRate, +}) { + const totalAmount = addCurrencies(amount, gasTotal, { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + + const balanceIsSufficient = conversionGTE( + { + value: balance, + fromNumericBase: 'hex', + fromCurrency: primaryCurrency, + conversionRate, + }, + { + value: totalAmount, + fromNumericBase: 'hex', + conversionRate: amountConversionRate, + fromCurrency: primaryCurrency, + }, + ) + + return balanceIsSufficient +} + +function isTokenBalanceSufficient ({ + amount = '0x0', + tokenBalance, + decimals, +}) { + const amountInDec = conversionUtil(amount, { + fromNumericBase: 'hex', + }) + + const tokenBalanceIsSufficient = conversionGTE( + { + value: tokenBalance, + fromNumericBase: 'dec', + }, + { + value: calcTokenAmount(amountInDec, decimals), + fromNumericBase: 'dec', + }, + ) + + return tokenBalanceIsSufficient +} + +module.exports = { + isBalanceSufficient, + isTokenBalanceSufficient, +} diff --git a/ui/app/components/send/send-v2-container.js b/ui/app/components/send/send-v2-container.js new file mode 100644 index 000000000..1106902b7 --- /dev/null +++ b/ui/app/components/send/send-v2-container.js @@ -0,0 +1,85 @@ +const connect = require('react-redux').connect +const actions = require('../../actions') +const abi = require('ethereumjs-abi') +const SendEther = require('../../send-v2') + +const { + accountsWithSendEtherInfoSelector, + getCurrentAccountWithSendEtherInfo, + conversionRateSelector, + getSelectedToken, + getSelectedAddress, + getAddressBook, + getSendFrom, + getCurrentCurrency, + getSelectedTokenToFiatRate, + getSelectedTokenContract, +} = require('../../selectors') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(SendEther) + +function mapStateToProps (state) { + const fromAccounts = accountsWithSendEtherInfoSelector(state) + const selectedAddress = getSelectedAddress(state) + const selectedToken = getSelectedToken(state) + const conversionRate = conversionRateSelector(state) + + let data + let primaryCurrency + let tokenToFiatRate + if (selectedToken) { + data = Array.prototype.map.call( + abi.rawEncode(['address', 'uint256'], [selectedAddress, '0x0']), + x => ('00' + x.toString(16)).slice(-2) + ).join('') + + primaryCurrency = selectedToken.symbol + + tokenToFiatRate = getSelectedTokenToFiatRate(state) + } + + return { + ...state.metamask.send, + from: getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state), + fromAccounts, + toAccounts: [...fromAccounts, ...getAddressBook(state)], + conversionRate, + selectedToken, + primaryCurrency, + convertedCurrency: getCurrentCurrency(state), + data, + amountConversionRate: selectedToken ? tokenToFiatRate : conversionRate, + tokenContract: getSelectedTokenContract(state), + unapprovedTxs: state.metamask.unapprovedTxs, + network: state.metamask.network, + } +} + +function mapDispatchToProps (dispatch) { + return { + showCustomizeGasModal: () => dispatch(actions.showModal({ name: 'CUSTOMIZE_GAS' })), + estimateGas: params => dispatch(actions.estimateGas(params)), + getGasPrice: () => dispatch(actions.getGasPrice()), + updateTokenExchangeRate: token => dispatch(actions.updateTokenExchangeRate(token)), + signTokenTx: (tokenAddress, toAddress, amount, txData) => ( + dispatch(actions.signTokenTx(tokenAddress, toAddress, amount, txData)) + ), + signTx: txParams => dispatch(actions.signTx(txParams)), + updateAndApproveTx: txParams => dispatch(actions.updateAndApproveTx(txParams)), + updateTx: txData => dispatch(actions.updateTransaction(txData)), + setSelectedAddress: address => dispatch(actions.setSelectedAddress(address)), + addToAddressBook: address => dispatch(actions.addToAddressBook(address)), + updateGasTotal: newTotal => dispatch(actions.updateGasTotal(newTotal)), + updateGasPrice: newGasPrice => dispatch(actions.updateGasPrice(newGasPrice)), + updateGasLimit: newGasLimit => dispatch(actions.updateGasLimit(newGasLimit)), + updateSendTokenBalance: tokenBalance => dispatch(actions.updateSendTokenBalance(tokenBalance)), + updateSendFrom: newFrom => dispatch(actions.updateSendFrom(newFrom)), + updateSendTo: newTo => dispatch(actions.updateSendTo(newTo)), + updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)), + updateSendMemo: newMemo => dispatch(actions.updateSendMemo(newMemo)), + updateSendErrors: newError => dispatch(actions.updateSendErrors(newError)), + goHome: () => dispatch(actions.goHome()), + clearSend: () => dispatch(actions.clearSend()), + setMaxModeTo: bool => dispatch(actions.setMaxModeTo(bool)), + } +} diff --git a/ui/app/components/send/to-autocomplete.js b/ui/app/components/send/to-autocomplete.js new file mode 100644 index 000000000..e0cdd0a58 --- /dev/null +++ b/ui/app/components/send/to-autocomplete.js @@ -0,0 +1,114 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const AccountListItem = require('./account-list-item') + +module.exports = ToAutoComplete + +inherits(ToAutoComplete, Component) +function ToAutoComplete () { + Component.call(this) + + this.state = { accountsToRender: [] } +} + +ToAutoComplete.prototype.getListItemIcon = function (listItemAddress, toAddress) { + const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } }) + + return toAddress && listItemAddress === toAddress + ? listItemIcon + : null +} + +ToAutoComplete.prototype.renderDropdown = function () { + const { + closeDropdown, + onChange, + to, + } = this.props + const { accountsToRender } = this.state + + return accountsToRender.length && h('div', {}, [ + + h('div.send-v2__from-dropdown__close-area', { + onClick: closeDropdown, + }), + + h('div.send-v2__from-dropdown__list', {}, [ + + ...accountsToRender.map(account => h(AccountListItem, { + account, + className: 'account-list-item__dropdown', + handleClick: () => { + onChange(account.address) + closeDropdown() + }, + icon: this.getListItemIcon(account.address, to), + displayBalance: false, + displayAddress: true, + })), + + ]), + + ]) +} + +ToAutoComplete.prototype.handleInputEvent = function (event = {}, cb) { + const { + to, + accounts, + closeDropdown, + openDropdown, + } = this.props + + const matchingAccounts = accounts.filter(({ address }) => address.match(to || '')) + const matches = matchingAccounts.length + + if (!matches || matchingAccounts[0].address === to) { + this.setState({ accountsToRender: [] }) + event.target && event.target.select() + closeDropdown() + } else { + this.setState({ accountsToRender: matchingAccounts }) + openDropdown() + } + cb && cb(event.target.value) +} + +ToAutoComplete.prototype.componentDidUpdate = function (nextProps, nextState) { + if (this.props.to !== nextProps.to) { + this.handleInputEvent() + } +} + +ToAutoComplete.prototype.render = function () { + const { + to, + dropdownOpen, + onChange, + inError, + } = this.props + + return h('div.send-v2__to-autocomplete', {}, [ + + h('input.send-v2__to-autocomplete__input', { + placeholder: 'Recipient Address', + className: inError ? `send-v2__error-border` : '', + value: to, + onChange: event => onChange(event.target.value), + onFocus: event => this.handleInputEvent(event), + style: { + borderColor: inError ? 'red' : null, + }, + }), + + !to && h(`i.fa.fa-caret-down.fa-lg.send-v2__to-autocomplete__down-caret`, { + style: { color: '#dedede' }, + onClick: () => this.handleInputEvent(), + }), + + dropdownOpen && this.renderDropdown(), + + ]) +} + diff --git a/ui/app/components/send/usd-fee-display.js b/ui/app/components/send/usd-fee-display.js new file mode 100644 index 000000000..4cf31a493 --- /dev/null +++ b/ui/app/components/send/usd-fee-display.js @@ -0,0 +1,35 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const FiatValue = require('../fiat-value') +const { getTxFeeBn } = require('../../util') + +module.exports = USDFeeDisplay + +inherits(USDFeeDisplay, Component) +function USDFeeDisplay () { + Component.call(this) +} + +USDFeeDisplay.prototype.render = function () { + const { + activeCurrency, + conversionRate, + gas, + gasPrice, + blockGasLimit, + } = this.props + + return h(FiatValue, { + value: getTxFeeBn(gas, gasPrice, blockGasLimit), + conversionRate, + currentCurrency: activeCurrency, + style: { + color: '#5d5d5d', + fontSize: '16px', + fontFamily: 'DIN OT', + lineHeight: '22.4px', + }, + }) +} + diff --git a/ui/app/components/shapeshift-form.js b/ui/app/components/shapeshift-form.js index c5993e3d3..2270b8236 100644 --- a/ui/app/components/shapeshift-form.js +++ b/ui/app/components/shapeshift-form.js @@ -1,308 +1,242 @@ -const PersistentForm = require('../../lib/persistent-form') const h = require('react-hyperscript') const inherits = require('util').inherits +const Component = require('react').Component const connect = require('react-redux').connect -const actions = require('../actions') -const Qr = require('./qr-code') -const isValidAddress = require('../util').isValidAddress -module.exports = connect(mapStateToProps)(ShapeshiftForm) +const classnames = require('classnames') +const { qrcode } = require('qrcode-npm') +const { shapeShiftSubview, pairUpdate, buyWithShapeShift } = require('../actions') +const { isValidAddress } = require('../util') +const SimpleDropdown = require('./dropdowns/simple-dropdown') function mapStateToProps (state) { + const { + coinOptions, + tokenExchangeRates, + selectedAddress, + } = state.metamask + return { - warning: state.appState.warning, - isSubLoading: state.appState.isSubLoading, - qrRequested: state.appState.qrRequested, + coinOptions, + tokenExchangeRates, + selectedAddress, } } -inherits(ShapeshiftForm, PersistentForm) +function mapDispatchToProps (dispatch) { + return { + shapeShiftSubview: () => dispatch(shapeShiftSubview()), + pairUpdate: coin => dispatch(pairUpdate(coin)), + buyWithShapeShift: data => dispatch(buyWithShapeShift(data)), + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ShapeshiftForm) +inherits(ShapeshiftForm, Component) function ShapeshiftForm () { - PersistentForm.call(this) - this.persistentFormParentId = 'shapeshift-buy-form' + Component.call(this) + + this.state = { + depositCoin: 'btc', + refundAddress: '', + showQrCode: false, + depositAddress: '', + errorMessage: '', + isLoading: false, + bought: false, + } } -ShapeshiftForm.prototype.render = function () { - return this.props.qrRequested ? h(Qr, {key: 'qr'}) : this.renderMain() +ShapeshiftForm.prototype.componentWillMount = function () { + this.props.shapeShiftSubview() } -ShapeshiftForm.prototype.renderMain = function () { - const marketinfo = this.props.buyView.formView.marketinfo - const coinOptions = this.props.buyView.formView.coinOptions - var coin = marketinfo.pair.split('_')[0].toUpperCase() - - return h('.flex-column', { - style: { - position: 'relative', - padding: '25px', - paddingTop: '5px', - width: '90%', - minHeight: '215px', - alignItems: 'center', - overflowY: 'auto', - }, - }, [ - h('.flex-row', { - style: { - justifyContent: 'center', - alignItems: 'baseline', - height: '42px', - }, - }, [ - h('img', { - src: coinOptions[coin].image, - width: '25px', - height: '25px', - style: { - marginRight: '5px', - }, - }), - - h('.input-container', { - position: 'relative', - }, [ - h('input#fromCoin.buy-inputs.ex-coins', { - type: 'text', - list: 'coinList', - autoFocus: true, - dataset: { - persistentFormId: 'input-coin', - }, - style: { - boxSizing: 'border-box', - }, - onChange: this.handleLiveInput.bind(this), - defaultValue: 'BTC', - }), +ShapeshiftForm.prototype.onCoinChange = function (e) { + const coin = e.target.value + this.setState({ + depositCoin: coin, + errorMessage: '', + }) + this.props.pairUpdate(coin) +} - this.renderCoinList(), +ShapeshiftForm.prototype.onBuyWithShapeShift = function () { + this.setState({ + isLoading: true, + showQrCode: true, + }) - h('i.fa.fa-pencil-square-o.edit-text', { - style: { - fontSize: '12px', - color: '#F7861C', - position: 'absolute', - }, - }), - ]), + const { + buyWithShapeShift, + selectedAddress: withdrawal, + } = this.props + const { + refundAddress: returnAddress, + depositCoin, + } = this.state + const pair = `${depositCoin}_eth` + const data = { + withdrawal, + pair, + returnAddress, + // Public api key + 'apiKey': '803d1f5df2ed1b1476e4b9e6bcd089e34d8874595dda6a23b67d93c56ea9cc2445e98a6748b219b2b6ad654d9f075f1f1db139abfa93158c04e825db122c14b6', + } - h('.icon-control', { - style: { - position: 'relative', - }, - }, [ - // Not visible on the screen, can't see it on master. - - // h('i.fa.fa-refresh.fa-4.orange', { - // style: { - // bottom: '5px', - // left: '5px', - // color: '#F7861C', - // }, - // onClick: this.updateCoin.bind(this), - // }), - h('i.fa.fa-chevron-right.fa-4.orange', { - style: { - position: 'absolute', - bottom: '35%', - left: '0%', - color: '#F7861C', - }, - onClick: this.updateCoin.bind(this), - }), - ]), + if (isValidAddress(withdrawal)) { + buyWithShapeShift(data) + .then(d => this.setState({ + showQrCode: true, + depositAddress: d.deposit, + isLoading: false, + })) + .catch(() => this.setState({ + showQrCode: false, + errorMessage: 'Invalid Request', + isLoading: false, + })) + } +} - h('#toCoin.ex-coins', marketinfo.pair.split('_')[1].toUpperCase()), +ShapeshiftForm.prototype.renderMetadata = function (label, value) { + return h('div', {className: 'shapeshift-form__metadata-wrapper'}, [ - h('img', { - src: coinOptions[marketinfo.pair.split('_')[1].toUpperCase()].image, - width: '25px', - height: '25px', - style: { - marginLeft: '5px', - }, - }), + h('div.shapeshift-form__metadata-label', {}, [ + h('span', `${label}:`), ]), - h('.flex-column', { - style: { - marginTop: '1%', - alignItems: 'flex-start', - }, - }, [ - this.props.warning ? - this.props.warning && - h('span.error.flex-center', { - style: { - textAlign: 'center', - width: '229px', - height: '82px', - }, - }, this.props.warning) - : this.renderInfo(), - - this.renderRefundAddressForCoin(coin), + h('div.shapeshift-form__metadata-value', {}, [ + h('span', value), ]), ]) } -ShapeshiftForm.prototype.renderRefundAddressForCoin = function (coin) { - return h(this.activeToggle('.input-container'), { - style: { - marginTop: '1%', - }, - }, [ - - h('div', `${coin} Address:`), - - h('input#fromCoinAddress.buy-inputs', { - type: 'text', - placeholder: `Your ${coin} Refund Address`, - dataset: { - persistentFormId: 'refund-address', - - }, - style: { - boxSizing: 'border-box', - width: '227px', - height: '30px', - padding: ' 5px ', - }, - }), - - h('i.fa.fa-pencil-square-o.edit-text', { - style: { - fontSize: '12px', - color: '#F7861C', - position: 'absolute', - }, - }), - h('div.flex-row', { - style: { - justifyContent: 'flex-start', - }, - }, [ - h('button', { - onClick: this.shift.bind(this), - style: { - marginTop: '1%', - }, - }, - 'Submit'), - ]), +ShapeshiftForm.prototype.renderMarketInfo = function () { + const { depositCoin } = this.state + const coinPair = `${depositCoin}_eth` + const { tokenExchangeRates } = this.props + const { + limit, + rate, + minimum, + } = tokenExchangeRates[coinPair] || {} + + return h('div.shapeshift-form__metadata', {}, [ + + this.renderMetadata('Status', limit ? 'Available' : 'Unavailable'), + this.renderMetadata('Limit', limit), + this.renderMetadata('Exchange Rate', rate), + this.renderMetadata('Minimum', minimum), + ]) } -ShapeshiftForm.prototype.shift = function () { - var props = this.props - var withdrawal = this.props.buyView.buyAddress - var returnAddress = document.getElementById('fromCoinAddress').value - var pair = this.props.buyView.formView.marketinfo.pair - var data = { - 'withdrawal': withdrawal, - 'pair': pair, - 'returnAddress': returnAddress, - // Public api key - 'apiKey': '803d1f5df2ed1b1476e4b9e6bcd089e34d8874595dda6a23b67d93c56ea9cc2445e98a6748b219b2b6ad654d9f075f1f1db139abfa93158c04e825db122c14b6', - } - var message = [ - `Deposit Limit: ${props.buyView.formView.marketinfo.limit}`, - `Deposit Minimum:${props.buyView.formView.marketinfo.minimum}`, - ] - if (isValidAddress(withdrawal)) { - this.props.dispatch(actions.coinShiftRquest(data, message)) - } -} +ShapeshiftForm.prototype.renderQrCode = function () { + const { depositAddress, isLoading } = this.state + const qrImage = qrcode(4, 'M') + qrImage.addData(depositAddress) + qrImage.make() -ShapeshiftForm.prototype.renderCoinList = function () { - var list = Object.keys(this.props.buyView.formView.coinOptions).map((item) => { - return h('option', { - value: item, - }, item) - }) + return h('div.shapeshift-form', {}, [ - return h('datalist#coinList', { - onClick: (event) => { - event.preventDefault() - }, - }, list) -} + h('div.shapeshift-form__deposit-instruction', [ + 'Deposit your BTC to the address below:', + ]), -ShapeshiftForm.prototype.updateCoin = function (event) { - event.preventDefault() - const props = this.props - var coinOptions = this.props.buyView.formView.coinOptions - var coin = document.getElementById('fromCoin').value - - if (!coinOptions[coin.toUpperCase()] || coin.toUpperCase() === 'ETH') { - var message = 'Not a valid coin' - return props.dispatch(actions.displayWarning(message)) - } else { - return props.dispatch(actions.pairUpdate(coin)) - } -} + h('div', depositAddress), -ShapeshiftForm.prototype.handleLiveInput = function () { - const props = this.props - var coinOptions = this.props.buyView.formView.coinOptions - var coin = document.getElementById('fromCoin').value + h('div.shapeshift-form__qr-code', [ + isLoading + ? h('img', { + src: 'images/loading.svg', + style: { width: '60px'}, + }) + : h('div', { + dangerouslySetInnerHTML: { __html: qrImage.createTableTag(4) }, + }), + ]), - if (!coinOptions[coin.toUpperCase()] || coin.toUpperCase() === 'ETH') { - return null - } else { - return props.dispatch(actions.pairUpdate(coin)) - } -} + this.renderMarketInfo(), -ShapeshiftForm.prototype.renderInfo = function () { - const marketinfo = this.props.buyView.formView.marketinfo - const coinOptions = this.props.buyView.formView.coinOptions - var coin = marketinfo.pair.split('_')[0].toUpperCase() - - return h('span', { - style: { - }, - }, [ - h('h3.flex-row.text-transform-uppercase', { - style: { - color: '#868686', - paddingTop: '4px', - justifyContent: 'space-around', - textAlign: 'center', - fontSize: '17px', - }, - }, `Market Info for ${marketinfo.pair.replace('_', ' to ').toUpperCase()}:`), - h('.marketinfo', ['Status : ', `${coinOptions[coin].status}`]), - h('.marketinfo', ['Exchange Rate: ', `${marketinfo.rate}`]), - h('.marketinfo', ['Limit: ', `${marketinfo.limit}`]), - h('.marketinfo', ['Minimum : ', `${marketinfo.minimum}`]), ]) } -ShapeshiftForm.prototype.activeToggle = function (elementType) { - if (!this.props.buyView.formView.response || this.props.warning) return elementType - return `${elementType}.inactive` -} -ShapeshiftForm.prototype.renderLoading = function () { - return h('span', { - style: { - position: 'absolute', - left: '70px', - bottom: '194px', - background: 'transparent', - width: '229px', - height: '82px', - display: 'flex', - justifyContent: 'center', - }, - }, [ - h('img', { - style: { - width: '60px', - }, - src: 'images/loading.svg', - }), - ]) +ShapeshiftForm.prototype.render = function () { + const { coinOptions, btnClass } = this.props + const { depositCoin, errorMessage, showQrCode, depositAddress } = this.state + const coinPair = `${depositCoin}_eth` + const { tokenExchangeRates } = this.props + const token = tokenExchangeRates[coinPair] + + return h('div.shapeshift-form-wrapper', [ + showQrCode + ? this.renderQrCode() + : h('div.shapeshift-form', [ + h('div.shapeshift-form__selectors', [ + + h('div.shapeshift-form__selector', [ + + h('div.shapeshift-form__selector-label', 'Deposit'), + + h(SimpleDropdown, { + selectedOption: this.state.depositCoin, + onSelect: this.onCoinChange, + options: Object.entries(coinOptions).map(([coin]) => ({ + value: coin.toLowerCase(), + displayValue: coin, + })), + }), + + ]), + + h('div.icon.shapeshift-form__caret', { + style: { backgroundImage: 'url(images/caret-right.svg)'}, + }), + + h('div.shapeshift-form__selector', [ + + h('div.shapeshift-form__selector-label', [ + 'Receive', + ]), + + h('div.shapeshift-form__selector-input', ['ETH']), + + ]), + + ]), + + h('div', { + className: classnames('shapeshift-form__address-input-wrapper', { + 'shapeshift-form__address-input-wrapper--error': errorMessage, + }), + }, [ + + h('div.shapeshift-form__address-input-label', [ + 'Your Refund Address', + ]), + + h('input.shapeshift-form__address-input', { + type: 'text', + onChange: e => this.setState({ + refundAddress: e.target.value, + errorMessage: '', + }), + }), + + h('divshapeshift-form__address-input-error-message', [errorMessage]), + ]), + + this.renderMarketInfo(), + + ]), + + !depositAddress && h('button.shapeshift-form__shapeshift-buy-btn', { + className: btnClass, + disabled: !token, + onClick: () => this.onBuyWithShapeShift(), + }, ['Buy']), + + ]) } diff --git a/ui/app/components/shift-list-item.js b/ui/app/components/shift-list-item.js index b555dee84..111a77df4 100644 --- a/ui/app/components/shift-list-item.js +++ b/ui/app/components/shift-list-item.js @@ -16,6 +16,7 @@ module.exports = connect(mapStateToProps)(ShiftListItem) function mapStateToProps (state) { return { + selectedAddress: state.metamask.selectedAddress, conversionRate: state.metamask.conversionRate, currentCurrency: state.metamask.currentCurrency, } @@ -28,36 +29,39 @@ function ShiftListItem () { } ShiftListItem.prototype.render = function () { + const { selectedAddress, receivingAddress } = this.props return ( - h('.transaction-list-item.flex-row', { - style: { - paddingTop: '20px', - paddingBottom: '20px', - justifyContent: 'space-around', - alignItems: 'center', - }, - }, [ - h('div', { + selectedAddress === receivingAddress + ? h('div.tx-list-item.tx-list-clickable', { style: { - width: '0px', - position: 'relative', - bottom: '19px', + paddingTop: '20px', + paddingBottom: '20px', + justifyContent: 'space-around', + alignItems: 'center', }, }, [ - h('img', { - src: 'https://info.shapeshift.io/sites/default/files/logo.png', + h('div', { style: { - height: '35px', - width: '132px', - position: 'absolute', - clip: 'rect(0px,23px,34px,0px)', + width: '0px', + position: 'relative', + bottom: '19px', }, - }), - ]), + }, [ + h('img', { + src: 'https://info.shapeshift.io/sites/default/files/logo.png', + style: { + height: '35px', + width: '132px', + position: 'absolute', + clip: 'rect(0px,23px,34px,0px)', + }, + }), + ]), - this.renderInfo(), - this.renderUtilComponents(), - ]) + this.renderInfo(), + this.renderUtilComponents(), + ]) + : null ) } diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js new file mode 100644 index 000000000..c5cc23aa8 --- /dev/null +++ b/ui/app/components/signature-request.js @@ -0,0 +1,253 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const Identicon = require('./identicon') +const connect = require('react-redux').connect +const ethUtil = require('ethereumjs-util') +const classnames = require('classnames') + +const AccountDropdownMini = require('./dropdowns/account-dropdown-mini') + +const actions = require('../actions') +const { conversionUtil } = require('../conversion-util') + +const { + getSelectedAccount, + getCurrentAccountWithSendEtherInfo, + getSelectedAddress, + accountsWithSendEtherInfoSelector, + conversionRateSelector, +} = require('../selectors.js') + +function mapStateToProps (state) { + return { + balance: getSelectedAccount(state).balance, + selectedAccount: getCurrentAccountWithSendEtherInfo(state), + selectedAddress: getSelectedAddress(state), + requester: null, + requesterAddress: null, + accounts: accountsWithSendEtherInfoSelector(state), + conversionRate: conversionRateSelector(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + goHome: () => dispatch(actions.goHome()), + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(SignatureRequest) + +inherits(SignatureRequest, Component) +function SignatureRequest (props) { + Component.call(this) + + this.state = { + selectedAccount: props.selectedAccount, + accountDropdownOpen: false, + } +} + +SignatureRequest.prototype.renderHeader = function () { + return h('div.request-signature__header', [ + + h('div.request-signature__header-background'), + + h('div.request-signature__header__text', 'Signature Request'), + + h('div.request-signature__header__tip-container', [ + h('div.request-signature__header__tip'), + ]), + + ]) +} + +SignatureRequest.prototype.renderAccountDropdown = function () { + const { + selectedAccount, + accountDropdownOpen, + } = this.state + + const { + accounts, + } = this.props + + return h('div.request-signature__account', [ + + h('div.request-signature__account-text', ['Account:']), + + h(AccountDropdownMini, { + selectedAccount, + accounts, + onSelect: selectedAccount => this.setState({ selectedAccount }), + dropdownOpen: accountDropdownOpen, + openDropdown: () => this.setState({ accountDropdownOpen: true }), + closeDropdown: () => this.setState({ accountDropdownOpen: false }), + }), + + ]) +} + +SignatureRequest.prototype.renderBalance = function () { + const { balance, conversionRate } = this.props + + const balanceInEther = conversionUtil(balance, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + numberOfDecimals: 6, + conversionRate, + }) + + return h('div.request-signature__balance', [ + + h('div.request-signature__balance-text', ['Balance:']), + + h('div.request-signature__balance-value', `${balanceInEther} ETH`), + + ]) +} + +SignatureRequest.prototype.renderAccountInfo = function () { + return h('div.request-signature__account-info', [ + + this.renderAccountDropdown(), + + this.renderRequestIcon(), + + this.renderBalance(), + + ]) +} + +SignatureRequest.prototype.renderRequestIcon = function () { + const { requesterAddress } = this.props + + return h('div.request-signature__request-icon', [ + h(Identicon, { + diameter: 40, + address: requesterAddress, + }), + ]) +} + +SignatureRequest.prototype.renderRequestInfo = function () { + return h('div.request-signature__request-info', [ + + h('div.request-signature__headline', [ + `Your signature is being requested`, + ]), + + ]) +} + +SignatureRequest.prototype.msgHexToText = function (hex) { + try { + const stripped = ethUtil.stripHexPrefix(hex) + const buff = Buffer.from(stripped, 'hex') + return buff.toString('utf8') + } catch (e) { + return hex + } +} + +SignatureRequest.prototype.renderBody = function () { + let rows + let notice = 'You are signing:' + + const { txData } = this.props + const { type, msgParams: { data } } = txData + + if (type === 'personal_sign') { + rows = [{ name: 'Message', value: this.msgHexToText(data) }] + } else if (type === 'eth_signTypedData') { + rows = data + } else if (type === 'eth_sign') { + rows = [{ name: 'Message', value: data }] + notice = `Signing this message can have + dangerous side effects. Only sign messages from + sites you fully trust with your entire account. + This dangerous method will be removed in a future version. ` + } + + return h('div.request-signature__body', {}, [ + + this.renderAccountInfo(), + + this.renderRequestInfo(), + + h('div.request-signature__notice', { + className: classnames({ + 'request-signature__notice': type === 'personal_sign' || type === 'eth_signTypedData', + 'request-signature__warning': type === 'eth_sign', + }), + }, [notice]), + + h('div.request-signature__rows', [ + + ...rows.map(({ name, value }) => { + return h('div.request-signature__row', [ + h('div.request-signature__row-title', [`${name}:`]), + h('div.request-signature__row-value', value), + ]) + }), + + ]), + + ]) +} + +SignatureRequest.prototype.renderFooter = function () { + const { + signPersonalMessage, + signTypedMessage, + cancelPersonalMessage, + cancelTypedMessage, + signMessage, + cancelMessage, + } = this.props + + const { txData } = this.props + const { type } = txData + + let cancel + let sign + if (type === 'personal_sign') { + cancel = cancelPersonalMessage + sign = signPersonalMessage + } else if (type === 'eth_signTypedData') { + cancel = cancelTypedMessage + sign = signTypedMessage + } else if (type === 'eth_sign') { + cancel = cancelMessage + sign = signMessage + } + + return h('div.request-signature__footer', [ + h('button.request-signature__footer__cancel-button', { + onClick: cancel, + }, 'CANCEL'), + h('button.request-signature__footer__sign-button', { + onClick: sign, + }, 'SIGN'), + ]) +} + +SignatureRequest.prototype.render = function () { + return ( + + h('div.request-signature__container', [ + + this.renderHeader(), + + this.renderBody(), + + this.renderFooter(), + + ]) + + ) + +} + diff --git a/ui/app/components/tab-bar.js b/ui/app/components/tab-bar.js index bef444a48..0edced119 100644 --- a/ui/app/components/tab-bar.js +++ b/ui/app/components/tab-bar.js @@ -1,37 +1,47 @@ -const Component = require('react').Component +const { Component } = require('react') const h = require('react-hyperscript') -const inherits = require('util').inherits +const PropTypes = require('react').PropTypes +const classnames = require('classnames') -module.exports = TabBar +class TabBar extends Component { + constructor (props) { + super(props) + const { defaultTab, tabs } = props -inherits(TabBar, Component) -function TabBar () { - Component.call(this) -} + this.state = { + subview: defaultTab || tabs[0].key, + } + } -TabBar.prototype.render = function () { - const props = this.props - const state = this.state || {} - const { tabs = [], defaultTab, tabSelected } = props - const { subview = defaultTab } = state + render () { + const { tabs = [], tabSelected } = this.props + const { subview } = this.state - return ( - h('.flex-row.space-around.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - paddingTop: '4px', - minHeight: '30px', - }, - }, tabs.map((tab) => { - const { key, content } = tab - return h(subview === key ? '.activeForm' : '.inactiveForm.pointer', { - onClick: () => { - this.setState({ subview: key }) - tabSelected(key) - }, - }, content) - })) - ) + return ( + h('.tab-bar', {}, [ + tabs.map((tab) => { + const { key, content } = tab + return h('div', { + className: classnames('tab-bar__tab pointer', { + 'tab-bar__tab--active': subview === key, + }), + onClick: () => { + this.setState({ subview: key }) + tabSelected(key) + }, + key, + }, content) + }), + h('div.tab-bar__tab.tab-bar__grow-tab'), + ]) + ) + } } +TabBar.propTypes = { + defaultTab: PropTypes.string, + tabs: PropTypes.array, + tabSelected: PropTypes.func, +} + +module.exports = TabBar diff --git a/ui/app/components/token-balance.js b/ui/app/components/token-balance.js new file mode 100644 index 000000000..2f71c0687 --- /dev/null +++ b/ui/app/components/token-balance.js @@ -0,0 +1,113 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const TokenTracker = require('eth-token-tracker') +const connect = require('react-redux').connect +const selectors = require('../selectors') + +function mapStateToProps (state) { + return { + userAddress: selectors.getSelectedAddress(state), + } +} + +module.exports = connect(mapStateToProps)(TokenBalance) + + +inherits(TokenBalance, Component) +function TokenBalance () { + this.state = { + string: '', + symbol: '', + isLoading: true, + error: null, + } + Component.call(this) +} + +TokenBalance.prototype.render = function () { + const state = this.state + const { symbol, string, isLoading } = state + const { balanceOnly } = this.props + + return isLoading + ? h('span', '') + : h('span.token-balance', [ + h('span.token-balance__amount', string), + !balanceOnly && h('span.token-balance__symbol', symbol), + ]) +} + +TokenBalance.prototype.componentDidMount = function () { + this.createFreshTokenTracker() +} + +TokenBalance.prototype.createFreshTokenTracker = function () { + if (this.tracker) { + // Clean up old trackers when refreshing: + this.tracker.stop() + this.tracker.removeListener('update', this.balanceUpdater) + this.tracker.removeListener('error', this.showError) + } + + if (!global.ethereumProvider) return + const { userAddress, token } = this.props + + this.tracker = new TokenTracker({ + userAddress, + provider: global.ethereumProvider, + tokens: [token], + pollingInterval: 8000, + }) + + + // Set up listener instances for cleaning up + this.balanceUpdater = this.updateBalance.bind(this) + this.showError = error => { + this.setState({ error, isLoading: false }) + } + this.tracker.on('update', this.balanceUpdater) + this.tracker.on('error', this.showError) + + this.tracker.updateBalances() + .then(() => { + this.updateBalance(this.tracker.serialize()) + }) + .catch((reason) => { + log.error(`Problem updating balances`, reason) + this.setState({ isLoading: false }) + }) +} + +TokenBalance.prototype.componentDidUpdate = function (nextProps) { + const { + userAddress: oldAddress, + token: { address: oldTokenAddress }, + } = this.props + const { + userAddress: newAddress, + token: { address: newTokenAddress }, + } = nextProps + + if ((!oldAddress || !newAddress) && (!oldTokenAddress || !newTokenAddress)) return + if ((oldAddress === newAddress) && (oldTokenAddress === newTokenAddress)) return + + this.setState({ isLoading: true }) + this.createFreshTokenTracker() +} + +TokenBalance.prototype.updateBalance = function (tokens = []) { + const [{ string, symbol }] = tokens + + this.setState({ + string, + symbol, + isLoading: false, + }) +} + +TokenBalance.prototype.componentWillUnmount = function () { + if (!this.tracker) return + this.tracker.stop() +} + diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js index 19d7139bb..59552f4a0 100644 --- a/ui/app/components/token-cell.js +++ b/ui/app/components/token-cell.js @@ -1,35 +1,135 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits +const connect = require('react-redux').connect const Identicon = require('./identicon') const prefixForNetwork = require('../../lib/etherscan-prefix-for-network') +const selectors = require('../selectors') +const actions = require('../actions') +const { conversionUtil, multiplyCurrencies } = require('../conversion-util') -module.exports = TokenCell +const TokenMenuDropdown = require('./dropdowns/token-menu-dropdown.js') + +function mapStateToProps (state) { + return { + network: state.metamask.network, + currentCurrency: state.metamask.currentCurrency, + selectedTokenAddress: state.metamask.selectedTokenAddress, + userAddress: selectors.getSelectedAddress(state), + tokenExchangeRates: state.metamask.tokenExchangeRates, + conversionRate: state.metamask.conversionRate, + sidebarOpen: state.appState.sidebarOpen, + } +} + +function mapDispatchToProps (dispatch) { + return { + setSelectedToken: address => dispatch(actions.setSelectedToken(address)), + updateTokenExchangeRate: token => dispatch(actions.updateTokenExchangeRate(token)), + hideSidebar: () => dispatch(actions.hideSidebar()), + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(TokenCell) inherits(TokenCell, Component) function TokenCell () { Component.call(this) + + this.state = { + tokenMenuOpen: false, + } +} + +TokenCell.prototype.componentWillMount = function () { + const { + updateTokenExchangeRate, + symbol, + } = this.props + + updateTokenExchangeRate(symbol) } TokenCell.prototype.render = function () { + const { tokenMenuOpen } = this.state const props = this.props - const { address, symbol, string, network, userAddress } = props + const { + address, + symbol, + string, + network, + setSelectedToken, + selectedTokenAddress, + tokenExchangeRates, + conversionRate, + hideSidebar, + sidebarOpen, + currentCurrency, + // userAddress, + } = props + + const pair = `${symbol.toLowerCase()}_eth` + + let currentTokenToFiatRate + let currentTokenInFiat + let formattedFiat = '' + + if (tokenExchangeRates[pair]) { + currentTokenToFiatRate = multiplyCurrencies( + tokenExchangeRates[pair].rate, + conversionRate + ) + currentTokenInFiat = conversionUtil(string, { + fromNumericBase: 'dec', + fromCurrency: symbol, + toCurrency: currentCurrency.toUpperCase(), + numberOfDecimals: 2, + conversionRate: currentTokenToFiatRate, + }) + formattedFiat = currentTokenInFiat.toString() === '0' + ? '' + : `${currentTokenInFiat} ${currentCurrency.toUpperCase()}` + } + + const showFiat = Boolean(currentTokenInFiat) && currentCurrency.toUpperCase() !== symbol return ( - h('li.token-cell', { - style: { cursor: network === '1' ? 'pointer' : 'default' }, - onClick: this.view.bind(this, address, userAddress, network), + h('div.token-list-item', { + className: `token-list-item ${selectedTokenAddress === address ? 'token-list-item--active' : ''}`, + // style: { cursor: network === '1' ? 'pointer' : 'default' }, + // onClick: this.view.bind(this, address, userAddress, network), + onClick: () => { + setSelectedToken(address) + selectedTokenAddress !== address && sidebarOpen && hideSidebar() + }, }, [ h(Identicon, { + className: 'token-list-item__identicon', diameter: 50, address, network, }), - h('h3', `${string || 0} ${symbol}`), + h('h.token-list-item__balance-wrapper', null, [ + h('h3.token-list-item__token-balance', `${string || 0} ${symbol}`), + + showFiat && h('div.token-list-item__fiat-amount', { + style: {}, + }, formattedFiat), + ]), - h('span', { style: { flex: '1 0 auto' } }), + h('i.fa.fa-ellipsis-h.fa-lg.token-list-item__ellipsis.cursor-pointer', { + onClick: (e) => { + e.stopPropagation() + this.setState({ tokenMenuOpen: true }) + }, + }), + + tokenMenuOpen && h(TokenMenuDropdown, { + onClose: () => this.setState({ tokenMenuOpen: false }), + token: { symbol, address }, + }), /* h('button', { diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js index 149733b89..8e06e0f27 100644 --- a/ui/app/components/token-list.js +++ b/ui/app/components/token-list.js @@ -3,8 +3,28 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const TokenTracker = require('eth-token-tracker') const TokenCell = require('./token-cell.js') +const connect = require('react-redux').connect +const selectors = require('../selectors') + +function mapStateToProps (state) { + return { + network: state.metamask.network, + tokens: state.metamask.tokens, + userAddress: selectors.getSelectedAddress(state), + } +} + +const defaultTokens = [] +const contracts = require('eth-contract-metadata') +for (const address in contracts) { + const contract = contracts[address] + if (contract.erc20) { + contract.address = address + defaultTokens.push(contract) + } +} -module.exports = TokenList +module.exports = connect(mapStateToProps)(TokenList) inherits(TokenList, Component) function TokenList () { @@ -17,12 +37,12 @@ function TokenList () { } TokenList.prototype.render = function () { + const { userAddress } = this.props const state = this.state const { tokens, isLoading, error } = state - const { userAddress, network } = this.props if (isLoading) { - return this.message('Loading') + return this.message('Loading Tokens...') } if (error) { @@ -47,87 +67,8 @@ TokenList.prototype.render = function () { ]) } - const tokenViews = tokens.map((tokenData) => { - tokenData.network = network - tokenData.userAddress = userAddress - return h(TokenCell, tokenData) - }) - - return h('.full-flex-height', [ - this.renderTokenStatusBar(), + return h('div', tokens.map((tokenData) => h(TokenCell, tokenData))) - h('ol.full-flex-height.flex-column', { - style: { - overflowY: 'auto', - display: 'flex', - flexDirection: 'column', - }, - }, [ - h('style', ` - - li.token-cell { - display: flex; - flex-direction: row; - align-items: center; - padding: 10px; - min-height: 50px; - } - - li.token-cell > h3 { - margin-left: 12px; - } - - li.token-cell:hover { - background: white; - cursor: pointer; - } - - `), - ...tokenViews, - h('.flex-grow'), - ]), - ]) -} - -TokenList.prototype.renderTokenStatusBar = function () { - const { tokens } = this.state - - let msg - if (tokens.length === 1) { - msg = `You own 1 token` - } else if (tokens.length > 1) { - msg = `You own ${tokens.length} tokens` - } else { - msg = `No tokens found` - } - - return h('div', { - style: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - minHeight: '70px', - padding: '10px', - }, - }, [ - h('span', msg), - h('button', { - key: 'reveal-account-bar', - onClick: (event) => { - event.preventDefault() - this.props.addToken() - }, - style: { - display: 'flex', - height: '40px', - padding: '10px', - justifyContent: 'center', - alignItems: 'center', - }, - }, [ - 'ADD TOKEN', - ]), - ]) } TokenList.prototype.message = function (body) { @@ -156,6 +97,7 @@ TokenList.prototype.createFreshTokenTracker = function () { if (!global.ethereumProvider) return const { userAddress } = this.props + this.tracker = new TokenTracker({ userAddress, provider: global.ethereumProvider, @@ -182,15 +124,30 @@ TokenList.prototype.createFreshTokenTracker = function () { }) } -TokenList.prototype.componentWillUpdate = function (nextProps) { - if (nextProps.network === 'loading') return - const oldNet = this.props.network - const newNet = nextProps.network - - if (oldNet && newNet && newNet !== oldNet) { - this.setState({ isLoading: true }) - this.createFreshTokenTracker() - } +TokenList.prototype.componentDidUpdate = function (nextProps) { + const { + network: oldNet, + userAddress: oldAddress, + tokens, + } = this.props + const { + network: newNet, + userAddress: newAddress, + tokens: newTokens, + } = nextProps + + const isLoading = newNet === 'loading' + const missingInfo = !oldNet || !newNet || !oldAddress || !newAddress + const sameUserAndNetwork = oldAddress === newAddress && oldNet === newNet + const shouldUpdateTokens = isLoading || missingInfo || sameUserAndNetwork + + const oldTokensLength = tokens ? tokens.length : 0 + const tokensLengthUnchanged = oldTokensLength === newTokens.length + + if (tokensLengthUnchanged && shouldUpdateTokens) return + + this.setState({ isLoading: true }) + this.createFreshTokenTracker() } TokenList.prototype.updateBalances = function (tokens) { @@ -202,3 +159,15 @@ TokenList.prototype.componentWillUnmount = function () { this.tracker.stop() } +// function uniqueMergeTokens (tokensA, tokensB = []) { +// const uniqueAddresses = [] +// const result = [] +// tokensA.concat(tokensB).forEach((token) => { +// const normal = normalizeAddress(token.address) +// if (!uniqueAddresses.includes(normal)) { +// uniqueAddresses.push(normal) +// result.push(token) +// } +// }) +// return result +// } diff --git a/ui/app/components/transaction-list-item.js b/ui/app/components/transaction-list-item.js index 42ef665b1..4e3d2cb93 100644 --- a/ui/app/components/transaction-list-item.js +++ b/ui/app/components/transaction-list-item.js @@ -199,34 +199,40 @@ function formatDate (date) { } function renderErrorOrWarning (transaction) { - const { status, err, warning } = transaction + const { status } = transaction // show rejected if (status === 'rejected') { return h('span.error', ' (Rejected)') } - - // show error - if (err) { - const message = err.message || '' - return ( - h(Tooltip, { - title: message, - position: 'bottom', - }, [ - h(`span.error`, ` (Failed)`), - ]) - ) - } - - // show warning - if (warning) { - const message = warning.message - return h(Tooltip, { - title: message, - position: 'bottom', - }, [ - h(`span.warning`, ` (Warning)`), - ]) + if (transaction.err || transaction.warning) { + const { err, warning = {} } = transaction + const errFirst = !!((err && warning) || err) + + errFirst ? err.message : warning.message + + // show error + if (err) { + const message = err.message || '' + return ( + h(Tooltip, { + title: message, + position: 'bottom', + }, [ + h(`span.error`, ` (Failed)`), + ]) + ) + } + + // show warning + if (warning) { + const message = warning.message + return h(Tooltip, { + title: message, + position: 'bottom', + }, [ + h(`span.warning`, ` (Warning)`), + ]) + } } } diff --git a/ui/app/components/tx-list-item.js b/ui/app/components/tx-list-item.js new file mode 100644 index 000000000..7ccc5c315 --- /dev/null +++ b/ui/app/components/tx-list-item.js @@ -0,0 +1,245 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const inherits = require('util').inherits +const classnames = require('classnames') +const abi = require('human-standard-token-abi') +const abiDecoder = require('abi-decoder') +abiDecoder.addABI(abi) +const Identicon = require('./identicon') +const contractMap = require('eth-contract-metadata') + +const { conversionUtil, multiplyCurrencies } = require('../conversion-util') +const { calcTokenAmount } = require('../token-util') + +const { getCurrentCurrency } = require('../selectors') + +module.exports = connect(mapStateToProps)(TxListItem) + +function mapStateToProps (state) { + return { + tokens: state.metamask.tokens, + currentCurrency: getCurrentCurrency(state), + tokenExchangeRates: state.metamask.tokenExchangeRates, + } +} + +inherits(TxListItem, Component) +function TxListItem () { + Component.call(this) + + this.state = { + total: null, + fiatTotal: null, + } +} + +TxListItem.prototype.componentDidMount = async function () { + const { txParams = {} } = this.props + + const decodedData = txParams.data && abiDecoder.decodeMethod(txParams.data) + const { name: txDataName } = decodedData || {} + + const { total, fiatTotal } = txDataName === 'transfer' + ? await this.getSendTokenTotal() + : this.getSendEtherTotal() + + this.setState({ total, fiatTotal }) +} + +TxListItem.prototype.getAddressText = function () { + const { + address, + txParams = {}, + } = this.props + + const decodedData = txParams.data && abiDecoder.decodeMethod(txParams.data) + const { name: txDataName, params = [] } = decodedData || {} + const { value } = params[0] || {} + + switch (txDataName) { + case 'transfer': + return `${value.slice(0, 10)}...${value.slice(-4)}` + default: + return address + ? `${address.slice(0, 10)}...${address.slice(-4)}` + : 'Contract Published' + } +} + +TxListItem.prototype.getSendEtherTotal = function () { + const { + transactionAmount, + conversionRate, + address, + currentCurrency, + } = this.props + + if (!address) { + return {} + } + + const totalInFiat = conversionUtil(transactionAmount, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency: currentCurrency, + fromDenomination: 'WEI', + numberOfDecimals: 2, + conversionRate, + }) + const totalInETH = conversionUtil(transactionAmount, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency: 'ETH', + fromDenomination: 'WEI', + conversionRate, + numberOfDecimals: 6, + }) + + return { + total: `${totalInETH} ETH`, + fiatTotal: `${totalInFiat} ${currentCurrency.toUpperCase()}`, + } +} + +TxListItem.prototype.getTokenInfo = async function () { + const { txParams = {}, tokenInfoGetter, tokens } = this.props + const toAddress = txParams.to + + let decimals + let symbol + + ({ decimals, symbol } = tokens.filter(({ address }) => address === toAddress)[0] || {}) + + if (!decimals && !symbol) { + ({ decimals, symbol } = contractMap[toAddress] || {}) + } + + if (!decimals && !symbol) { + ({ decimals, symbol } = await tokenInfoGetter(toAddress)) + } + + return { decimals, symbol } +} + +TxListItem.prototype.getSendTokenTotal = async function () { + const { + txParams = {}, + conversionRate, + tokenExchangeRates, + currentCurrency, + } = this.props + + const decodedData = txParams.data && abiDecoder.decodeMethod(txParams.data) + const { params = [] } = decodedData || {} + const { value } = params[1] || {} + const { decimals, symbol } = await this.getTokenInfo() + const total = calcTokenAmount(value, decimals) + + const pair = symbol && `${symbol.toLowerCase()}_eth` + + let tokenToFiatRate + let totalInFiat + + if (tokenExchangeRates[pair]) { + tokenToFiatRate = multiplyCurrencies( + tokenExchangeRates[pair].rate, + conversionRate + ) + + totalInFiat = conversionUtil(total, { + fromNumericBase: 'dec', + toNumericBase: 'dec', + fromCurrency: symbol, + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate: tokenToFiatRate, + }) + } + + const showFiat = Boolean(totalInFiat) && currentCurrency.toUpperCase() !== symbol + + return { + total: `${total} ${symbol}`, + fiatTotal: showFiat && `${totalInFiat} ${currentCurrency.toUpperCase()}`, + } +} + +TxListItem.prototype.render = function () { + const { + transactionStatus, + transactionAmount, + onClick, + transActionId, + dateString, + address, + className, + } = this.props + const { total, fiatTotal } = this.state + const showFiatTotal = transactionAmount !== '0x0' && fiatTotal + + return h(`div${className || ''}`, { + key: transActionId, + onClick: () => onClick && onClick(transActionId), + }, [ + h(`div.flex-column.tx-list-item-wrapper`, {}, [ + + h('div.tx-list-date-wrapper', { + style: {}, + }, [ + h('span.tx-list-date', {}, [ + dateString, + ]), + ]), + + h('div.flex-row.tx-list-content-wrapper', { + style: {}, + }, [ + + h('div.tx-list-identicon-wrapper', { + style: {}, + }, [ + h(Identicon, { + address, + diameter: 28, + }), + ]), + + h('div.tx-list-account-and-status-wrapper', {}, [ + h('div.tx-list-account-wrapper', { + style: {}, + }, [ + h('span.tx-list-account', {}, [ + this.getAddressText(address), + ]), + ]), + + h('div.tx-list-status-wrapper', { + style: {}, + }, [ + h('span', { + className: classnames('tx-list-status', { + 'tx-list-status--rejected': transactionStatus === 'rejected', + 'tx-list-status--failed': transactionStatus === 'failed', + }), + }, + transactionStatus, + ), + ]), + ]), + + h('div.flex-column.tx-list-details-wrapper', { + style: {}, + }, [ + + h('span.tx-list-value', total), + + showFiatTotal && h('span.tx-list-fiat-value', fiatTotal), + + ]), + ]), + ]), // holding on icon from design + ]) +} diff --git a/ui/app/components/tx-list.js b/ui/app/components/tx-list.js new file mode 100644 index 000000000..84cd0f093 --- /dev/null +++ b/ui/app/components/tx-list.js @@ -0,0 +1,140 @@ +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const inherits = require('util').inherits +const prefixForNetwork = require('../../lib/etherscan-prefix-for-network') +const selectors = require('../selectors') +const TxListItem = require('./tx-list-item') +const ShiftListItem = require('./shift-list-item') +const { formatDate } = require('../util') +const { showConfTxPage } = require('../actions') +const classnames = require('classnames') +const { tokenInfoGetter } = require('../token-util') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(TxList) + +function mapStateToProps (state) { + return { + txsToRender: selectors.transactionsSelector(state), + conversionRate: selectors.conversionRateSelector(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + showConfTxPage: ({ id }) => dispatch(showConfTxPage({ id })), + } +} + +inherits(TxList, Component) +function TxList () { + Component.call(this) +} + +TxList.prototype.componentWillMount = function () { + this.tokenInfoGetter = tokenInfoGetter() +} + +TxList.prototype.render = function () { + return h('div.flex-column', [ + h('div.flex-row.tx-list-header-wrapper', [ + h('div.flex-row.tx-list-header', [ + h('div', 'transactions'), + ]), + ]), + h('div.flex-column.tx-list-container', {}, [ + this.renderTransaction(), + ]), + ]) +} + +TxList.prototype.renderTransaction = function () { + const { txsToRender, conversionRate } = this.props + return txsToRender.length + ? txsToRender.map((transaction, i) => this.renderTransactionListItem(transaction, conversionRate, i)) + : [h( + 'div.tx-list-item.tx-list-item--empty', + { key: 'tx-list-none' }, + [ 'No Transactions' ], + )] +} + +// TODO: Consider moving TxListItem into a separate component +TxList.prototype.renderTransactionListItem = function (transaction, conversionRate, index) { + // console.log({transaction}) + // refer to transaction-list.js:line 58 + + if (transaction.key === 'shapeshift') { + return h('div', { + key: `shapeshift${index}`, + }, [ + h(ShiftListItem, transaction), + ]) + } + + const props = { + dateString: formatDate(transaction.time), + address: transaction.txParams.to, + transactionStatus: transaction.status, + transactionAmount: transaction.txParams.value, + transActionId: transaction.id, + transactionHash: transaction.hash, + transactionNetworkId: transaction.metamaskNetworkId, + } + + const { + address, + transactionStatus, + transactionAmount, + dateString, + transActionId, + transactionHash, + transactionNetworkId, + } = props + const { showConfTxPage } = this.props + + const opts = { + key: transActionId || transactionHash, + txParams: transaction.txParams, + transactionStatus, + transActionId, + dateString, + address, + transactionAmount, + transactionHash, + conversionRate, + tokenInfoGetter: this.tokenInfoGetter, + } + + const isUnapproved = transactionStatus === 'unapproved' + + if (isUnapproved) { + opts.onClick = () => showConfTxPage({id: transActionId}) + opts.transactionStatus = 'Not Started' + } else if (transactionHash) { + opts.onClick = () => this.view(transactionHash, transactionNetworkId) + } + + opts.className = classnames('.tx-list-item', { + '.tx-list-pending-item-container': isUnapproved, + '.tx-list-clickable': Boolean(transactionHash) || isUnapproved, + }) + + return h(TxListItem, opts) +} + +TxList.prototype.view = function (txHash, network) { + const url = etherscanLinkFor(txHash, network) + if (url) { + navigateTo(url) + } +} + +function navigateTo (url) { + global.platform.openWindow({ url }) +} + +function etherscanLinkFor (txHash, network) { + const prefix = prefixForNetwork(network) + return `https://${prefix}etherscan.io/tx/${txHash}` +} diff --git a/ui/app/components/tx-view.js b/ui/app/components/tx-view.js new file mode 100644 index 000000000..b25d8e0f9 --- /dev/null +++ b/ui/app/components/tx-view.js @@ -0,0 +1,148 @@ +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const ethUtil = require('ethereumjs-util') +const inherits = require('util').inherits +const actions = require('../actions') +const selectors = require('../selectors') + +const BalanceComponent = require('./balance-component') +const TxList = require('./tx-list') +const Identicon = require('./identicon') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(TxView) + +function mapStateToProps (state) { + const sidebarOpen = state.appState.sidebarOpen + const isMascara = state.appState.isMascara + + const identities = state.metamask.identities + const accounts = state.metamask.accounts + const network = state.metamask.network + const selectedTokenAddress = state.metamask.selectedTokenAddress + const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] + const checksumAddress = selectedAddress && ethUtil.toChecksumAddress(selectedAddress) + const identity = identities[selectedAddress] + + return { + sidebarOpen, + selectedAddress, + checksumAddress, + selectedTokenAddress, + selectedToken: selectors.getSelectedToken(state), + identity, + network, + isMascara, + } +} + +function mapDispatchToProps (dispatch) { + return { + showSidebar: () => { dispatch(actions.showSidebar()) }, + hideSidebar: () => { dispatch(actions.hideSidebar()) }, + showModal: (payload) => { dispatch(actions.showModal(payload)) }, + showSendPage: () => { dispatch(actions.showSendPage()) }, + showSendTokenPage: () => { dispatch(actions.showSendTokenPage()) }, + } +} + +inherits(TxView, Component) +function TxView () { + Component.call(this) +} + +TxView.prototype.renderHeroBalance = function () { + const { selectedToken } = this.props + + return h('div.hero-balance', {}, [ + + h(BalanceComponent, { token: selectedToken }), + + this.renderButtons(), + ]) +} + +TxView.prototype.renderButtons = function () { + const {selectedToken, showModal, showSendPage, showSendTokenPage } = this.props + + return !selectedToken + ? ( + h('div.flex-row.flex-center.hero-balance-buttons', [ + h('button.btn-clear.hero-balance-button', { + onClick: () => showModal({ + name: 'DEPOSIT_ETHER', + }), + }, 'DEPOSIT'), + + h('button.btn-clear.hero-balance-button', { + style: { + marginLeft: '0.8em', + }, + onClick: showSendPage, + }, 'SEND'), + ]) + ) + : ( + h('div.flex-row.flex-center.hero-balance-buttons', [ + h('button.btn-clear.hero-balance-button', { + onClick: showSendTokenPage, + }, 'SEND'), + ]) + ) +} + +TxView.prototype.render = function () { + const { selectedAddress, identity, network, isMascara } = this.props + + return h('div.tx-view.flex-column', { + style: {}, + }, [ + + h('div.flex-row.phone-visible', { + style: { + margin: '1.5em 1.2em 0', + justifyContent: 'space-between', + alignItems: 'center', + }, + }, [ + + h('div.fa.fa-bars', { + style: { + fontSize: '1.3em', + cursor: 'pointer', + }, + onClick: () => { + this.props.sidebarOpen ? this.props.hideSidebar() : this.props.showSidebar() + }, + }, []), + + h('.identicon-wrapper.select-none', { + style: { + marginLeft: '0.9em', + }, + }, [ + h(Identicon, { + diameter: 24, + address: selectedAddress, + network, + }), + ]), + + h('span.account-name', { + style: {}, + }, [ + identity.name, + ]), + + !isMascara && h('div.open-in-browser', { + onClick: () => global.platform.openExtensionInBrowser(), + }, [h('img', { src: 'images/popout.svg' })]), + + ]), + + this.renderHeroBalance(), + + h(TxList), + + ]) +} diff --git a/ui/app/components/wallet-content-display.js b/ui/app/components/wallet-content-display.js new file mode 100644 index 000000000..bfa061be4 --- /dev/null +++ b/ui/app/components/wallet-content-display.js @@ -0,0 +1,56 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits + +module.exports = WalletContentDisplay + +inherits(WalletContentDisplay, Component) +function WalletContentDisplay () { + Component.call(this) +} + +WalletContentDisplay.prototype.render = function () { + const { title, amount, fiatValue, active, style } = this.props + + // TODO: Separate component: wallet-content-account + return h('div.flex-column', { + style: { + marginLeft: '1.3em', + alignItems: 'flex-start', + ...style, + }, + }, [ + + h('span', { + style: { + fontSize: '1.1em', + }, + }, title), + + h('span', { + style: { + fontSize: '1.8em', + margin: '0.4em 0em', + }, + }, amount), + + h('span', { + style: { + fontSize: '1.3em', + }, + }, fiatValue), + + active && h('div', { + style: { + position: 'absolute', + marginLeft: '-1.3em', + height: '6em', + width: '0.3em', + background: '#D8D8D8', // $alto + }, + }, [ + ]), + ]) + +} + diff --git a/ui/app/components/wallet-view.js b/ui/app/components/wallet-view.js new file mode 100644 index 000000000..b1ef83cee --- /dev/null +++ b/ui/app/components/wallet-view.js @@ -0,0 +1,171 @@ +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const inherits = require('util').inherits +const Identicon = require('./identicon') +// const AccountDropdowns = require('./dropdowns/index.js').AccountDropdowns +const copyToClipboard = require('copy-to-clipboard') +const actions = require('../actions') +const BalanceComponent = require('./balance-component') +const TokenList = require('./token-list') +const selectors = require('../selectors') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(WalletView) + +function mapStateToProps (state) { + + return { + network: state.metamask.network, + sidebarOpen: state.appState.sidebarOpen, + identities: state.metamask.identities, + accounts: state.metamask.accounts, + tokens: state.metamask.tokens, + keyrings: state.metamask.keyrings, + selectedAddress: selectors.getSelectedAddress(state), + selectedIdentity: selectors.getSelectedIdentity(state), + selectedAccount: selectors.getSelectedAccount(state), + selectedTokenAddress: state.metamask.selectedTokenAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + showSendPage: () => dispatch(actions.showSendPage()), + hideSidebar: () => dispatch(actions.hideSidebar()), + unsetSelectedToken: () => dispatch(actions.setSelectedToken()), + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + showAddTokenPage: () => dispatch(actions.showAddTokenPage()), + } +} + +inherits(WalletView, Component) +function WalletView () { + Component.call(this) + this.state = { + hasCopied: false, + } +} + +WalletView.prototype.renderWalletBalance = function () { + const { + selectedTokenAddress, + selectedAccount, + unsetSelectedToken, + hideSidebar, + sidebarOpen, + } = this.props + + const selectedClass = selectedTokenAddress + ? '' + : 'wallet-balance-wrapper--active' + const className = `flex-column wallet-balance-wrapper ${selectedClass}` + + return h('div', { className }, [ + h('div.wallet-balance', + { + onClick: () => { + unsetSelectedToken() + selectedTokenAddress && sidebarOpen && hideSidebar() + }, + }, + [ + h(BalanceComponent, { + balanceValue: selectedAccount ? selectedAccount.balance : '', + style: {}, + }), + ] + ), + ]) +} + +WalletView.prototype.render = function () { + const { + responsiveDisplayClassname, + selectedAddress, + selectedIdentity, + keyrings, + showAccountDetailModal, + hideSidebar, + showAddTokenPage, + } = this.props + // temporary logs + fake extra wallets + // console.log('walletview, selectedAccount:', selectedAccount) + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(selectedAddress) || + kr.accounts.includes(selectedIdentity.address) + }) + + const type = keyring.type + const isLoose = type !== 'HD Key Tree' + + return h('div.wallet-view.flex-column' + (responsiveDisplayClassname || ''), { + style: {}, + }, [ + + // TODO: Separate component: wallet account details + h('div.flex-column.wallet-view-account-details', { + style: {}, + }, [ + h('div.wallet-view__sidebar-close', { + onClick: hideSidebar, + }), + + h('div.wallet-view__keyring-label', isLoose ? 'IMPORTED' : ''), + + h('div.flex-column.flex-center.wallet-view__name-container', { + style: { margin: '0 auto' }, + onClick: showAccountDetailModal, + }, [ + h(Identicon, { + diameter: 54, + address: selectedAddress, + }), + + h('span.account-name', { + style: {}, + }, [ + selectedIdentity.name, + ]), + + h('button.btn-clear.wallet-view__details-button', 'DETAILS'), + ]), + ]), + + + h('div.wallet-view__address', { + onClick: () => { + copyToClipboard(selectedAddress) + this.setState({ hasCopied: true }) + setTimeout(() => this.setState({ hasCopied: false }), 3000) + }, + }, [ + this.state.hasCopied && 'Copied to Clipboard', + !this.state.hasCopied && `${selectedAddress.slice(0, 4)}...${selectedAddress.slice(-4)}`, + h('i.fa.fa-clipboard', { style: { marginLeft: '8px' } }), + ]), + + this.renderWalletBalance(), + + h(TokenList), + + h('button.btn-clear.wallet-view__add-token-button', { + onClick: () => { + showAddTokenPage() + hideSidebar() + }, + }, 'Add Token'), + ]) +} + +// TODO: Extra wallets, for dev testing. Remove when PRing to master. +// const extraWallet = h('div.flex-column.wallet-balance-wrapper', {}, [ +// h('div.wallet-balance', {}, [ +// h(BalanceComponent, { +// balanceValue: selectedAccount.balance, +// style: {}, +// }), +// ]), +// ]) diff --git a/ui/app/conf-tx.js b/ui/app/conf-tx.js index ce4d153b5..9f273aaec 100644 --- a/ui/app/conf-tx.js +++ b/ui/app/conf-tx.js @@ -3,17 +3,24 @@ const Component = require('react').Component const h = require('react-hyperscript') const connect = require('react-redux').connect const actions = require('./actions') -const NetworkIndicator = require('./components/network') -const LoadingIndicator = require('./components/loading') const txHelper = require('../lib/tx-helper') -const isPopupOrNotification = require('../../app/scripts/lib/is-popup-or-notification') const PendingTx = require('./components/pending-tx') -const PendingMsg = require('./components/pending-msg') -const PendingPersonalMsg = require('./components/pending-personal-msg') -const PendingTypedMsg = require('./components/pending-typed-msg') +const SignatureRequest = require('./components/signature-request') +// const PendingMsg = require('./components/pending-msg') +// const PendingPersonalMsg = require('./components/pending-personal-msg') +// const PendingTypedMsg = require('./components/pending-typed-msg') const Loading = require('./components/loading') +// const contentDivider = h('div', { +// style: { +// marginLeft: '16px', +// marginRight: '16px', +// height:'1px', +// background:'#E7E7E7', +// }, +// }) + module.exports = connect(mapStateToProps)(ConfirmTxScreen) function mapStateToProps (state) { @@ -43,96 +50,71 @@ function ConfirmTxScreen () { ConfirmTxScreen.prototype.render = function () { const props = this.props - const { network, provider, unapprovedTxs, currentCurrency, computedBalances, - unapprovedMsgs, unapprovedPersonalMsgs, unapprovedTypedMessages, conversionRate, blockGasLimit } = props + const { + network, + unapprovedTxs, + currentCurrency, + unapprovedMsgs, + unapprovedPersonalMsgs, + unapprovedTypedMessages, + conversionRate, + blockGasLimit, + // provider, + // computedBalances, + } = props var unconfTxList = txHelper(unapprovedTxs, unapprovedMsgs, unapprovedPersonalMsgs, unapprovedTypedMessages, network) var txData = unconfTxList[props.index] || {} var txParams = txData.params || {} - var isNotification = isPopupOrNotification() === 'notification' + + // var isNotification = isPopupOrNotification() === 'notification' + /* + Client is using the flag above to render the following in conf screen + // subtitle and nav + h('.section-title.flex-row.flex-center', [ + !isNotification ? h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { + onClick: this.goHome.bind(this), + }) : null, + h('h2.page-subtitle', 'Confirm Transaction'), + isNotification ? h(NetworkIndicator, { + network: network, + provider: provider, + }) : null, + ]), + */ log.info(`rendering a combined ${unconfTxList.length} unconf msg & txs`) - if (unconfTxList.length === 0) return h(Loading, { isLoading: true }) - - const unconfTxListLength = unconfTxList.length - - return ( - - h('.flex-column.flex-grow', [ - - h(LoadingIndicator, { - isLoading: txData.loadingDefaults, - loadingMessage: 'Estimating transaction cost…', - }), - - // subtitle and nav - h('.section-title.flex-row.flex-center', [ - !isNotification ? h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - onClick: this.goHome.bind(this), - }) : null, - h('h2.page-subtitle', 'Confirm Transaction'), - isNotification ? h(NetworkIndicator, { - network: network, - provider: provider, - }) : null, - ]), - - h('h3', { - style: { - alignSelf: 'center', - display: unconfTxList.length > 1 ? 'block' : 'none', - }, - }, [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - style: { - display: props.index === 0 ? 'none' : 'inline-block', - }, - onClick: () => props.dispatch(actions.previousTx()), - }), - ` ${props.index + 1} of ${unconfTxList.length} `, - h('i.fa.fa-arrow-right.fa-lg.cursor-pointer', { - style: { - display: props.index + 1 === unconfTxList.length ? 'none' : 'inline-block', - }, - onClick: () => props.dispatch(actions.nextTx()), - }), - ]), - - warningIfExists(props.warning), - - currentTxView({ - // Properties - txData: txData, - key: txData.id, - selectedAddress: props.selectedAddress, - accounts: props.accounts, - identities: props.identities, - conversionRate, - currentCurrency, - blockGasLimit, - unconfTxListLength, - computedBalances, - // Actions - buyEth: this.buyEth.bind(this, txParams.from || props.selectedAddress), - sendTransaction: this.sendTransaction.bind(this), - cancelTransaction: this.cancelTransaction.bind(this, txData), - cancelAllTransactions: this.cancelAllTransactions.bind(this, unconfTxList), - signMessage: this.signMessage.bind(this, txData), - signPersonalMessage: this.signPersonalMessage.bind(this, txData), - signTypedMessage: this.signTypedMessage.bind(this, txData), - cancelMessage: this.cancelMessage.bind(this, txData), - cancelPersonalMessage: this.cancelPersonalMessage.bind(this, txData), - cancelTypedMessage: this.cancelTypedMessage.bind(this, txData), - }), - ]) - ) + if (unconfTxList.length === 0) return h(Loading) + + return currentTxView({ + // Properties + txData: txData, + key: txData.id, + selectedAddress: props.selectedAddress, + accounts: props.accounts, + identities: props.identities, + conversionRate, + currentCurrency, + blockGasLimit, + // Actions + buyEth: this.buyEth.bind(this, txParams.from || props.selectedAddress), + sendTransaction: this.sendTransaction.bind(this), + cancelTransaction: this.cancelTransaction.bind(this, txData), + signMessage: this.signMessage.bind(this, txData), + signPersonalMessage: this.signPersonalMessage.bind(this, txData), + signTypedMessage: this.signTypedMessage.bind(this, txData), + cancelMessage: this.cancelMessage.bind(this, txData), + cancelPersonalMessage: this.cancelPersonalMessage.bind(this, txData), + cancelTypedMessage: this.cancelTypedMessage.bind(this, txData), + }) + } function currentTxView (opts) { log.info('rendering current tx view') const { txData } = opts - const { txParams, msgParams, type } = txData + const { txParams, msgParams } = txData if (txParams) { log.debug('txParams detected, rendering pending tx') @@ -140,17 +122,20 @@ function currentTxView (opts) { } else if (msgParams) { log.debug('msgParams detected, rendering pending msg') - if (type === 'eth_sign') { - log.debug('rendering eth_sign message') - return h(PendingMsg, opts) - } else if (type === 'personal_sign') { - log.debug('rendering personal_sign message') - return h(PendingPersonalMsg, opts) - } else if (type === 'eth_signTypedData') { - log.debug('rendering eth_signTypedData message') - return h(PendingTypedMsg, opts) - } + return h(SignatureRequest, opts) + + // if (type === 'eth_sign') { + // log.debug('rendering eth_sign message') + // return h(PendingMsg, opts) + // } else if (type === 'personal_sign') { + // log.debug('rendering personal_sign message') + // return h(PendingPersonalMsg, opts) + // } else if (type === 'eth_signTypedData') { + // log.debug('rendering eth_signTypedData message') + // return h(PendingTypedMsg, opts) + // } } + return h(Loading) } ConfirmTxScreen.prototype.buyEth = function (address, event) { @@ -228,14 +213,14 @@ ConfirmTxScreen.prototype.goHome = function (event) { this.props.dispatch(actions.goHome()) } -function warningIfExists (warning) { - if (warning && - // Do not display user rejections on this screen: - warning.indexOf('User denied transaction signature') === -1) { - return h('.error', { - style: { - margin: 'auto', - }, - }, warning) - } -} +// function warningIfExists (warning) { +// if (warning && +// // Do not display user rejections on this screen: +// warning.indexOf('User denied transaction signature') === -1) { +// return h('.error', { +// style: { +// margin: 'auto', +// }, +// }, warning) +// } +// } diff --git a/ui/app/config.js b/ui/app/config.js deleted file mode 100644 index 9cb2a0aad..000000000 --- a/ui/app/config.js +++ /dev/null @@ -1,220 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const actions = require('./actions') -const infuraCurrencies = require('./infura-conversion.json').objects.sort((a, b) => { - return a.quote.name.toLocaleLowerCase().localeCompare(b.quote.name.toLocaleLowerCase()) - }) -const validUrl = require('valid-url') -const exportAsFile = require('./util').exportAsFile - - -module.exports = connect(mapStateToProps)(ConfigScreen) - -function mapStateToProps (state) { - return { - metamask: state.metamask, - warning: state.appState.warning, - } -} - -inherits(ConfigScreen, Component) -function ConfigScreen () { - Component.call(this) -} - -ConfigScreen.prototype.render = function () { - var state = this.props - var metamaskState = state.metamask - var warning = state.warning - - return ( - h('.flex-column.flex-grow', [ - - // subtitle and nav - h('.section-title.flex-row.flex-center', [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - onClick: (event) => { - state.dispatch(actions.goHome()) - }, - }), - h('h2.page-subtitle', 'Settings'), - ]), - - h('.error', { - style: { - display: warning ? 'block' : 'none', - padding: '0 20px', - textAlign: 'center', - }, - }, warning), - - // conf view - h('.flex-column.flex-justify-center.flex-grow.select-none', [ - h('.flex-space-around', { - style: { - padding: '20px', - }, - }, [ - - currentProviderDisplay(metamaskState), - - h('div', { style: {display: 'flex'} }, [ - h('input#new_rpc', { - placeholder: 'New RPC URL', - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - onKeyPress (event) { - if (event.key === 'Enter') { - var element = event.target - var newRpc = element.value - rpcValidation(newRpc, state) - } - }, - }), - h('button', { - style: { - alignSelf: 'center', - }, - onClick (event) { - event.preventDefault() - var element = document.querySelector('input#new_rpc') - var newRpc = element.value - rpcValidation(newRpc, state) - }, - }, 'Save'), - ]), - - h('hr.horizontal-line'), - - currentConversionInformation(metamaskState, state), - - h('hr.horizontal-line'), - - h('div', { - style: { - marginTop: '20px', - }, - }, [ - h('p', { - style: { - fontFamily: 'Montserrat Light', - fontSize: '13px', - }, - }, `State logs contain your public account addresses and sent transactions.`), - h('br'), - h('button', { - style: { - alignSelf: 'center', - }, - onClick (event) { - window.logStateString((err, result) => { - if (err) { - state.dispatch(actions.displayWarning('Error in retrieving state logs.')) - } else { - exportAsFile('MetaMask State Logs.json', result) - } - }) - }, - }, 'Download State Logs'), - ]), - - h('hr.horizontal-line'), - - h('div', { - style: { - marginTop: '20px', - }, - }, [ - h('button', { - style: { - alignSelf: 'center', - }, - onClick (event) { - event.preventDefault() - state.dispatch(actions.revealSeedConfirmation()) - }, - }, 'Reveal Seed Words'), - ]), - - ]), - ]), - ]) - ) -} - -function rpcValidation (newRpc, state) { - if (validUrl.isWebUri(newRpc)) { - state.dispatch(actions.setRpcTarget(newRpc)) - } else { - var appendedRpc = `http://${newRpc}` - if (validUrl.isWebUri(appendedRpc)) { - state.dispatch(actions.displayWarning('URIs require the appropriate HTTP/HTTPS prefix.')) - } else { - state.dispatch(actions.displayWarning('Invalid RPC URI')) - } - } -} - -function currentConversionInformation (metamaskState, state) { - var currentCurrency = metamaskState.currentCurrency - var conversionDate = metamaskState.conversionDate - return h('div', [ - h('span', {style: { fontWeight: 'bold', paddingRight: '10px'}}, 'Current Conversion'), - h('span', {style: { fontWeight: 'bold', paddingRight: '10px', fontSize: '13px'}}, `Updated ${Date(conversionDate)}`), - h('select#currentCurrency', { - onChange (event) { - event.preventDefault() - var element = document.getElementById('currentCurrency') - var newCurrency = element.value - state.dispatch(actions.setCurrentCurrency(newCurrency)) - }, - defaultValue: currentCurrency, - }, infuraCurrencies.map((currency) => { - return h('option', {key: currency.quote.code, value: currency.quote.code}, `${currency.quote.code.toUpperCase()} - ${currency.quote.name}`) - }) - ), - ]) -} - -function currentProviderDisplay (metamaskState) { - var provider = metamaskState.provider - var title, value - - switch (provider.type) { - - case 'mainnet': - title = 'Current Network' - value = 'Main Ethereum Network' - break - - case 'ropsten': - title = 'Current Network' - value = 'Ropsten Test Network' - break - - case 'kovan': - title = 'Current Network' - value = 'Kovan Test Network' - break - - case 'rinkeby': - title = 'Current Network' - value = 'Rinkeby Test Network' - break - - default: - title = 'Current RPC' - value = metamaskState.provider.rpcTarget - } - - return h('div', [ - h('span', {style: { fontWeight: 'bold', paddingRight: '10px'}}, title), - h('span', value), - ]) -} diff --git a/ui/app/conversion-util.js b/ui/app/conversion-util.js new file mode 100644 index 000000000..ee42ebea1 --- /dev/null +++ b/ui/app/conversion-util.js @@ -0,0 +1,221 @@ +/* Currency Conversion Utility +* This utility function can be used for converting currency related values within metamask. +* The caller should be able to pass it a value, along with information about the value's +* numeric base, denomination and currency, and the desired numeric base, denomination and +* currency. It should return a single value. +* +* @param {(number | string | BN)} value The value to convert. +* @param {Object} [options] Options to specify details of the conversion +* @param {string} [options.fromCurrency = 'ETH' | 'USD'] The currency of the passed value +* @param {string} [options.toCurrency = 'ETH' | 'USD'] The desired currency of the result +* @param {string} [options.fromNumericBase = 'hex' | 'dec' | 'BN'] The numeric basic of the passed value. +* @param {string} [options.toNumericBase = 'hex' | 'dec' | 'BN'] The desired numeric basic of the result. +* @param {string} [options.fromDenomination = 'WEI'] The denomination of the passed value +* @param {number} [options.numberOfDecimals] The desired number of in the result +* @param {number} [options.conversionRate] The rate to use to make the fromCurrency -> toCurrency conversion +* @returns {(number | string | BN)} +* +* The utility passes value along with the options as a single object to the `converter` function. +* `converter` uses Ramda.js to apply a composition of conditional setters to the `value` property, depending +* on the accompanying options. Some of these conditional setters are selected via key-value maps, where +* the keys are specified in the options parameters and the values are setter functions. +*/ + +const BigNumber = require('bignumber.js') +const ethUtil = require('ethereumjs-util') +const BN = ethUtil.BN +const R = require('ramda') +const { stripHexPrefix } = require('ethereumjs-util') + +BigNumber.config({ + ROUNDING_MODE: BigNumber.ROUND_HALF_DOWN, +}) + +// Big Number Constants +const BIG_NUMBER_WEI_MULTIPLIER = new BigNumber('1000000000000000000') +const BIG_NUMBER_GWEI_MULTIPLIER = new BigNumber('1000000000') + +// Individual Setters +const convert = R.invoker(1, 'times') +const round = R.invoker(2, 'round')(R.__, BigNumber.ROUND_HALF_DOWN) +const invertConversionRate = conversionRate => () => new BigNumber(1.0).div(conversionRate) +const decToBigNumberViaString = n => R.pipe(String, toBigNumber['dec']) + +// Setter Maps +const toBigNumber = { + hex: n => new BigNumber(stripHexPrefix(n), 16), + dec: n => new BigNumber(n, 10), + BN: n => new BigNumber(n.toString(16), 16), +} +const toNormalizedDenomination = { + WEI: bigNumber => bigNumber.div(BIG_NUMBER_WEI_MULTIPLIER), + GWEI: bigNumber => bigNumber.div(BIG_NUMBER_GWEI_MULTIPLIER), +} +const toSpecifiedDenomination = { + WEI: bigNumber => bigNumber.times(BIG_NUMBER_WEI_MULTIPLIER).round(), + GWEI: bigNumber => bigNumber.times(BIG_NUMBER_GWEI_MULTIPLIER).round(9), +} +const baseChange = { + hex: n => n.toString(16), + dec: n => Number(n).toString(10), + BN: n => new BN(n.toString(16)), +} + +// Predicates +const fromAndToCurrencyPropsNotEqual = R.compose( + R.not, + R.eqBy(R.__, 'fromCurrency', 'toCurrency'), + R.flip(R.prop) +) + +// Lens +const valuePropertyLens = R.over(R.lensProp('value')) +const conversionRateLens = R.over(R.lensProp('conversionRate')) + +// conditional conversionRate setting wrapper +const whenPredSetCRWithPropAndSetter = (pred, prop, setter) => R.when( + pred, + R.converge( + conversionRateLens, + [R.pipe(R.prop(prop), setter), R.identity] + ) +) + +// conditional 'value' setting wrappers +const whenPredSetWithPropAndSetter = (pred, prop, setter) => R.when( + pred, + R.converge( + valuePropertyLens, + [R.pipe(R.prop(prop), setter), R.identity] + ) +) +const whenPropApplySetterMap = (prop, setterMap) => whenPredSetWithPropAndSetter( + R.prop(prop), + prop, + R.prop(R.__, setterMap) +) + +// Conversion utility function +const converter = R.pipe( + whenPredSetCRWithPropAndSetter(R.prop('conversionRate'), 'conversionRate', decToBigNumberViaString), + whenPredSetCRWithPropAndSetter(R.prop('invertConversionRate'), 'conversionRate', invertConversionRate), + whenPropApplySetterMap('fromNumericBase', toBigNumber), + whenPropApplySetterMap('fromDenomination', toNormalizedDenomination), + whenPredSetWithPropAndSetter(fromAndToCurrencyPropsNotEqual, 'conversionRate', convert), + whenPropApplySetterMap('toDenomination', toSpecifiedDenomination), + whenPredSetWithPropAndSetter(R.prop('numberOfDecimals'), 'numberOfDecimals', round), + whenPropApplySetterMap('toNumericBase', baseChange), + R.view(R.lensProp('value')) +) + +const conversionUtil = (value, { + fromCurrency = null, + toCurrency = fromCurrency, + fromNumericBase, + toNumericBase, + fromDenomination, + toDenomination, + numberOfDecimals, + conversionRate, + invertConversionRate, +}) => converter({ + fromCurrency, + toCurrency, + fromNumericBase, + toNumericBase, + fromDenomination, + toDenomination, + numberOfDecimals, + conversionRate, + invertConversionRate, + value: value || '0', +}) + +const addCurrencies = (a, b, options = {}) => { + const { + aBase, + bBase, + ...conversionOptions + } = options + const value = (new BigNumber(a, aBase)).add(b, bBase) + + return converter({ + value, + ...conversionOptions, + }) +} + +const subtractCurrencies = (a, b, options = {}) => { + const { + aBase, + bBase, + ...conversionOptions + } = options + const value = (new BigNumber(a, aBase)).minus(b, bBase) + + return converter({ + value, + ...conversionOptions, + }) +} + +const multiplyCurrencies = (a, b, options = {}) => { + const { + multiplicandBase, + multiplierBase, + ...conversionOptions + } = options + + const bigNumberA = new BigNumber(String(a), multiplicandBase) + const bigNumberB = new BigNumber(String(b), multiplierBase) + + const value = bigNumberA.times(bigNumberB) + + return converter({ + value, + ...conversionOptions, + }) +} + +const conversionGreaterThan = ( + { ...firstProps }, + { ...secondProps }, +) => { + const firstValue = converter({ ...firstProps }) + const secondValue = converter({ ...secondProps }) + + return firstValue.gt(secondValue) +} + +const conversionGTE = ( + { ...firstProps }, + { ...secondProps }, +) => { + const firstValue = converter({ ...firstProps }) + const secondValue = converter({ ...secondProps }) + return firstValue.greaterThanOrEqualTo(secondValue) +} + +const conversionLTE = ( + { ...firstProps }, + { ...secondProps }, +) => { + const firstValue = converter({ ...firstProps }) + const secondValue = converter({ ...secondProps }) + return firstValue.lessThanOrEqualTo(secondValue) +} + +const toNegative = (n, options = {}) => { + return multiplyCurrencies(n, -1, options) +} + +module.exports = { + conversionUtil, + addCurrencies, + multiplyCurrencies, + conversionGreaterThan, + conversionGTE, + conversionLTE, + toNegative, + subtractCurrencies, +} diff --git a/ui/app/css/debug.css b/ui/app/css/debug.css deleted file mode 100644 index 3e125bcd4..000000000 --- a/ui/app/css/debug.css +++ /dev/null @@ -1,21 +0,0 @@ -/* -debug / dev -*/ - -#app-content { - border: 2px solid green; -} - -#design-container { - position: absolute; - left: 360px; - top: -42px; - width: calc(100vw - 360px); - height: 100vh; - overflow: scroll; -} - -#design-container img { - width: 2000px; - margin-right: 600px; -}
\ No newline at end of file diff --git a/ui/app/css/fonts.css b/ui/app/css/fonts.css deleted file mode 100644 index 3b9f581b9..000000000 --- a/ui/app/css/fonts.css +++ /dev/null @@ -1,36 +0,0 @@ -@import url(https://fonts.googleapis.com/css?family=Roboto:300,500); -@import url(https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css); - -@font-face { - font-family: 'Montserrat Regular'; - src: url('/fonts/Montserrat/Montserrat-Regular.woff') format('woff'); - src: url('/fonts/Montserrat/Montserrat-Regular.ttf') format('truetype'); - font-weight: normal; - font-style: normal; - font-size: 'small'; - -} - -@font-face { - font-family: 'Montserrat Bold'; - src: url('/fonts/Montserrat/Montserrat-Bold.woff') format('woff'); - src: url('/fonts/Montserrat/Montserrat-Bold.ttf') format('truetype'); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: 'Montserrat Light'; - src: url('/fonts/Montserrat/Montserrat-Light.woff') format('woff'); - src: url('/fonts/Montserrat/Montserrat-Light.ttf') format('truetype'); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: 'Montserrat UltraLight'; - src: url('/fonts/Montserrat/Montserrat-UltraLight.woff') format('woff'); - src: url('/fonts/Montserrat/Montserrat-UltraLight.ttf') format('truetype'); - font-weight: normal; - font-style: normal; -} diff --git a/ui/app/css/index.scss b/ui/app/css/index.scss new file mode 100644 index 000000000..445c819ff --- /dev/null +++ b/ui/app/css/index.scss @@ -0,0 +1,14 @@ +/* + ITCSS + + http://www.creativebloq.com/web-design/manage-large-css-projects-itcss-101517528 + https://www.xfive.co/blog/itcss-scalable-maintainable-css-architecture/ + */ + +@import './itcss/settings/index.scss'; +@import './itcss/tools/index.scss'; +@import './itcss/generic/index.scss'; +@import './itcss/base/index.scss'; +@import './itcss/objects/index.scss'; +@import './itcss/components/index.scss'; +@import './itcss/trumps/index.scss'; diff --git a/ui/app/css/itcss/base/index.scss b/ui/app/css/itcss/base/index.scss new file mode 100644 index 000000000..baa6ea037 --- /dev/null +++ b/ui/app/css/itcss/base/index.scss @@ -0,0 +1 @@ +// Base diff --git a/ui/app/css/itcss/components/account-dropdown-mini.scss b/ui/app/css/itcss/components/account-dropdown-mini.scss new file mode 100644 index 000000000..996993db7 --- /dev/null +++ b/ui/app/css/itcss/components/account-dropdown-mini.scss @@ -0,0 +1,48 @@ +.account-dropdown-mini { + height: 22px; + background-color: $white; + font-family: Roboto; + line-height: 16px; + font-size: 12px; + width: 124px; + + &__close-area { + position: fixed; + top: 0; + left: 0; + z-index: 1000; + width: 100%; + height: 100%; + } + + &__list { + z-index: 1050; + position: absolute; + height: 180px; + width: 96pxpx; + border: 1px solid $geyser; + border-radius: 4px; + background-color: $white; + box-shadow: 0 3px 6px 0 rgba(0 ,0 ,0 ,.11); + overflow-y: scroll; + } + + .account-list-item { + margin-top: 6px; + } + + .account-list-item__account-name { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 80px; + } + + .account-list-item__top-row { + margin: 0; + } + + .account-list-item__icon { + position: initial; + } +}
\ No newline at end of file diff --git a/ui/app/css/itcss/components/account-dropdown.scss b/ui/app/css/itcss/components/account-dropdown.scss new file mode 100644 index 000000000..725da9d39 --- /dev/null +++ b/ui/app/css/itcss/components/account-dropdown.scss @@ -0,0 +1,83 @@ +.account-dropdown-name { + font-family: Roboto; +} + +.account-dropdown-balance { + color: $dusty-gray; + line-height: 19px; +} + +.account-dropdown-edit-button { + color: $dusty-gray; + font-family: Roboto; + + &:hover { + color: $white; + } +} + +.account-list-item { + &__top-row { + display: flex; + margin-top: 10px; + margin-left: 8px; + position: relative; + } + + &__account-balances { + height: auto; + border: none; + background-color: transparent; + color: #9b9b9b; + margin-left: 34px; + margin-top: 4px; + position: relative; + } + + &__account-name { + font-size: 16px; + margin-left: 8px; + } + + &__icon { + position: absolute; + right: 12px; + top: 1px; + } + + &__account-primary-balance, + &__account-secondary-balance { + font-family: Roboto; + line-height: 16px; + font-size: 12px; + font-weight: 300; + } + + &__account-primary-balance { + color: $scorpion; + border: none; + outline: 0 !important; + } + + &__account-secondary-balance { + color: $dusty-gray; + } + + &__account-address { + margin-left: 35px; + width: 80%; + overflow: hidden; + text-overflow: ellipsis; + } + + &__dropdown { + &:hover { + background: rgba($alto, .2); + cursor: pointer; + + input { + background: rgba($alto, .1); + } + } + } +} diff --git a/ui/app/css/itcss/components/account-menu.scss b/ui/app/css/itcss/components/account-menu.scss new file mode 100644 index 000000000..e16d2e024 --- /dev/null +++ b/ui/app/css/itcss/components/account-menu.scss @@ -0,0 +1,132 @@ +.account-menu { + position: fixed; + z-index: 100; + top: 58px; + width: 310px; + + @media screen and (max-width: 575px) { + right: calc(((100vw - 100%) / 2) + 8px); + } + + @media screen and (min-width: 576px) { + right: calc((100vw - 85vw) / 2); + } + + @media screen and (min-width: 769px) { + right: calc((100vw - 80vw) / 2); + } + + @media screen and (min-width: 1281px) { + right: calc((100vw - 65vw) / 2); + } + + &__icon { + margin-left: 20px; + cursor: pointer; + } + + &__header { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + } + + &__logout-button { + border: 1px solid $dusty-gray; + background-color: transparent; + color: $white; + border-radius: 4px; + font-size: 12px; + line-height: 23px; + padding: 0 24px; + font-weight: 300; + } + + img { + width: 16px; + height: 16px; + } + + &__accounts { + display: flex; + flex-flow: column nowrap; + overflow-y: auto; + max-height: 240px; + position: relative; + z-index: 200; + + &::-webkit-scrollbar { + display: none; + } + + @media screen and (max-width: 575px) { + max-height: 215px; + } + + .keyring-label { + margin-top: 5px; + background-color: $black; + color: $dusty-gray; + } + } + + &__account { + display: flex; + flex-flow: row nowrap; + padding: 16px 14px; + flex: 0 0 auto; + + @media screen and (max-width: 575px) { + padding: 12px 14px; + } + } + + &__account-info { + flex: 1 0 auto; + display: flex; + flex-flow: column nowrap; + padding-top: 4px; + } + + &__check-mark { + width: 14px; + margin-right: 12px; + flex: 0 0 auto; + } + + &__check-mark-icon { + background-image: url("images/check-white.svg"); + height: 18px; + width: 18px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + margin: 3px 0; + } + + .identicon { + margin: 0 12px 0 0; + flex: 0 0 auto; + } + + &__name { + color: $white; + font-size: 18px; + font-weight: 300; + line-height: 16px; + } + + &__balance { + color: $dusty-gray; + font-size: 14px; + line-height: 19px; + } + + &__action { + font-size: 16px; + line-height: 18px; + font-weight: 300; + cursor: pointer; + } +} diff --git a/ui/app/css/itcss/components/add-token.scss b/ui/app/css/itcss/components/add-token.scss new file mode 100644 index 000000000..13020f62f --- /dev/null +++ b/ui/app/css/itcss/components/add-token.scss @@ -0,0 +1,343 @@ +.add-token { + width: 498px; + display: flex; + flex-flow: column nowrap; + align-items: center; + position: relative; + z-index: 12; + font-family: 'DIN Next Light'; + + &__wrapper { + background-color: $white; + box-shadow: 0 2px 4px 0 rgba($black, .08); + display: flex; + flex-flow: column nowrap; + align-items: center; + flex: 0 0 auto; + } + + &__title-container { + display: flex; + flex-flow: column nowrap; + align-items: center; + padding: 30px 60px 12px; + border-bottom: 1px solid $gallery; + flex: 0 0 auto; + } + + &__title { + color: $scorpion; + font-size: 20px; + line-height: 26px; + text-align: center; + font-weight: 600; + margin-bottom: 12px; + } + + &__description { + text-align: center; + } + + &__description + &__description { + margin-top: 24px; + } + + &__confirmation-description { + margin: 12px 0; + } + + &__content-container { + width: 100%; + border-bottom: 1px solid $gallery; + } + + &__input-container { + padding: 11px 0; + width: 263px; + margin: 0 auto; + position: relative; + } + + &__search-input-error-message { + position: absolute; + bottom: -10px; + font-size: 12px; + width: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: $red; + } + + &__input { + width: 100%; + border: 2px solid $gallery; + border-radius: 4px; + padding: 5px 15px; + font-size: 14px; + line-height: 19px; + + &::placeholder { + color: $silver; + } + } + + &__footers { + width: 100%; + } + + &__add-custom { + color: $scorpion; + font-size: 18px; + line-height: 24px; + text-align: center; + padding: 12px 0; + font-weight: 600; + cursor: pointer; + position: relative; + + &:hover { + background-color: rgba(0, 0, 0, .05); + } + + &:active { + background-color: rgba(0, 0, 0, .1); + } + + .fa { + position: absolute; + right: 24px; + font-size: 24px; + line-height: 24px; + } + } + + &__add-custom-form { + display: flex; + flex-flow: column nowrap; + margin: 8px 0 51px; + } + + &__add-custom-field { + width: 290px; + margin: 0 auto; + position: relative; + + &--error { + .add-token__add-custom-input { + border-color: $red; + } + } + } + + &__add-custom-error-message { + position: absolute; + bottom: -21px; + font-size: 12px; + width: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: $red; + } + + &__add-custom-label { + font-size: 16px; + line-height: 21px; + margin-bottom: 8px; + } + + &__add-custom-input { + width: 100%; + border: 1px solid $silver; + padding: 5px 15px; + font-size: 14px; + line-height: 19px; + + &::placeholder { + color: $silver; + } + } + + &__add-custom-field + &__add-custom-field { + margin-top: 21px; + } + + &__buttons { + display: flex; + flex-flow: row nowrap; + margin: 30px 0 51px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + } + + &__button { + flex: 1 0 141px; + margin: 0 12px; + padding: 10px 22px; + height: 54px; + } + + &__token-icons-container { + display: flex; + flex-flow: row wrap; + } + + &__token-wrapper { + transition: 200ms ease-in-out; + display: flex; + flex-flow: row nowrap; + flex: 0 0 42.5%; + align-items: center; + padding: 12px; + margin: 2.5%; + box-sizing: border-box; + border-radius: 10px; + cursor: pointer; + border: 2px solid transparent; + position: relative; + + &:hover { + border: 2px solid rgba($malibu-blue, .5); + } + + &--selected { + border: 2px solid $malibu-blue !important; + } + + &--disabled { + opacity: .4; + pointer-events: none; + } + } + + &__token-data { + align-self: flex-start; + } + + &__token-name { + font-size: 14px; + line-height: 19px; + } + + &__token-symbol { + font-size: 22px; + line-height: 29px; + font-weight: 600; + } + + &__token-icon { + width: 60px; + height: 60px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + border-radius: 50%; + background-color: $white; + box-shadow: 0 2px 4px 0 rgba($black, .24); + margin-right: 12px; + flex: 0 0 auto; + } + + &__token-message { + position: absolute; + color: $caribbean-green; + font-size: 11px; + bottom: 0; + left: 85px; + } + + &__confirmation-token-list { + display: flex; + flex-flow: column nowrap; + + .token-balance { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + + &__amount { + color: $scorpion; + font-size: 43px; + font-weight: 300; + line-height: 43px; + margin-right: 8px; + } + + &__symbol { + color: $scorpion; + font-size: 16px; + line-height: 24px; + } + } + } + + &__confirmation-title { + padding: 30px 120px 12px; + + @media screen and (max-width: $break-small) { + padding: 20px 0; + width: 100%; + } + } + + &__confirmation-content { + padding-bottom: 60px; + } + + &__confirmation-token-list-item { + display: flex; + flex-flow: row nowrap; + margin: 0 auto; + align-items: center; + } + + &__confirmation-token-list-item + &__confirmation-token-list-item { + margin-top: 30px; + } + + &__confirmation-token-icon { + margin-right: 18px; + } + + @media screen and (max-width: $break-small) { + top: 0; + width: 100%; + overflow: hidden; + height: 100%; + + &__wrapper { + box-shadow: none !important; + flex: 1 1 auto; + width: 100%; + overflow-y: auto; + } + + &__footers { + border-bottom: 1px solid $gallery; + } + + &__token-icon { + width: 50px; + height: 50px; + } + + &__token-symbol { + font-size: 18px; + line-height: 24px; + } + + &__token-name { + font-size: 12px; + line-height: 16px; + } + + &__buttons { + padding: 12px 0; + margin: 0; + border-top: 1px solid $gallery; + width: 100%; + } + } +} diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss new file mode 100644 index 000000000..1450b71cc --- /dev/null +++ b/ui/app/css/itcss/components/buttons.scss @@ -0,0 +1,142 @@ +/* + Buttons + */ + +.btn-green { + background-color: #02c9b1; // TODO: reusable color in colors.css +} + +.btn-clear { + background: $white; + text-align: center; + padding: .8rem 1rem; + color: $curious-blue; + border: 2px solid $spindle; + border-radius: 4px; + font-size: .85rem; + font-weight: 400; + transition: border-color .3s ease; + + &:hover { + border-color: $curious-blue; + } + + &--disabled, + &[disabled] { + cursor: auto; + opacity: .5; + pointer-events: none; + } +} + +.btn-cancel { + background: $white; + text-align: center; + padding: .9rem 1rem; + color: $scorpion; + border: 2px solid $dusty-gray; + border-radius: 4px; + font-size: .85rem; + font-weight: 400; + transition: border-color .3s ease; + + &:hover { + border-color: $scorpion; + } +} + +// No longer used in flat design, remove when modal buttons done +// div.wallet-btn { +// border: 1px solid rgb(91, 93, 103); +// border-radius: 2px; +// height: 30px; +// width: 75px; +// font-size: 0.8em; +// text-align: center; +// line-height: 25px; +// } + +// .btn-red { +// background: rgba(254, 35, 17, 1); +// box-shadow: 0px 3px 6px rgba(254, 35, 17, 0.36); +// } + +button[disabled], +input[type="submit"][disabled] { + cursor: not-allowed; + opacity: .5; + // background: rgba(197, 197, 197, 1); + // box-shadow: 0 3px 6px rgba(197, 197, 197, .36); +} + +// button.spaced { +// margin: 2px; +// } + +// button:not([disabled]):hover, input[type="submit"]:not([disabled]):hover { +// transform: scale(1.1); +// } +// button:not([disabled]):active, input[type="submit"]:not([disabled]):active { +// transform: scale(0.95); +// } + +button.primary { + padding: 8px 12px; + background: #f7861c; + box-shadow: 0 3px 6px rgba(247, 134, 28, .36); + color: $white; + font-size: 1.1em; + font-family: Roboto; + text-transform: uppercase; +} + +.btn-light { + padding: 8px 12px; + // background: #FFFFFF; // $bg-white + box-shadow: 0 3px 6px rgba(247, 134, 28, .36); + color: #585d67; // TODO: make reusable light button color + font-size: 1.1em; + font-family: Roboto; + text-transform: uppercase; + text-align: center; + line-height: 20px; + border-radius: 2px; + border: 1px solid #979797; // #TODO: make reusable light border color + opacity: .5; +} + +// TODO: cleanup: not used anywhere +button.btn-thin { + border: 1px solid; + border-color: #4d4d4d; + color: #4d4d4d; + background: rgb(255, 174, 41); + border-radius: 4px; + min-width: 200px; + margin: 12px 0; + padding: 6px; + font-size: 13px; +} + +.btn-secondary { + border: 1px solid #979797; + border-radius: 2px; + background-color: $white; + font-size: 16px; + line-height: 24px; + padding: 16px 42px; + + &[disabled] { + background-color: $white !important; + opacity: .5; + } +} + +.btn-tertiary { + border: 1px solid transparent; + border-radius: 2px; + background-color: transparent; + font-size: 16px; + line-height: 24px; + padding: 16px 42px; +} diff --git a/ui/app/css/itcss/components/confirm.scss b/ui/app/css/itcss/components/confirm.scss new file mode 100644 index 000000000..255f66e66 --- /dev/null +++ b/ui/app/css/itcss/components/confirm.scss @@ -0,0 +1,326 @@ +.confirm-screen-container { + position: relative; + align-items: center; + font-family: Roboto; + flex: 1 0 auto; + flex-flow: column nowrap; + box-shadow: 0 2px 4px 0 rgba($black, .08); + border-radius: 8px; + display: flex; + + @media screen and (max-width: 575px) { + width: 100%; + box-shadow: initial; + } + + @media screen and (min-width: 576px) { + // top: -26px; + } +} + +.notification { + .confirm-screen-wrapper { + + @media screen and (max-width: $break-small) { + height: calc(100vh - 85px); + } + } +} + +.confirm-screen-wrapper { + height: 100%; + width: 380px; + background-color: $white; + display: flex; + flex-flow: column nowrap; + z-index: 25; + align-items: center; + font-family: Roboto; + position: relative; + overflow-y: auto; + overflow-x: hidden; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + @media screen and (max-width: $break-small) { + width: 100%; + overflow-x: hidden; + overflow-y: auto; + top: 0; + box-shadow: none; + height: calc(100vh - 58px - 85px); + border-top-left-radius: 0; + border-top-right-radius: 0; + } +} + +.confirm-screen-wrapper > .confirm-screen-total-box { + margin-left: 10px; + margin-right: 10px; +} + +.confirm-screen-wrapper > .confirm-memo-wrapper { + margin: 0; +} + +.confirm-screen-header { + height: 88px; + background-color: $athens-grey; + position: relative; + display: flex; + justify-content: center; + align-items: center; + font-size: 22px; + line-height: 29px; + width: 100%; + padding: 25px 0; + flex: 0 0 auto; + + @media screen and (max-width: $break-small) { + font-size: 20px; + } +} + +.confirm-screen-header-tip { + height: 25px; + width: 25px; + background: $athens-grey; + position: absolute; + transform: rotate(45deg); + top: 71px; + left: 0; + right: 0; + margin: 0 auto; +} + +.confirm-screen-title { + line-height: 27px; + + @media screen and (max-width: $break-small) { + margin-left: 22px; + margin-right: 8px; + } +} + +.confirm-screen-back-button { + background: transparent; + left: 24px; + position: absolute; + padding: 6px 12px; + font-size: .7rem; + + @media screen and (max-width: $break-small) { + margin-right: 12px; + } +} + +.confirm-screen-account-wrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.confirm-screen-account-name { + margin-top: 12px; + font-size: 14px; + line-height: 19px; + color: $scorpion; + text-align: center; +} + +.confirm-screen-row-info { + font-size: 16px; + line-height: 21px; +} + +.confirm-screen-account-number { + font-size: 10px; + line-height: 16px; + color: $dusty-gray; + text-align: center; + height: 16px; +} + +.confirm-send-ether, +.confirm-send-token { + i.fa-arrow-right { + align-self: start; + margin: 24px 14px 0 !important; + } +} + +.confirm-screen-identicons { + margin-top: 24px; + flex: 0 0 auto; + + i.fa-arrow-right { + align-self: start; + margin: 42px 14px 0; + } + + i.fa-file-text-o { + font-size: 60px; + margin: 16px 8px 0 8px; + text-align: center; + } +} + +.confirm-screen-sending-to-message { + text-align: center; + font-size: 16px; + margin-top: 30px; + font-family: 'DIN NEXT Light'; +} + +.confirm-screen-send-amount { + color: $scorpion; + margin-top: 12px; + text-align: center; + font-size: 40px; + font-weight: 300; + line-height: 53px; + flex: 0 0 auto; +} + +.confirm-screen-send-amount-currency { + font-size: 20px; + line-height: 20px; + text-align: center; + flex: 0 0 auto; +} + +.confirm-memo-wrapper { + min-height: 24px; + width: 100%; + border-bottom: 1px solid $alto; + flex: 0 0 auto; +} + +.confirm-screen-send-memo { + color: $scorpion; + font-size: 16px; + line-height: 19px; + font-weight: 400; +} + +.confirm-screen-label { + font-size: 18px; + line-height: 40px; + color: $scorpion; + text-align: left; +} + +section .confirm-screen-account-name, +section .confirm-screen-account-number, +.confirm-screen-row-info, +.confirm-screen-row-detail { + text-align: left; +} + +.confirm-screen-rows { + display: flex; + flex-flow: column nowrap; + width: 100%; + flex: 0 0 auto; +} + +.confirm-screen-section-column { + flex: .5; +} + +.confirm-screen-row { + display: flex; + flex-flow: row nowrap; + border-bottom: 1px solid $alto; + width: 100%; + align-items: center; + padding: 12px; + padding-left: 35px; + font-size: 16px; + line-height: 22px; + font-weight: 300; +} + +.confirm-screen-row-detail { + font-size: 12px; + line-height: 16px; + color: $dusty-gray; +} + +.confirm-screen-total-box { + background-color: $wild-sand; + padding: 20px; + padding-left: 35px; + border-bottom: 1px solid $alto; + + .confirm-screen-label { + line-height: 18px; + } + + .confirm-screen-row-detail { + color: $scorpion; + } + + &__subtitle { + font-size: 12px; + line-height: 22px; + } + + .confirm-screen-row-info { + font-size: 16px; + font-weight: 500; + line-height: 21px; + } +} + +.confirm-screen-confirm-button { + height: 50px; + border-radius: 4px; + background-color: #02c9b1; + font-size: 16px; + color: $white; + text-align: center; + font-family: Roboto; + padding-top: 15px; + padding-bottom: 15px; + border-width: 0; + box-shadow: none; + flex: 1 0 auto; + font-weight: 300; + margin: 0 5px; +} + +.btn-light.confirm-screen-cancel-button { + height: 50px; + background: none; + border: none; + opacity: 1; + font-family: Roboto; + border-width: 0; + padding-top: 15px; + padding-bottom: 15px; + font-size: 16px; + box-shadow: none; + cursor: pointer; + flex: 1 0 auto; + font-weight: 300; + margin: 0 5px; +} + +#pending-tx-form { + flex: 1 0 auto; + position: relative; + display: flex; + flex-flow: row nowrap; + background-color: $white; + padding: 12px; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + width: 100%; + + @media screen and (max-width: $break-small) { + border-top: 1px solid $alto; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} diff --git a/ui/app/css/itcss/components/currency-display.scss b/ui/app/css/itcss/components/currency-display.scss new file mode 100644 index 000000000..9459629b6 --- /dev/null +++ b/ui/app/css/itcss/components/currency-display.scss @@ -0,0 +1,56 @@ +.currency-display { + height: 54px; + width: 100%ß; + border: 1px solid $alto; + border-radius: 4px; + background-color: $white; + color: $dusty-gray; + font-family: Roboto; + font-size: 16px; + font-weight: 300; + padding: 8px 10px; + position: relative; + + &__primary-row { + display: flex; + } + + &__input { + color: $scorpion; + font-family: Roboto; + font-size: 16px; + line-height: 22px; + border: none; + outline: 0 !important; + max-width: 100%; + } + + &__primary-currency { + color: $scorpion; + font-weight: 400; + font-family: Roboto; + font-size: 16px; + line-height: 22px; + } + + &__converted-row { + display: flex; + } + + &__converted-value, + &__converted-currency { + color: $dusty-gray; + font-family: Roboto; + font-size: 12px; + line-height: 12px; + } + + &__input-wrapper { + position: relative; + display: flex; + } + + &__currency-symbol { + margin-top: 1px; + } +}
\ No newline at end of file diff --git a/ui/app/css/itcss/components/editable-label.scss b/ui/app/css/itcss/components/editable-label.scss new file mode 100644 index 000000000..c69ea1428 --- /dev/null +++ b/ui/app/css/itcss/components/editable-label.scss @@ -0,0 +1,35 @@ +.editable-label { + display: flex; + align-items: center; + justify-content: center; + position: relative; + + &__value { + max-width: 250px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &__input { + width: 250px; + font-size: 14px; + text-align: center; + border: 1px solid $alto; + + &--error { + border: 1px solid $monzo; + } + } + + &__icon-wrapper { + position: absolute; + margin-left: 10px; + left: 100%; + } + + &__icon { + cursor: pointer; + color: $dusty-gray; + } +} diff --git a/ui/app/css/itcss/components/footer.scss b/ui/app/css/itcss/components/footer.scss new file mode 100644 index 000000000..000a53eed --- /dev/null +++ b/ui/app/css/itcss/components/footer.scss @@ -0,0 +1,4 @@ +.app-footer { + padding-bottom: 10px; + align-items: center; +} diff --git a/ui/app/css/itcss/components/gas-slider.scss b/ui/app/css/itcss/components/gas-slider.scss new file mode 100644 index 000000000..c27a560bd --- /dev/null +++ b/ui/app/css/itcss/components/gas-slider.scss @@ -0,0 +1,51 @@ +.gas-slider { + position: relative; + width: 313px; + + &__input { + width: 317px; + margin-left: -2px; + z-index: 2; + } + + input[type=range] { + -webkit-appearance: none !important; + } + + input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none !important; + height: 26px; + width: 26px; + border: 2px solid #B8B8B8; + background-color: #FFFFFF; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.08); + border-radius: 50%; + position: relative; + z-index: 10; + } + + &__bar { + height: 6px; + width: 313px; + background: $alto; + display: flex; + justify-content: space-between; + position: absolute; + top: 11px; + z-index: 0; + } + + &__low, &__high { + height: 6px; + width: 49px; + z-index: 1; + } + + &__low { + background-color: $crimson; + } + + &__high { + background-color: $caribbean-green; + } +}
\ No newline at end of file diff --git a/ui/app/css/itcss/components/header.scss b/ui/app/css/itcss/components/header.scss new file mode 100644 index 000000000..ac2cecf7e --- /dev/null +++ b/ui/app/css/itcss/components/header.scss @@ -0,0 +1,107 @@ +.app-header { + align-items: center; + visibility: visible; + background: $gallery; + position: relative; + z-index: $header-z-index; + display: flex; + flex-flow: column nowrap; + + @media screen and (max-width: 575px) { + padding: 12px; + width: 100%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, .08); + z-index: $mobile-header-z-index; + } + + @media screen and (min-width: 576px) { + height: 75px; + justify-content: center; + } + + .metafox-icon { + cursor: pointer; + } +} + +.app-header--initialized { + + @media screen and (min-width: 576px) { + &::after { + content: ''; + position: absolute; + width: 100%; + height: 32px; + background: $gallery; + bottom: -32px; + } + } +} + +.app-header-contents { + display: flex; + justify-content: space-between; + flex-flow: row nowrap; + width: 100%; + height: 6.9vh; + + @media screen and (max-width: 575px) { + height: 100%; + } + + @media screen and (min-width: 576px) { + width: 85vw; + } + + @media screen and (min-width: 769px) { + width: 80vw; + } + + @media screen and (min-width: 1281px) { + width: 62vw; + } +} + +.app-header h1 { + font-family: Roboto; + text-transform: uppercase; + font-weight: 400; + font-size: 1.1rem; + position: relative; + padding-left: 15px; + color: #5b5d67; + + @media screen and (max-width: 575px) { + display: none; + } +} + +h2.page-subtitle { + text-transform: uppercase; + color: #aeaeae; + font-size: 1em; + margin: 12px; +} + +.network-component-wrapper { + display: flex; + flex-direction: row; + align-items: center; +} + +.left-menu-wrapper { + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; +} + +.header__right-actions { + display: flex; + flex-flow: row nowrap; + align-items: center; + + .identicon { + cursor: pointer; + } +} diff --git a/ui/app/css/itcss/components/hero-balance.scss b/ui/app/css/itcss/components/hero-balance.scss new file mode 100644 index 000000000..ccc9a0118 --- /dev/null +++ b/ui/app/css/itcss/components/hero-balance.scss @@ -0,0 +1,94 @@ +.hero-balance { + + @media screen and (max-width: $break-small) { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + margin: .3em .9em 0; + // height: 80vh; + // max-height: 225px; + flex: 0 0 auto; + } + + @media screen and (min-width: $break-large) { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + margin: 2.3em 2.37em .8em; + flex: 0 0 auto; + } + + .balance-container { + display: flex; + margin: 0; + justify-content: flex-start; + align-items: center; + + @media screen and (max-width: $break-small) { + flex-direction: column; + flex: 0 0 auto; + } + + @media screen and (min-width: $break-large) { + flex-direction: row; + flex-grow: 3; + } + } + + .balance-display { + .token-amount { + color: $black; + } + + @media screen and (max-width: $break-small) { + text-align: center; + + .token-amount { + font-size: 1.75rem; + margin-top: 1rem; + } + + .fiat-amount { + font-size: 115%; + margin-top: 8.5%; + color: #a0a0a0; + } + } + + @media screen and (min-width: $break-large) { + margin-left: .8em; + justify-content: flex-start; + align-items: flex-start; + + .token-amount { + font-size: 1.5rem; + } + + .fiat-amount { + margin-top: .25%; + font-size: 105%; + } + } + } + + .hero-balance-buttons { + + @media screen and (max-width: $break-small) { + width: 100%; + // height: 100px; // needed a round number to set the heights of the buttons inside + flex: 0 0 auto; + padding: 16px 0; + } + + @media screen and (min-width: $break-large) { + flex-grow: 2; + justify-content: flex-end; + } + } +} + +.hero-balance-button { + width: 6rem; +} diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss new file mode 100644 index 000000000..d1b9b6277 --- /dev/null +++ b/ui/app/css/itcss/components/index.scss @@ -0,0 +1,55 @@ +@import './buttons.scss'; + +@import './header.scss'; + +@import './footer.scss'; + +@import './network.scss'; + +@import './modal.scss'; + +@import './newui-sections.scss'; + +@import './account-dropdown.scss'; + +@import './send.scss'; + +@import './confirm.scss'; + +@import './loading-overlay.scss'; + +// Balances +@import './hero-balance.scss'; + +@import './wallet-balance.scss'; + +// Tx List and Sections +@import './transaction-list.scss'; + +@import './sections.scss'; + +@import './token-list.scss'; + +@import './add-token.scss'; + +@import './currency-display.scss'; + +@import './account-menu.scss'; + +@import './menu.scss'; + +@import './gas-slider.scss'; + +@import './settings.scss'; + +@import './tab-bar.scss'; + +@import './simple-dropdown.scss'; + +@import './request-signature.scss'; + +@import './account-dropdown-mini.scss'; + +@import './editable-label.scss'; + +@import './new-account.scss'; diff --git a/ui/app/css/itcss/components/loading-overlay.scss b/ui/app/css/itcss/components/loading-overlay.scss new file mode 100644 index 000000000..15009c1e6 --- /dev/null +++ b/ui/app/css/itcss/components/loading-overlay.scss @@ -0,0 +1,21 @@ +.loading-overlay { + left: 0px; + z-index: 50; + position: absolute; + flex-direction: column; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + background: rgba(255, 255, 255, 0.8); + + @media screen and (max-width: 575px) { + margin-top: 56px; + height: calc(100% - 56px); + } + + @media screen and (min-width: 576px) { + margin-top: 75px; + height: calc(100% - 75px); + } +} diff --git a/ui/app/css/itcss/components/menu.scss b/ui/app/css/itcss/components/menu.scss new file mode 100644 index 000000000..eb92a1b70 --- /dev/null +++ b/ui/app/css/itcss/components/menu.scss @@ -0,0 +1,59 @@ +.menu { + border-radius: 4px; + background: rgba($black, .8); + box-shadow: rgba($black, .15) 0 2px 2px 2px; + min-width: 150px; + color: $white; + + &__item { + padding: 18px; + display: flex; + flex-flow: row nowrap; + align-items: center; + position: relative; + font-weight: 300; + z-index: 201; + + @media screen and (max-width: 575px) { + padding: 14px; + } + + &--clickable { + cursor: pointer; + + &:hover { + background-color: rgba($white, .05); + } + + &:active { + background-color: rgba($white, .1); + } + } + + &__icon { + height: 16px; + width: 16px; + margin-right: 14px; + } + + &__text { + font-size: 16px; + line-height: 21px; + } + } + + &__divider { + background-color: $scorpion; + width: 100%; + height: 1px; + } + + &__close-area { + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 100; + } +} diff --git a/ui/app/css/itcss/components/modal.scss b/ui/app/css/itcss/components/modal.scss new file mode 100644 index 000000000..5bca4a07d --- /dev/null +++ b/ui/app/css/itcss/components/modal.scss @@ -0,0 +1,835 @@ +.modal > div:focus { + outline: none !important; +} + +// Buy Modal +.buy-modal-content { + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + font-family: Roboto; + padding: 0 16px; +} + +.buy-modal-content-option { + cursor: pointer; + color: #5B5D67; +} + +.qr-ellip-address, .ellip-address { + width: 247px; + border: none; + font-family: Roboto; + font-size: 14px; +} + +@media screen and (max-width: 575px) { + .buy-modal-content-title-wrapper { + justify-content: space-around; + width: 100%; + height: 100px; + } + + .buy-modal-content-title { + font-size: 26px; + margin-top: 15px; + } + + .buy-modal-content-options { + flex-direction: column; + padding: 5% 33%; + } + + .buy-modal-content-footer { + text-transform: uppercase; + width: 100%; + height: 50px; + } + + div.buy-modal-content-option { + display: flex; + flex-direction: column; + width: 80vw; + height: 15vh; + margin: 10px; + text-align: center; + border-radius: 6px; + border: 1px solid $black; + padding: 0% 7%; + justify-content: center; + + div.buy-modal-content-option-title { + font-size: 20px; + } + + div.buy-modal-content-option-subtitle { + font-size: 16px; + } + } +} + +@media screen and (min-width: 576px) { + .buy-modal-content-title-wrapper { + justify-content: space-around; + width: 100%; + height: 110px; + } + + .buy-modal-content-title { + font-size: 26px; + margin-top: 15px; + } + + .buy-modal-content-footer { + text-transform: uppercase; + width: 100%; + height: 50px; + } + + .buy-modal-content-options { + flex-direction: row; + margin: 20px 0 60px; + } + + div.buy-modal-content-option { + display: flex; + flex-direction: column; + width: 20vw; + height: 120px; + text-align: center; + border-radius: 6px; + border: 1px solid $black; + margin: 0 8px; + padding: 18px 0; + + div.buy-modal-content-option-title { + font-size: 20px; + margin-bottom: 12px; + + @media screen and (max-width: 679px) { + font-size: 14px; + } + + @media screen and (min-width: 1281px) { + font-size: 20px; + } + } + + div.buy-modal-content-option-subtitle { + font-size: 16px; + padding: 0 10px; + height: 25%; + + @media screen and (max-width: 679px) { + font-size: 10px; + padding: 0 10px; + margin-bottom: 5px; + line-height: 15px; + } + + @media screen and (min-width: 680px) { + font-size: 14px; + padding: 0 4px; + margin-bottom: 2px; + } + + @media screen and (min-width: 1281px) { + font-size: 16px; + padding: 0; + } + } + + div.buy-modal-content-footer { + margin-top: 8vh; + } + } +} + +// Edit Account Name Modal +.edit-account-name-modal-content { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; +} + +.edit-account-name-modal-cancel { + position: absolute; + top: 12px; + right: 20px; + font-size: 25px; +} + +.edit-account-name-modal-title { + margin: 15px; +} + +.edit-account-name-modal-save-button { + width: 33%; + height: 45px; + margin: 15px; + font-weight: 700; + margin-top: 25px; +} + +.edit-account-name-modal-input { + width: 90%; + height: 50px; + text-align: left; + margin: 10px; + padding: 10px; + font-size: 18px; +} + +// Account Modal Container +.account-modal-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; + padding: 5px 0 31px 0; + border: 1px solid $silver; + border-radius: 4px; + font-family: Roboto; + + button { + cursor: pointer; + } +} + +.account-modal-back { + color: $dusty-gray; + position: absolute; + top: 13px; + left: 17px; + cursor: pointer; + + &__text { + margin-top: 2px; + font-family: Roboto; + font-size: 14px; + line-height: 18px; + } +} + +.account-modal-close::after { + content: '\00D7'; + font-size: 40px; + color: $dusty-gray; + position: absolute; + top: 10px; + right: 12px; + cursor: pointer; +} + +.account-modal-container .identicon { + position: relative; + left: 0; + right: 0; + margin: 0 auto; + top: -32px; + margin-bottom: -32px; +} + + +// Account Details Modal + +.account-modal-container { + + .qr-header { + margin-top: 9px; + font-size: 20px; + } + + .qr-wrapper { + margin-top: 5px; + } + + .ellip-address-wrapper { + display: flex; + justify-content: center; + border: 1px solid $alto; + padding: 5px 10px; + font-family: Roboto; + margin-top: 7px; + width: 286px; + } + + .account-modal__button { + margin-top: 17px; + padding: 10px 22px; + width: 235px; + } +} + +.account-modal-divider { + width: 100%; + height: 1px; + margin: 19px 0 8px 0; + background-color: $alto; +} + +// Export Private Key Modal + +.account-modal-container .account-name { + margin-top: 9px; + font-size: 20px; +} + +.account-modal-container .modal-body-title { + margin-top: 16px; + margin-bottom: 16px; + font-size: 18px; +} + +.account-modal__name { + margin-top: 9px; + font-size: 20px; +} + +.private-key-password { + display: flex; + flex-direction: column; +} + +.private-key-password-label, .private-key-password-error { + color: $scorpion; + font-size: 14px; + line-height: 18px; + margin-bottom: 10px; +} + +.private-key-password-error { + color: $crimson; + margin-bottom: 0; +} + +.private-key-password-input { + padding: 10px 0 13px 17px; + font-size: 16px; + line-height: 21px; + width: 291px; + height: 44px; +} + +.private-key-password::-webkit-input-placeholder { + color: $dusty-gray; + font-family: Roboto; +} + +.private-key-password-warning { + border-radius: 8px; + background-color: #FFF6F6; + font-size: 12px; + font-weight: 500; + line-height: 15px; + color: $crimson; + width: 292px; + padding: 9px 15px; + margin-top: 18px; + font-family: Roboto; +} + +.export-private-key-buttons { + display: flex; + flex-direction: row; + justify-content: center; +} + +.export-private-key__button { + margin-top: 17px; + padding: 10px 22px; + width: 141px; + height: 54px; +} + +.export-private-key__button--cancel { + margin-right: 15px; +} + +.private-key-password-display-wrapper { + height: 80px; + width: 291px; + border: 1px solid $silver; + border-radius: 2px; +} + +.private-key-password-display-textarea { + color: $crimson; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + border: none; + height: 75px; + width: 100%; + overflow: hidden; + resize: none; + padding: 9px 13px 8px; + text-transform: uppercase; + font-weight: 300; +} + + +// New Account Modal +.new-account-modal-wrapper { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; + border: 1px solid $alto; + box-shadow: 0 0 2px 2px $alto; + font-family: Roboto; +} + +.new-account-modal-header { + background: $wild-sand; + width: 100%; + display: flex; + justify-content: center; + padding: 30px; + font-size: 22px; + color: $nile-blue; + height: 79px; +} + +.modal-close-x::after { + content: '\00D7'; + font-size: 2em; + color: $dusty-gray; + position: absolute; + top: 25px; + right: 17.5px; + font-family: sans-serif; + cursor: pointer; +} + +.new-account-modal-content { + width: 100%; + display: flex; + justify-content: center; + margin-top: 15px; + font-size: 17px; + color: $nile-blue; +} + +.new-account-modal-content.after-input { + margin-top: 15px; + line-height: 25px; +} + +.new-account-input-wrapper { + display: flex; + width: 100%; + justify-content: center; + padding-bottom: 2px; + margin-top: 13px; +} + +.new-account-input { + padding: 15px; + padding-bottom: 20px; + border-radius: 8px; + border: 1px solid $alto; + width: 100%; + font-size: 1em; + color: $dusty-gray; + font-family: Roboto; + font-size: 17px; + margin: 0 60px; +} + +// For reference on below placeholder selectors: https://stackoverflow.com/questions/2610497/change-an-html5-inputs-placeholder-color-with-css +.new-account-input::-webkit-input-placeholder { + color: $dusty-gray; +} + +.new-account-input:-moz-placeholder { + color: $dusty-gray; + opacity: 1; +} + +.new-account-input::-moz-placeholder { + color: $dusty-gray; + opacity: 1; +} + +.new-account-input:-ms-input-placeholder { + color: $dusty-gray; +} + +.new-account-input::-ms-input-placeholder { + color: $dusty-gray; +} + +.new-account-modal-content.button { + margin-top: 22px; + margin-bottom: 30px; + width: 113px; + height: 44px; +} + +.new-account-modal-wrapper .btn-clear { + font-size: 14px; + font-weight: 700; + background: $white; + border: 1px solid; + border-radius: 2px; + color: $tundora; + flex: 1; +} + +// Hide token confirmation + +.hide-token-confirmation { + min-height: 250.72px; + border-radius: 4px; + background-color: $white; + box-shadow: 0 1px 7px 0 rgba(0, 0, 0, .5); + + &__container { + padding: 24px 27px 21px; + display: flex; + flex-direction: column; + align-items: center; + } + + &__identicon { + margin-bottom: 10px; + } + + &__symbol { + color: $tundora; + font-family: Roboto; + font-size: 16px; + line-height: 24px; + text-align: center; + margin-bottom: 7.5px; + } + + &__title { + height: 30px; + width: 271.28px; + color: $tundora; + font-family: Roboto; + font-size: 22px; + line-height: 30px; + text-align: center; + margin-bottom: 10.5px; + } + + &__copy { + height: 41px; + width: 318px; + color: $scorpion; + font-family: Roboto; + font-size: 14px; + line-height: 18px; + text-align: center; + } + + &__buttons { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 15px; + width: 100%; + } + + &__button { + width: 141px; + margin: 0 5px; + } +} + +//Notification Modal + +.notification-modal-wrapper { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; + border: 1px solid $alto; + box-shadow: 0 0 2px 2px $alto; + font-family: Roboto; +} + +.notification-modal-header { + background: $wild-sand; + width: 100%; + display: flex; + justify-content: center; + padding: 30px; + font-size: 22px; + color: $nile-blue; + height: 79px; +} + +.notification-modal-message { + padding: 20px; +} + +.notification-modal-message { + width: 100%; + display: flex; + justify-content: center; + font-size: 17px; + color: $nile-blue; +} + +// Deposit Ether Modal +.deposit-ether-modal { + border-radius: 8px; + font-family: Roboto; + display: flex; + flex-flow: column; + height: 100%; + + &__header { + width: 100%; + border-radius: 8px 8px 0 0; + background-color: $mid-gray; + display: flex; + position: relative; + padding: 25px; + flex-flow: column; + align-items: flex-start; + + &__title { + color: $white; + font-size: 24px; + line-height: 32px; + } + + &__description { + color: $white; + font-size: 16px; + line-height: 22px; + margin-top: 10px; + } + + &__close::after { + content: '\00D7'; + font-size: 2em; + color: $white; + position: absolute; + top: 20.8px; + right: 28px; + cursor: pointer; + } + } + + &__buy-rows { + width: 100%; + padding: 33px; + padding-top: 0px; + display: flex; + flex-flow: column nowrap; + flex: 1; + overflow-y: auto; + + @media screen and (max-width: 575px) { + height: 0; + } + } + + &__buy-row { + border-bottom: 1px solid $alto; + display: flex; + justify-content: space-between; + align-items: center; + flex: 1; + padding-bottom: 25px; + padding-top: 25px; + + @media screen and (max-width: 575px) { + min-height: 360px; + flex-flow: column; + justify-content: center; + padding-top: 45px; + } + + &__back { + position: absolute; + top: 10px; + left: 0px; + } + + &__shapeshift-buy { + padding-top: 25px; + position: relative; + @media screen and (max-width: 575px) { + display: flex; + justify-content: space-between; + align-items: center; + flex: 1; + padding-bottom: 25px; + flex-flow: column; + justify-content: center; + padding-top: 20px; + min-height: 240px; + border: none; + } + } + + &__logo { + display: flex; + justify-content: center; + flex: 0.3 1 auto; + + @media screen and (min-width: 575px) { + min-width: 215px; + } + } + + &__coinbase-logo { + height: 40px; + width: 180px; + } + + &__shapeshift-logo { + height: 60px; + width: 174px; + } + + &__eth-logo { + border-radius: 50%; + width: 68px; + height: 68px; + border: 3px solid $tundora; + z-index: 25; + padding: 4px; + background-color: #fff; + } + + &__right { + display: flex; + } + + &__description { + color: $cape-cod; + flex: 0.5 1 auto; + + @media screen and (min-width: 575px) { + min-width: 315px; + } + + &__title { + font-size: 20px; + line-height: 30px; + } + + &__text { + font-size: 14px; + line-height: 22px; + margin-top: 7px; + } + } + + &__button { + display: flex; + justify-content: flex-end; + + @media screen and (min-width: 575px) { + min-width: 300px; + } + } + } + + &__buy-row:last-of-type { + border-bottom: 0px; + } + + &__deposit-button, .shapeshift-form__shapeshift-buy-btn { + height: 54px; + width: 257px; + border: 1px solid $curious-blue; + border-radius: 4px; + display: flex; + justify-content: center; + font-size: 16px; + color: $curious-blue; + background-color: $white; + } + + .shapeshift-form-wrapper { + display: flex; + flex-flow: column; + justify-content: center; + align-items: center; + margin-top: 28px; + flex: 1 0 auto; + + .shapeshift-form { + width: auto; + + &__caret { + width: auto; + flex: 1; + } + } + } + + .shapeshift-form__shapeshift-buy-btn { + margin-top: 10px; + } + + .simple-dropdown { + color: #5B5D67; + font-size: 16px; + font-weight: 300; + line-height: 21px; + border: 1px solid #D8D8D8; + background-color: #FFFFFF; + text-align: center; + width: 100%; + height: 45px; + line-height: 44px; + font-family: Montserrat Light; + } + + .simple-dropdown__selected { + text-align: center; + } +} + +//Notification Modal + +.notification-modal-wrapper { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; + border: 1px solid $alto; + box-shadow: 0 0 2px 2px $alto; + font-family: Roboto; +} + +.notification-modal-header { + background: $wild-sand; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 30px; + font-size: 22px; + color: $nile-blue; + height: 79px; +} + +.notification-modal-message { + padding: 20px; +} + +.notification-modal-message { + width: 100%; + display: flex; + justify-content: center; + font-size: 17px; + color: $nile-blue; +} diff --git a/ui/app/css/itcss/components/network.scss b/ui/app/css/itcss/components/network.scss new file mode 100644 index 000000000..d9a39b8d5 --- /dev/null +++ b/ui/app/css/itcss/components/network.scss @@ -0,0 +1,157 @@ +.network-component--disabled { + // border-color: transparent !important; + cursor: default; + + .fa-caret-down { + opacity: 0; + } +} + +.network-component.pointer { + border: 2px solid $silver; + border-radius: 82px; + padding: 3px; + flex: 0 0 auto; + + &.ethereum-network .menu-icon-circle div { + background-color: rgba(3, 135, 137, .7) !important; + } + + &.ropsten-test-network .menu-icon-circle div { + background-color: rgba(233, 21, 80, .7) !important; + } + + &.kovan-test-network .menu-icon-circle div { + background-color: rgba(105, 4, 150, .7) !important; + } + + &.rinkeby-test-network .menu-icon-circle div { + background-color: rgba(235, 179, 63, .7) !important; + } +} + +.dropdown-menu-item { + .menu-icon-circle, + .menu-icon-circle--active { + margin: 0 14px; + } +} + +.network-indicator { + display: flex; + align-items: center; + font-size: .6em; + + .fa-caret-down { + line-height: 15px; + font-size: 12px; + padding: 0 4px; + } +} + +.network-name { + padding: 0 4px; + font-family: Roboto; + font-size: 12px; + flex: 1 0 auto; + color: $tundora; + font-weight: 500; +} + +.network-droppo { + right: 2px; + + @media screen and (min-width: 576px) { + right: calc(((100% - 85vw) / 2) + 2px); + } + + @media screen and (min-width: 769px) { + right: calc(((100% - 80vw) / 2) + 2px); + } + + @media screen and (min-width: 1281px) { + right: calc(((100% - 65vw) / 2) + 2px); + } +} + +.network-name-item { + font-weight: 100; + flex: 1 0 auto; + color: $dusty-gray; +} + +.network-check, +.network-check__transparent { + color: $white; + margin-left: 7px; +} + +.network-check__transparent { + opacity: 0; + width: 16px; + margin: 0; +} + +.menu-icon-circle, +.menu-icon-circle--active { + background: none; + border-radius: 22px; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid transparent; + margin: 0 4px; +} + +.menu-icon-circle--active { + border: 1px solid $white; + background: rgba(100, 100, 100, .4); +} + +.menu-icon-circle div, +.menu-icon-circle--active div { + height: 12px; + width: 12px; + border-radius: 17px; +} + +.menu-icon-circle--active div { + opacity: 1; +} + +.network-dropdown-header { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.network-dropdown-divider { + width: 100%; + height: 1px; + margin: 10px 0; + background-color: $scorpion; +} + +.network-dropdown-title { + height: 25px; + width: 75px; + color: $white; + font-family: Roboto; + font-size: 18px; + line-height: 25px; + text-align: center; +} + +.network-dropdown-content { + height: 36px; + width: 265px; + color: $dusty-gray; + font-family: Roboto; + font-size: 14px; + line-height: 18px; +} + +.network-caret { + margin: 0 8px 2px; +} diff --git a/ui/app/css/itcss/components/new-account.scss b/ui/app/css/itcss/components/new-account.scss new file mode 100644 index 000000000..c5e4ea761 --- /dev/null +++ b/ui/app/css/itcss/components/new-account.scss @@ -0,0 +1,192 @@ +.new-account { + width: 376px; + background-color: #FFFFFF; + box-shadow: 0 0 7px 0 rgba(0,0,0,0.08); + z-index: 25; + padding-bottom: 31px; + + &__header { + display: flex; + flex-flow: column; + border-bottom: 1px solid $geyser; + } + + &__title { + color: $tundora; + font-family: Roboto; + font-size: 32px; + font-weight: 500; + line-height: 43px; + margin-top: 22px; + margin-left: 29px; + } + + &__tabs { + margin-left: 22px; + display: flex; + margin-top: 10px; + + &__tab { + height: 54px; + width: 75px; + padding: 15px 10px; + color: $dusty-gray; + font-family: Roboto; + font-size: 18px; + line-height: 24px; + text-align: center; + } + + &__tab:first-of-type { + margin-right: 20px; + } + + &__unselected:hover { + color: $black; + border-bottom: none; + } + + &__selected { + color: $curious-blue; + border-bottom: 3px solid $curious-blue; + } + } + +} + +.new-account-import-form { + &__select-section { + display: flex; + justify-content: space-evenly; + align-items: center; + margin-top: 29px; + } + + &__select-label { + color: $scorpion; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + } + + &__select { + height: 54px; + width: 210px; + border: 1px solid #D2D8DD; + border-radius: 4px; + background-color: #FFFFFF; + display: flex; + align-items: center; + + .Select-control, + .Select-control:hover { + height: 100%; + border: none; + box-shadow: none; + + .Select-value { + display: flex; + align-items: center; + } + } + } + + &__instruction { + color: $scorpion; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + align-self: flex-start; + margin-left: 30px; + } + + &__private-key { + display: flex; + flex-flow: column; + align-items: center; + margin-top: 34px; + } + + &__input-password { + height: 54px; + width: 315px; + border: 1px solid $geyser; + border-radius: 4px; + background-color: $white; + margin-top: 16px; + color: $scorpion; + font-family: Roboto; + font-size: 16px; + padding: 0px 20px; + } + + &__json { + display: flex; + flex-flow: column; + align-items: center; + margin-top: 29px; + } +} + +.new-account-create-form { + display: flex; + flex-flow: column; + align-items: center; + + &__input-label { + color: $scorpion; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + margin-top: 29px; + align-self: flex-start; + margin-left: 30px; + } + + &__input { + height: 54px; + width: 315.84px; + border: 1px solid $geyser; + border-radius: 4px; + background-color: $white; + color: $scorpion; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + margin-top: 15px; + padding: 0px 20px; + } + + &__buttons { + margin-top: 39px; + display: flex; + width: 100%; + justify-content: space-evenly; + } + + &__button-cancel, + &__button-create { + height: 55px; + width: 150px; + border-radius: 2px; + background-color: #FFFFFF; + } + + &__button-cancel { + border: 1px solid $dusty-gray; + color: $dusty-gray; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + text-align: center; + } + + &__button-create { + border: 1px solid $curious-blue; + color: $curious-blue; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + text-align: center; + } +}
\ No newline at end of file diff --git a/ui/app/css/itcss/components/newui-sections.scss b/ui/app/css/itcss/components/newui-sections.scss new file mode 100644 index 000000000..1c26882b5 --- /dev/null +++ b/ui/app/css/itcss/components/newui-sections.scss @@ -0,0 +1,269 @@ +/* + NewUI Container Elements + */ + +// Component Colors +$tx-view-bg: $white; +$wallet-view-bg: $alabaster; + +// Main container +.main-container { + // position: absolute; + z-index: $main-container-z-index; + font-family: Roboto; + display: flex; + flex-wrap: wrap; + align-items: stretch; +} + +.main-container::-webkit-scrollbar { + display: none; +} + +// tx view + +.tx-view { + flex: 63.5 0 66.5%; + background: $tx-view-bg; + + // No title on mobile + @media screen and (max-width: 575px) { + .identicon-wrapper { + display: none; + } + + .account-name { + display: none; + } + } +} + +.open-in-browser { + cursor: pointer; + display: flex; + justify-content: center; +} + +// wallet view and sidebar + +.wallet-view { + display: flex; + flex-direction: column; + flex: 32 1 32%; + width: 0; + background: $wallet-view-bg; + z-index: 200; + position: relative; + + @media screen and (min-width: 576px) { + overflow-y: scroll; + overflow-x: hidden; + } + + .wallet-view-account-details { + flex: 0 0 auto; + } + + &__name-container { + flex: 0 0 auto; + cursor: pointer; + width: 100%; + } + + &__keyring-label { + height: 50px; + color: $dusty-gray; + font-family: Roboto; + font-size: 10px; + text-align: right; + padding: 17px 20px 0; + box-sizing: border-box; + } + + &__details-button { + font-size: 10px; + border-radius: 17px; + background-color: transparent; + margin: 0 auto; + padding: 4px 12px; + flex: 0 0 auto; + } + + &__address { + border-radius: 3px; + background-color: $alto; + color: $scorpion; + font-size: 14px; + line-height: 12px; + padding: 4px 12px; + margin: 24px auto; + font-weight: 300; + cursor: pointer; + flex: 0 0 auto; + } + + &__sidebar-close { + + @media screen and (max-width: 575px) { + &::after { + content: '\00D7'; + font-size: 40px; + color: $tundora; + position: absolute; + top: 12px; + left: 12px; + cursor: pointer; + } + } + } + + &__add-token-button { + flex: 0 0 auto; + margin: 36px auto; + background: none; + padding: .7rem 2rem; + transition: border-color .3s ease; + + &:hover { + border-color: $curious-blue; + } + } +} + +@media screen and (min-width: 576px) { + .wallet-view::-webkit-scrollbar { + display: none; + } +} + +.wallet-view-title-wrapper { + flex: 0 0 25px; +} + +.wallet-view-title { + margin-left: 15px; + font-size: 16px; + + // No title on mobile + @media screen and (max-width: 575px) { + display: none; + } +} + +.wallet-view.sidebar { + flex: 1 0 230px; + background: rgb(250, 250, 250); + z-index: $sidebar-z-index; + position: fixed; + top: 66px; + left: 0; + right: 0; + bottom: 0; + opacity: 1; + visibility: visible; + will-change: transform; + overflow-y: auto; + box-shadow: rgba(0, 0, 0, .15) 2px 2px 4px; + width: 85%; + height: calc(100% - 56px); +} + +.sidebar-overlay { + z-index: $sidebar-overlay-z-index; + position: fixed; + // top: 41px; + height: 100%; + width: 100%; + left: 0; + right: 0; + bottom: 0; + opacity: 1; + visibility: visible; + background-color: rgba(0, 0, 0, .3); +} + +// main-container media queries + +@media screen and (min-width: 576px) { + .lap-visible { + display: flex; + } + + .phone-visible { + display: none; + } + + .main-container { + // margin-top: 6.9vh; + width: 85vw; + height: 90vh; + box-shadow: 0 0 7px 0 rgba(0, 0, 0, .08); + } +} + +@media screen and (min-width: 769px) { + .main-container { + // margin-top: 6.9vh; + width: 80vw; + height: 82vh; + box-shadow: 0 0 7px 0 rgba(0, 0, 0, .08); + } +} + +@media screen and (min-width: 1281px) { + .main-container { + // margin-top: 6.9vh; + width: 62vw; + height: 82vh; + box-shadow: 0 0 7px 0 rgba(0, 0, 0, .08); + } +} + +@media screen and (max-width: 575px) { + .lap-visible { + display: none; + } + + .phone-visible { + display: flex; + } + + .main-container { + // margin-top: 41px; + height: 100%; + width: 100%; + overflow-y: auto; + background-color: $white; + } +} + +// wallet view +.account-name { + font-size: 24px; + font-weight: 300; + line-height: 20px; + color: $black; + margin-top: 8px; + margin-bottom: .9rem; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + padding: 0 8px; + text-align: center; +} + +// account options dropdown +.account-options-menu { + align-items: center; + justify-content: flex-start; + margin: 5% 7% 0%; +} + +.fiat-amount { + text-transform: uppercase; +} + +.token-balance__amount { + padding-right: 6px; +} diff --git a/ui/app/css/itcss/components/request-signature.scss b/ui/app/css/itcss/components/request-signature.scss new file mode 100644 index 000000000..d81099bfa --- /dev/null +++ b/ui/app/css/itcss/components/request-signature.scss @@ -0,0 +1,230 @@ +.request-signature { + &__container { + width: 380px; + border-radius: 8px; + background-color: $white; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.08); + display: flex; + flex-flow: column nowrap; + z-index: 25; + align-items: center; + font-family: Roboto; + position: relative; + height: 100%; + + @media screen and (max-width: $break-small) { + width: 100%; + top: 0; + box-shadow: none; + } + + @media screen and (min-width: $break-large) { + max-height: 620px; + } + } + + &__header { + height: 64px; + width: 100%; + position: relative; + display: flex; + flex-flow: column; + justify-content: center; + align-items: center; + flex: 0 0 auto; + } + + &__header-background { + position: absolute; + background-color: $athens-grey; + z-index: 2; + width: 100%; + height: 100%; + } + + &__header__text { + height: 29px; + width: 179px; + color: #5B5D67; + font-family: Roboto; + font-size: 22px; + font-weight: 300; + line-height: 29px; + z-index: 3; + } + + &__header__tip-container { + width: 100%; + display: flex; + justify-content: center; + } + + &__header__tip { + height: 25px; + width: 25px; + background: $athens-grey; + transform: rotate(45deg); + position: absolute; + bottom: -8px; + z-index: 1; + } + + &__account-info { + display: flex; + justify-content: space-between; + margin-top: 18px; + margin-bottom: 20px; + } + + &__account { + color: $dusty-gray; + margin-left: 17px; + } + + &__account-text { + font-size: 14px; + } + + &__balance { + color: $dusty-gray; + margin-right: 17px; + width: 124px; + } + + &__balance-text { + text-align: right; + font-size: 14px; + } + + &__balance-value { + text-align: right; + margin-top: 2.5px; + } + + &__request-icon { + margin-top: 25px; + } + + &__body { + width: 100%; + height: 100%; + display: flex; + flex-flow: column; + flex: 1 1 auto; + height: 0; + } + + &__request-info { + display: flex; + justify-content: center; + } + + &__headline { + height: 48px; + width: 240px; + color: $tundora; + font-family: Roboto; + font-size: 18px; + font-weight: 300; + line-height: 24px; + text-align: center; + margin-top: 20px; + } + + &__notice, + &__warning { + font-family: "Avenir Next"; + font-size: 14px; + line-height: 19px; + text-align: center; + margin-top: 41px; + margin-bottom: 11px; + width: 100%; + } + + &__notice { + color: $dusty-gray; + } + + &__warning { + color: $crimson; + } + + &__rows { + height: 100%; + overflow-y: scroll; + overflow-x: hidden; + border-top: 1px solid $geyser; + display: flex; + flex-flow: column; + } + + &__row { + display: flex; + flex-flow: column; + } + + &__row-title { + width: 80px; + color: $dusty-gray; + font-family: Roboto; + font-size: 16px; + line-height: 22px; + margin-top: 12px; + margin-left: 18px; + width: 100%; + } + + &__row-value { + color: $scorpion; + font-family: Roboto; + font-size: 14px; + line-height: 19px; + width: 100%; + overflow-wrap: break-word; + border-bottom: 1px solid #d2d8dd; + padding: 6px 18px 15px; + } + + &__footer { + width: 100%; + display: flex; + align-items: center; + justify-content: space-evenly; + font-size: 22px; + position: relative; + flex: 0 0 auto; + border-top: 1px solid $geyser; + + &__cancel-button, + &__sign-button { + display: flex; + align-items: center; + justify-content: center; + flex: 1 0 auto; + font-family: Roboto; + font-size: 16px; + font-weight: 300; + height: 55px; + line-height: 32px; + cursor: pointer; + border-radius: 2px; + box-shadow: none; + max-width: 162px; + margin: 12px; + } + + &__cancel-button { + background: none; + border: 1px solid $dusty-gray; + margin-right: 6px; + } + + &__sign-button { + background-color: $caribbean-green; + border-width: 0; + color: $white; + margin-left: 6px; + } + } +}
\ No newline at end of file diff --git a/ui/app/css/index.css b/ui/app/css/itcss/components/sections.scss index b40d48b5d..388aea175 100644 --- a/ui/app/css/index.css +++ b/ui/app/css/itcss/components/sections.scss @@ -1,226 +1,26 @@ -/* -faint orange (textfield shades) #FAF6F0 -light orange (button shades): #F5C26D -dark orange (text): #F5A623 -borders/font/any gray: #4A4A4A -*/ - -/* -application specific styles -*/ - -* { - box-sizing: border-box; -} - -html, body { - font-family: 'Montserrat Regular', Arial; - color: #4D4D4D; - font-weight: 300; - line-height: 1.4em; - background: #F7F7F7; - margin: 0; - padding: 0; -} - -html { - min-height: 500px; -} - -.app-root { - overflow: hidden; - position: relative -} - -.app-primary { - display: flex; -} - -input:focus, textarea:focus { - outline: none; -} - -.full-size { - height: 100%; - width: 100%; -} - -.full-width { - width: 100%; -} - -.full-height { - height: 100%; -} - -.full-flex-height { - display: flex; - flex: 1 1 auto; - flex-direction: column; -} - -#app-content { - overflow-x: hidden; - min-width: 357px; - height: 100%; - display: flex; - flex-direction: column; -} - -button, input[type="submit"] { - font-family: 'Montserrat Bold'; - outline: none; - cursor: pointer; - padding: 8px 12px; - border: none; - color: white; - transform-origin: center center; - transition: transform 50ms ease-in; - /* default orange */ - background: rgba(247, 134, 28, 1); - box-shadow: 0px 3px 6px rgba(247, 134, 28, 0.36); -} - -.btn-green, input[type="submit"].btn-green { - background: rgba(106, 195, 96, 1); - box-shadow: 0px 3px 6px rgba(106, 195, 96, 0.36); -} - -.btn-red { - background: rgba(254, 35, 17, 1); - box-shadow: 0px 3px 6px rgba(254, 35, 17, 0.36); -} - -button[disabled], input[type="submit"][disabled] { - cursor: not-allowed; - background: rgba(197, 197, 197, 1); - box-shadow: 0px 3px 6px rgba(197, 197, 197, 0.36); -} - -button.spaced { - margin: 2px; -} +// Old scss, do not lint - clean up later +/* stylelint-disable */ -button:not([disabled]):hover, input[type="submit"]:not([disabled]):hover { - transform: scale(1.1); -} -button:not([disabled]):active, input[type="submit"]:not([disabled]):active { - transform: scale(0.95); -} - -.grow-on-hover:hover { - transform: scale(1.05); -} - -a { - text-decoration: none; - color: inherit; -} - -a:hover{ - color: #df6b0e; -} /* -app +App Sections + TODO: Move into separate files. */ -.active { - color: #909090; -} - -button.primary { - padding: 8px 12px; - background: #F7861C; - box-shadow: 0px 3px 6px rgba(247, 134, 28, 0.36); - color: white; - font-size: 1.1em; - font-family: 'Montserrat Regular'; - text-transform: uppercase; -} - -button.btn-thin { - border: 1px solid; - border-color: #4D4D4D; - color: #4D4D4D; - background: rgb(255, 174, 41); - border-radius: 4px; - min-width: 200px; - margin: 12px 0; - padding: 6px; - font-size: 13px; -} - -.app-header { - padding: 6px 8px; -} - -.app-header h1 { - font-family: 'Montserrat Regular'; - text-transform: uppercase; - color: #AEAEAE; -} - -h2.page-subtitle { - font-family: 'Montserrat Regular'; - text-transform: uppercase; - color: #AEAEAE; - font-size: 1em; - margin: 12px; -} - -.app-footer { - padding-bottom: 10px; - align-items: center; -} - -.identicon { - height: 46px; - width: 46px; - background-size: cover; - border-radius: 100%; - border: 3px solid gray; -} - +/* initialize */ textarea.twelve-word-phrase { padding: 12px; width: 300px; height: 140px; font-size: 16px; - background: white; + background: $white; resize: none; } -.network-indicator { - display: flex; - align-items: center; - font-size: 0.6em; - -} - -.network-name { - width: 5.2em; - line-height: 9px; - text-rendering: geometricPrecision; -} - -.check { - margin-left: 12px; - color: #F7861C; - flex: 1 0 auto; - display: flex; - justify-content: flex-end; -} -/* -app sections -*/ - -/* initialize */ - .initialize-screen hr { width: 60px; margin: 12px; - border-color: #F7861C; + border-color: #f7861c; border-style: solid; } @@ -239,12 +39,13 @@ app sections /* unlock */ .error { + // color: #e20202; color: #f7861c; margin-bottom: 9px; } .warning { - color: #FFAE00; + color: #ffae00; } .lock { @@ -254,9 +55,10 @@ app sections .lock.locked { transform: scale(1.5); - opacity: 0.0; + opacity: 0; transition: opacity 400ms ease-in, transform 400ms ease-in; } + .lock.unlocked { transform: scale(1); opacity: 1; @@ -267,15 +69,18 @@ app sections transform: scaleX(1) translateX(0); transition: transform 250ms ease-in; } + .lock.unlocked .lock-top { transform: scaleX(-1) translateX(-12px); transition: transform 250ms ease-in; } + .lock.unlocked:hover { border-radius: 4px; background: #e5e5e5; border: 1px solid #b1b1b1; } + .lock.unlocked:active { background: #c3c3c3; } @@ -295,55 +100,46 @@ app sections .unlock-screen input[type=password] { width: 260px; - /*height: 36px; - margin-bottom: 24px; - padding: 8px;*/ } -.sizing-input{ +.sizing-input { font-size: 14px; height: 30px; padding-left: 5px; } -.editable-label{ + +.editable-label { display: flex; } + /* Webkit */ + .unlock-screen input::-webkit-input-placeholder { text-align: center; font-size: 1.2em; } + /* Firefox 18- */ + .unlock-screen input:-moz-placeholder { text-align: center; font-size: 1.2em; } + /* Firefox 19+ */ + .unlock-screen input::-moz-placeholder { text-align: center; font-size: 1.2em; } + /* IE */ + .unlock-screen input:-ms-input-placeholder { text-align: center; font-size: 1.2em; } -input.large-input, textarea.large-input { - /*margin-bottom: 24px;*/ - padding: 8px; -} - -input.large-input { - height: 36px; -} - -.letter-spacey { - letter-spacing: 0.1em; -} - - - /* accounts */ .accounts-section { @@ -351,7 +147,7 @@ input.large-input { } .accounts-section .horizontal-line { - margin: 0px 18px; + margin: 0 18px; } .accounts-list-option { @@ -368,7 +164,7 @@ input.large-input { } .unconftx-link .fa-arrow-right { - margin: 0px -8px 0px 8px; + margin: 0 -8px 0px 8px; } /* identity panel */ @@ -395,7 +191,7 @@ input.large-input { .identity-panel i { margin-top: 32px; margin-right: 6px; - color: #B9B9B9; + color: #b9b9b9; } .identity-panel .arrow-right { @@ -406,34 +202,33 @@ input.large-input { } .identity-copy.flex-column { - flex: 0.25 0 auto; + flex: .25 0 auto; justify-content: center; } /* accounts screen */ .identity-section { - } .identity-section .identity-panel { - background: #E9E9E9; - border-bottom: 1px solid #B1B1B1; + background: #e9e9e9; + border-bottom: 1px solid #b1b1b1; cursor: pointer; } .identity-section .identity-panel.selected { - background: white; - color: #F3C83E; + background: $white; + color: #f3c83e; } .identity-section .identity-panel.selected .identicon { - border-color: orange; + border-color: $orange; } .identity-section .accounts-list-option:hover, .identity-section .accounts-list-option.selected { - background:white; + background: $white; } /* account detail screen */ @@ -441,9 +236,7 @@ input.large-input { .account-detail-section { display: flex; flex-wrap: wrap; - overflow-x: hidden; overflow-y: auto; - max-height: 465px; flex-direction: inherit; } @@ -451,15 +244,14 @@ input.large-input { flex-grow: 10; } -.name-label{ - +.name-label { } .unapproved-tx-icon { height: 16px; width: 16px; background: rgb(47, 174, 244); - border-color: #AEAEAE; + border-color: $silver-chalice; border-radius: 13px; } @@ -467,6 +259,7 @@ input.large-input { height: 100%; visibility: hidden; } + .editing-label { display: flex; justify-content: flex-start; @@ -474,8 +267,9 @@ input.large-input { margin-bottom: 2px; font-size: 11px; text-rendering: geometricPrecision; - color: #F7861C; + color: #f7861c; } + .name-label:hover .edit-text { visibility: visible; } @@ -487,48 +281,33 @@ input.large-input { margin: 12px; margin-bottom: 24px; border-radius: 4px; - border: 2px solid #F3C83E; - background: #FAF6F0; -} - -/* Send Screen */ - -.send-screen { - -} - -.send-screen section { - margin: 8px 16px; -} - -.send-screen input { - width: 100%; - font-size: 12px; + border: 2px solid #f3c83e; + background: #faf6f0; } /* Ether Balance Widget */ .ether-balance-amount { - color: #F7861C; + color: #f7861c; } .ether-balance-label { - color: #ABA9AA; + color: #aba9aa; } /* Info screen */ -.info-gray{ - font-family: 'Montserrat Regular'; +.info-gray { + font-family: Roboto; text-transform: uppercase; - color: #AEAEAE; + color: $silver-chalice; } -.icon-size{ +.icon-size { width: 20px; } -.info{ - font-family: 'Montserrat Regular', Arial; +.info { + font-family: Roboto, Arial; padding-bottom: 10px; display: inline-block; padding-left: 5px; @@ -540,7 +319,6 @@ input.large-input { align-items: center; } - .custom-radio-selected { width: 17px; height: 17px; @@ -549,7 +327,7 @@ input.large-input { border-radius: 15px; border-width: 5px; background: rgba(247, 134, 28, 1); - border-color: #F7F7F7; + border-color: #f7f7f7; } .custom-radio-inactive { @@ -558,72 +336,59 @@ input.large-input { border: solid; border-width: 1px; border-radius: 24px; - border-color: #AEAEAE; + border-color: $silver-chalice; } .radio-titles { color: rgba(247, 134, 28, 1); } -.radio-titles-subtext { - -} - -.selected-exchange { - -} - -.buy-radio { - -} - -.eth-warning{ +.eth-warning { transition: opacity 400ms ease-in, transform 400ms ease-in; } -.buy-subview{ +.buy-subview { transition: opacity 400ms ease-in, transform 400ms ease-in; } -.input-container:hover .edit-text{ +.input-container:hover .edit-text { visibility: visible; } -.buy-inputs{ - font-family: 'Montserrat Light'; +.buy-inputs { + font-family: Roboto; font-size: 13px; height: 20px; background: transparent; box-sizing: border-box; border: solid; border-color: transparent; - border-width: 0.5px; + border-width: .5px; border-radius: 2px; - } -.input-container:hover .buy-inputs{ + +.input-container:hover .buy-inputs { box-sizing: inherit; border: solid; - border-color: #F7861C; - border-width: 0.5px; + border-color: #f7861c; + border-width: .5px; border-radius: 2px; } -.buy-inputs:focus{ +.buy-inputs:focus { border: solid; - border-color: #F7861C; - border-width: 0.5px; + border-color: #f7861c; + border-width: .5px; border-radius: 2px; } .activeForm { - background: #F7F7F7; + background: #f7f7f7; border: none; border-radius: 8px 8px 0px 0px; width: 50%; text-align: center; padding-bottom: 4px; - } .inactiveForm { @@ -635,19 +400,19 @@ input.large-input { } .ex-coins { - font-family: 'Montserrat Regular'; + font-family: Roboto; text-transform: uppercase; text-align: center; font-size: 33px; width: 118px; height: 42px; padding: 1px; - color: #4D4D4D; + color: #4d4d4d; } -.marketinfo{ - font-family: 'Montserrat light'; - color: #AEAEAE; +.marketinfo { + font-family: Roboto; + color: $silver-chalice; font-size: 15px; line-height: 17px; } @@ -662,52 +427,50 @@ input.large-input { overflow: scroll; } -.icon-control .fa-refresh{ +.icon-control .fa-refresh { visibility: hidden; } -.icon-control:hover .fa-refresh{ +.icon-control:hover .fa-refresh { visibility: visible; } -.icon-control:hover .fa-chevron-right{ +.icon-control:hover .fa-chevron-right { visibility: hidden; } .inactive { - color: #AEAEAE; + color: $silver-chalice; } -.inactive button{ - background: #AEAEAE; - color: white; +.inactive button { + background: $silver-chalice; + color: $white; } -.ellip-address { +.qr-ellip-address, .ellip-address { overflow: hidden; text-overflow: ellipsis; - width: 5em; - font-size: 14px; - font-family: "Montserrat Light"; - margin-left: 5px; } .qr-header { - font-size: 25px; - margin-top: 40px; + font-size: 25px; + margin-top: 40px; } .qr-message { font-size: 12px; - color: #F7861C; + color: #f7861c; } div.message-container > div:first-child { margin-top: 18px; font-size: 15px; - color: #4D4D4D; + color: #4d4d4d; } .pop-hover:hover { - transform: scale(1.1); + transform: scale(1.1); } + +/* stylelint-enable */ diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss new file mode 100644 index 000000000..7a6e2823b --- /dev/null +++ b/ui/app/css/itcss/components/send.scss @@ -0,0 +1,868 @@ +.send-screen-wrapper { + display: flex; + flex-flow: column nowrap; + z-index: 25; + font-family: Roboto; + + @media screen and (max-width: $break-small) { + width: 100%; + overflow-y: auto; + } + + section { + flex: 0 0 auto; + } +} + +.send-screen-card { + background-color: #fff; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .08); + padding: 46px 40.5px 26px; + position: relative; + // top: -26px; + align-items: center; + display: flex; + flex-flow: column nowrap; + width: 498px; + flex: 1 0 auto; + + @media screen and (max-width: $break-small) { + top: 0; + width: 100%; + box-shadow: none; + padding: 12px; + } +} + +/* Send Screen */ + +.send-screen section { + margin: 4px 16px; +} + +.send-screen input { + width: 100%; + font-size: 12px; +} + +.send-eth-icon { + border-radius: 50%; + width: 70px; + height: 70px; + border: 1px solid $alto; + box-shadow: 0 0 4px 0 rgba(0, 0, 0, .2); + position: absolute; + top: -35px; + z-index: 25; + padding: 4px; + background-color: $white; + + @media screen and (max-width: $break-small) { + position: relative; + top: 0; + } +} + +.send-screen-input-wrapper { + width: 95%; + position: relative; + + .fa-bolt { + padding-right: 4px; + } + + .large-input { + border: 1px solid $dusty-gray; + border-radius: 4px; + margin: 4px 0 20px; + font-size: 16px; + line-height: 22.4px; + font-family: Roboto; + } + + .send-screen-gas-input { + border: 1px solid transparent; + } + + &__error-message { + display: none; + } + + &--error { + input, + .send-screen-gas-input { + border-color: $red !important; + } + + .send-screen-input-wrapper__error-message { + display: block; + position: absolute; + bottom: 4px; + font-size: 12px; + line-height: 12px; + left: 8px; + color: $red; + } + } + + .send-screen-input-wrapper__error-message { + display: block; + position: absolute; + bottom: 4px; + font-size: 12px; + line-height: 12px; + left: 8px; + color: $red; + } +} + +.send-screen-input { + width: 100%; +} + +.send-screen-gas-input { + width: 100%; + height: 41px; + border-radius: 3px; + background-color: #f3f3f3; + border-width: 0; + border-style: none; + display: flex; + justify-content: space-between; + align-items: center; + padding-left: 10px; + padding-right: 12px; + font-size: 16px; + color: $scorpion; +} + +.send-screen-amount-labels { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.send-screen-gas-labels { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.currency-toggle { + &__item { + color: $curious-blue; + cursor: pointer; + + &--selected { + color: $black; + cursor: default; + } + } +} + +.send-screen-gas-input-customize { + color: $curious-blue; + font-size: 12px; + cursor: pointer; +} + +.gas-tooltip-close-area { + position: fixed; + top: 0; + left: 0; + z-index: 1000; + width: 100%; + height: 100%; +} + +.customize-gas-tooltip-container { + position: absolute; + bottom: 50px; + width: 237px; + height: 307px; + background-color: $white; + opacity: 1; + box-shadow: $alto 0 0 5px; + z-index: 1050; + padding: 13px 19px; + font-size: 16px; + border-radius: 4px; + font-family: "Lato"; + font-weight: 500; +} + +.gas-tooltip-arrow { + height: 25px; + width: 25px; + z-index: 1200; + background: $white; + position: absolute; + transform: rotate(45deg); + left: 107px; + top: 294px; + box-shadow: 2px 2px 2px $alto; +} + +.customize-gas-tooltip-container input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; +} + +.customize-gas-tooltip-container input[type="number"]:hover::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; +} + +.customize-gas-tooltip { + position: relative; +} + +.gas-tooltip { + display: flex; + justify-content: center; +} + +.gas-tooltip-label { + font-size: 16px; + color: $tundora; +} + +.gas-tooltip-header { + padding-bottom: 12px; +} + +.gas-tooltip-input-label { + margin-bottom: 5px; +} + +.gas-tooltip-input-label i { + color: $silver-chalice; + margin-left: 6px; +} + +.customize-gas-input { + width: 178px; + height: 28px; + border: 1px solid $alto; + font-size: 16px; + color: $nile-blue; + padding-left: 8px; +} + +.customize-gas-input-wrapper { + position: relative; +} + +.gas-tooltip-input-detail { + position: absolute; + top: 4px; + right: 26px; + font-size: 12px; + color: $silver-chalice; +} + +.gas-tooltip-input-arrows { + position: absolute; + top: 0; + right: 4px; + width: 17px; + height: 28px; + border: 1px solid #dadada; + border-left: 0; + display: flex; + flex-direction: column; + color: #9b9b9b; + font-size: .8em; + padding: 1px 4px; + cursor: pointer; +} + +.token-gas { + &__amount { + display: inline-block; + margin-right: 4px; + } + + &__symbol { + display: inline-block; + } +} + +.send-screen { + &__title { + color: $scorpion; + font-size: 18px; + line-height: 29px; + } + + &__subtitle { + margin: 10px 0 20px; + font-size: 14px; + line-height: 24px; + } + + &__send-button, + &__cancel-button { + width: 163px; + text-align: center; + } + + &__send-button__disabled { + opacity: .5; + cursor: auto; + } +} + +.send-token { + display: flex; + flex-flow: column nowrap; + z-index: 25; + font-family: Roboto; + + &__content { + width: 498px; + height: 605px; + background-color: #fff; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .08); + padding: 46px 40.5px 26px; + position: relative; + // top: -26px; + align-items: center; + display: flex; + flex-flow: column nowrap; + flex: 1 0 auto; + + @media screen and (max-width: $break-small) { + top: 0; + width: 100%; + box-shadow: none; + padding: 12px; + } + } + + .identicon { + position: absolute; + top: -35px; + z-index: 25; + + @media screen and (max-width: $break-small) { + position: relative; + top: 0; + flex: 0 0 auto; + } + } + + &__title { + color: $scorpion; + font-size: 18px; + line-height: 29px; + } + + &__description, + &__balance-text, + &__token-symbol { + margin-top: 10px; + font-size: 14px; + line-height: 24px; + text-align: center; + } + + &__token-balance { + font-size: 40px; + line-height: 40px; + margin-top: 13px; + + .token-balance__amount { + padding-right: 12px; + } + } + + &__button-group { + display: flex; + flex-flow: column nowrap; + align-items: center; + flex: 0 0 auto; + + @media screen and (max-width: $break-small) { + margin-top: 24px; + } + + button { + width: 163px; + } + } +} + +.confirm-send-token { + &__hero-amount-wrapper { + width: 100%; + } +} + +.send-v2 { + &__container { + // height: 701px; + width: 380px; + border-radius: 8px; + background-color: $white; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .08); + display: flex; + flex-flow: column nowrap; + z-index: 25; + align-items: center; + font-family: Roboto; + position: relative; + + @media screen and (max-width: $break-small) { + width: 100%; + top: 0; + box-shadow: none; + flex: 1 1 auto; + } + } + + &__send-header-icon-container { + z-index: 25; + + @media screen and (max-width: $break-small) { + position: relative; + top: 0; + } + } + + &__send-header-icon { + border-radius: 50%; + width: 48px; + height: 48px; + border: 1px solid $alto; + z-index: 25; + padding: 4px; + background-color: $white; + } + + &__send-arrow-icon { + color: #f28930; + transform: rotate(-45deg); + position: absolute; + top: -2px; + left: 0; + font-size: 1.12em; + } + + &__arrow-background { + background-color: $white; + height: 14px; + width: 14px; + position: absolute; + top: 52px; + left: 199px; + border-radius: 50%; + z-index: 100; + + @media screen and (max-width: $break-small) { + top: 36px; + } + } + + &__header { + height: 88px; + width: 380px; + background-color: $athens-grey; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + @media screen and (max-width: $break-small) { + height: 59px; + width: 100vw; + } + } + + &__header-tip { + height: 25px; + width: 25px; + background: $athens-grey; + position: absolute; + transform: rotate(45deg); + left: 178px; + top: 75px; + + @media screen and (max-width: $break-small) { + top: 46px; + left: 0; + right: 0; + margin: 0 auto; + } + } + + &__title { + color: $scorpion; + font-size: 22px; + line-height: 29px; + text-align: center; + margin-top: 25px; + } + + &__copy { + color: $gray; + font-size: 14px; + font-weight: 300; + line-height: 19px; + text-align: center; + margin-top: 10px; + width: 287px; + } + + &__error { + font-size: 12px; + line-height: 12px; + left: 8px; + color: $red; + } + + &__error-border { + color: $red; + } + + &__form { + padding: 13px 0; + width: 100%; + overflow-y: auto; + + @media screen and (max-width: $break-small) { + padding: 13px 0; + margin: 0; + height: 0; + overflow-y: auto; + flex: 1 1 auto; + } + } + + &__form-header, + &__form-header-copy { + width: 100%; + display: flex; + flex-flow: column; + align-items: center; + } + + &__form-row { + margin: 14.5px 18px 0px; + position: relative; + display: flex; + flex-flow: row; + flex: 1 0 auto; + justify-content: space-between; + } + + &__form-field { + flex: 1 1 auto; + } + + &__form-label { + color: $scorpion; + font-family: Roboto; + font-size: 16px; + line-height: 22px; + width: 88px; + } + + &__from-dropdown { + height: 73px; + width: 100%; + border: 1px solid $alto; + border-radius: 4px; + background-color: $white; + font-family: Roboto; + line-height: 16px; + font-size: 12px; + color: $tundora; + position: relative; + + &__close-area { + position: fixed; + top: 0; + left: 0; + z-index: 1000; + width: 100%; + height: 100%; + } + + &__list { + z-index: 1050; + position: absolute; + height: 220px; + width: 100%; + border: 1px solid $geyser; + border-radius: 4px; + background-color: $white; + box-shadow: 0 3px 6px 0 rgba(0 ,0 ,0 ,.11); + margin-top: 11px; + margin-left: -1px; + overflow-y: scroll; + } + } + + &__to-autocomplete { + position: relative; + + &__down-caret { + position: absolute; + top: 18px; + right: 12px; + } + } + + &__to-autocomplete, &__memo-text-area { + &__input { + height: 54px; + width: 100%; + border: 1px solid $alto; + border-radius: 4px; + background-color: $white; + color: $dusty-gray; + padding: 10px; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + font-weight: 300; + } + } + + &__amount-max { + color: $curious-blue; + font-family: Roboto; + font-size: 12px; + left: 8px; + border: none; + cursor: pointer; + } + + &__gas-fee-display { + width: 100%; + } + + &__sliders-icon-container { + display: flex; + align-items: center; + justify-content: center; + height: 24px; + width: 24px; + border: 1px solid $curious-blue; + border-radius: 4px; + background-color: $white; + position: absolute; + right: 15px; + top: 14px; + cursor: pointer; + font-size: 1em; + } + + &__sliders-icon { + color: $curious-blue; + } + + &__memo-text-area { + &__input { + padding: 6px 10px; + } + } + + &__footer { + height: 92px; + width: 100%; + display: flex; + justify-content: space-evenly; + align-items: center; + border-top: 1px solid $alto; + background: $white; + padding: 0 12px; + flex-shrink: 0; + } + + &__next-btn, + &__cancel-btn { + width: 163px; + margin: 0 4px; + } + + &__customize-gas { + border: 1px solid #D8D8D8; + border-radius: 4px; + background-color: #FFFFFF; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.14); + font-family: Roboto; + display: flex; + flex-flow: column; + + @media screen and (max-width: $break-small) { + width: 100vw; + height: 100vh; + } + + &__header { + height: 52px; + border-bottom: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + + @media screen and (max-width: $break-small) { + flex: 0 0 auto; + } + } + + &__title { + margin-left: 19.25px; + } + + &__close::after { + content: '\00D7'; + font-size: 1.8em; + color: $dusty-gray; + font-family: sans-serif; + cursor: pointer; + margin-right: 19.25px; + } + + &__content { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + + &__body { + display: flex; + margin-bottom: 24px; + + @media screen and (max-width: $break-small) { + flex-flow: column; + flex: 1 1 auto; + } + } + + &__footer { + height: 75px; + border-top: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + position: relative; + + @media screen and (max-width: $break-small) { + flex: 0 0 auto; + } + } + + &__buttons { + display: flex; + justify-content: space-between; + width: 181.75px; + margin-right: 21.25px; + } + + &__revert, &__cancel, &__save, &__save__error { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + + &__revert { + color: $silver-chalice; + font-size: 16px; + margin-left: 21.25px; + } + + &__cancel, &__save, &__save__error { + height: 34.64px; + width: 85.74px; + border: 1px solid $dusty-gray; + border-radius: 2px; + font-family: 'DIN OT'; + font-size: 12px; + color: $dusty-gray; + } + + &__save__error { + opacity: 0.5; + cursor: auto; + } + + &__error-message { + display: block; + position: absolute; + top: 4px; + right: 4px; + font-size: 12px; + line-height: 12px; + color: $red; + } + } + + &__gas-modal-card { + width: 360px; + display: flex; + flex-flow: column; + align-items: flex-start; + padding-left: 20px; + + &__title { + height: 26px; + color: $tundora; + font-family: Roboto; + font-size: 20px; + font-weight: 300; + line-height: 26px; + margin-top: 17px; + } + + &__copy { + height: 38px; + width: 314px; + color: $tundora; + font-family: Roboto; + font-size: 14px; + line-height: 19px; + margin-top: 17px; + } + + .customize-gas-input-wrapper { + margin-top: 17px; + } + + .customize-gas-input { + height: 54px; + width: 315px; + border: 1px solid $geyser; + background-color: $white; + padding-left: 15px; + } + + .gas-tooltip-input-arrows { + width: 32px; + height: 54px; + border-left: 1px solid #dadada; + font-size: 18px; + color: $tundora; + right: 0px; + padding: 1px 4px; + display: flex; + justify-content: space-around; + align-items: center; + } + + input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + + input[type="number"]:hover::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + } +} diff --git a/ui/app/css/itcss/components/settings.scss b/ui/app/css/itcss/components/settings.scss new file mode 100644 index 000000000..d60ebd934 --- /dev/null +++ b/ui/app/css/itcss/components/settings.scss @@ -0,0 +1,206 @@ +.settings { + position: relative; + background: $white; + display: flex; + flex-flow: column nowrap; + height: auto; + overflow: auto; +} + +.settings__header { + padding: 25px; +} + +.settings__close-button::after { + content: '\00D7'; + font-size: 40px; + color: $dusty-gray; + position: absolute; + top: 25px; + right: 30px; + cursor: pointer; +} + +.settings__error { + padding-bottom: 20px; + text-align: center; + color: $crimson; +} + +.settings__content { + padding: 0 25px; +} + +.settings__content-row { + display: flex; + flex-direction: row; + padding: 10px 0 20px; + + @media screen and (max-width: 575px) { + flex-direction: column; + padding: 10px 0; + } +} + +.settings__content-item { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: 0 5px; + height: 71px; + + @media screen and (max-width: 575px) { + height: initial; + padding: 5px 0; + } + + &--without-height { + height: initial; + } +} + +.settings__content-item-col { + max-width: 300px; + display: flex; + flex-direction: column; + + @media screen and (max-width: 575px) { + max-width: 100%; + width: 100%; + } +} + +.settings__content-description { + font-size: 14px; + color: $dusty-gray; + padding-top: 5px; +} + +.settings__input { + padding-left: 10px; + font-size: 14px; + height: 40px; + border: 1px solid $alto; +} + +.settings__input::-webkit-input-placeholder { + font-weight: 100; + color: $dusty-gray; +} + +.settings__input::-moz-placeholder { + font-weight: 100; + color: $dusty-gray; +} + +.settings__input:-ms-input-placeholder { + font-weight: 100; + color: $dusty-gray; +} + +.settings__input:-moz-placeholder { + font-weight: 100; + color: $dusty-gray; +} + +.settings__provider-wrapper { + font-size: 16px; + border: 1px solid $alto; + border-radius: 2px; + padding: 15px; + background-color: $white; + display: flex; + align-items: center; + justify-content: flex-start; +} + +.settings__provider-icon { + height: 10px; + width: 10px; + margin-right: 10px; + border-radius: 10px; +} + +.settings__rpc-save-button { + align-self: flex-end; + padding: 5px; + text-transform: uppercase; + color: $dusty-gray; + cursor: pointer; +} + +.settings__clear-button { + font-size: 16px; + border: 1px solid $curious-blue; + color: $curious-blue; + border-radius: 2px; + padding: 18px; + background-color: $white; + text-transform: uppercase; +} + +.settings__clear-button--red { + border: 1px solid $monzo; + color: $monzo; +} + +.settings__clear-button--orange { + border: 1px solid rgba(247, 134, 28, 1); + color: rgba(247, 134, 28, 1); +} + +.settings__info-logo-wrapper { + height: 80px; + margin-bottom: 20px; +} + +.settings__info-logo { + max-height: 100%; + max-width: 100%; +} + +.settings__info-item { + padding: 10px 0; +} + +.settings__info-link-header { + padding-bottom: 15px; + + @media screen and (max-width: 575px) { + padding-bottom: 5px; + } +} + +.settings__info-link-item { + padding: 15px 0; + + @media screen and (max-width: 575px) { + padding: 5px 0; + } +} + +.settings__info-version-number { + padding-top: 5px; + font-size: 13px; + color: $dusty-gray; +} + +.settings__info-about { + color: $dusty-gray; + margin-bottom: 15px; +} + +.settings__info-link { + color: $curious-blue; +} + +.settings__info-separator { + margin: 15px 0; + width: 80px; + border-color: $alto; + border: none; + height: 1px; + background-color: $alto; + color: $alto; +} diff --git a/ui/app/css/itcss/components/simple-dropdown.scss b/ui/app/css/itcss/components/simple-dropdown.scss new file mode 100644 index 000000000..a21095a3e --- /dev/null +++ b/ui/app/css/itcss/components/simple-dropdown.scss @@ -0,0 +1,65 @@ +.simple-dropdown { + height: 56px; + display: flex; + justify-content: flex-start; + align-items: center; + border: 1px solid $alto; + border-radius: 4px; + background-color: $white; + font-size: 16px; + color: #4d4d4d; + cursor: pointer; + position: relative; +} + +.simple-dropdown__caret { + color: $silver; + padding: 0 10px; +} + +.simple-dropdown__selected { + flex-grow: 1; + padding: 0 15px; +} + +.simple-dropdown__options { + z-index: 1050; + position: absolute; + height: 220px; + width: 100%; + border: 1px solid #d2d8dd; + border-radius: 4px; + background-color: #fff; + -webkit-box-shadow: 0 3px 6px 0 rgba(0, 0, 0, .11); + box-shadow: 0 3px 6px 0 rgba(0, 0, 0, .11); + margin-top: 10px; + overflow-y: scroll; + left: 0; + top: 100%; +} + +.simple-dropdown__option { + padding: 10px; + + &:hover { + background-color: $gallery; + } +} + +.simple-dropdown__option--selected { + background-color: $alto; + + &:hover { + background-color: $alto; + cursor: default; + } +} + +.simple-dropdown__close-area { + position: fixed; + top: 0; + left: 0; + z-index: 1000; + width: 100%; + height: 100%; +} diff --git a/ui/app/css/itcss/components/tab-bar.scss b/ui/app/css/itcss/components/tab-bar.scss new file mode 100644 index 000000000..4f3077974 --- /dev/null +++ b/ui/app/css/itcss/components/tab-bar.scss @@ -0,0 +1,23 @@ +.tab-bar { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-end; +} + +.tab-bar__tab { + min-width: 0; + flex: 0 0 auto; + padding: 15px 25px; + border-bottom: 1px solid $alto; + box-sizing: border-box; + font-size: 18px; +} + +.tab-bar__tab--active { + border-color: $black; +} + +.tab-bar__grow-tab { + flex-grow: 1; +} diff --git a/ui/app/css/itcss/components/token-list.scss b/ui/app/css/itcss/components/token-list.scss new file mode 100644 index 000000000..e24bf812b --- /dev/null +++ b/ui/app/css/itcss/components/token-list.scss @@ -0,0 +1,102 @@ +$wallet-balance-breakpoint: 890px; +$wallet-balance-breakpoint-range: "screen and (min-width: #{$break-large}) and (max-width: #{$wallet-balance-breakpoint})"; + +.token-list-item { + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 20px 24px; + cursor: pointer; + transition: linear 200ms; + background-color: rgba($wallet-balance-bg, 0); + position: relative; + + &__token-balance { + font-size: 1.5rem; + + @media #{$wallet-balance-breakpoint-range} { + font-size: 105%; + } + } + + &__fiat-amount { + margin-top: .25%; + font-size: 105%; + text-transform: uppercase; + + @media #{$wallet-balance-breakpoint-range} { + font-size: 95%; + } + } + + @media #{$wallet-balance-breakpoint-range} { + padding: 10% 4%; + } + + &--active { + background-color: $manatee; + color: $white; + } + + &__identicon { + margin-right: 15px; + border: '1px solid #dedede'; + + @media #{$wallet-balance-breakpoint-range} { + margin-right: 4%; + } + } + + &__ellipsis { + // position: absolute; + // top: 20px; + // right: 24px; + line-height: 45px; + } + + &__balance-wrapper { + flex: 1 1 auto; + } +} + +.token-menu-dropdown { + height: 55px; + width: 191px; + border-radius: 4px; + background-color: rgba(0, 0, 0, .82); + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5); + position: absolute; + top: 60px; + right: 25px; + z-index: 2000; + + &__close-area { + position: fixed; + top: 0; + left: 0; + z-index: 2100; + width: 100%; + height: 100%; + cursor: default; + } + + &__container { + padding: 16px 34px 32px; + z-index: 2200; + position: relative; + } + + &__options { + display: flex; + flex-direction: column; + justify-content: center; + } + + &__option { + color: $white; + font-family: Roboto; + font-size: 16px; + line-height: 21px; + text-align: center; + } +}
\ No newline at end of file diff --git a/ui/app/css/itcss/components/transaction-list.scss b/ui/app/css/itcss/components/transaction-list.scss new file mode 100644 index 000000000..c3df493df --- /dev/null +++ b/ui/app/css/itcss/components/transaction-list.scss @@ -0,0 +1,264 @@ +.tx-list-container { + height: 87.5%; + + @media screen and (min-width: $break-large) { + overflow-y: scroll; + } +} + +.tx-list-header-wrapper { + flex: 0 0 auto; +} + +.tx-list-header { + text-transform: capitalize; +} + +@media screen and (max-width: $break-small) { + .tx-list-header-wrapper { + margin-top: .2em; + margin-bottom: .6em; + // TODO: Resolve Layout Conflicst in Wallet View + // - This fixes txlist "transactions" title dispay + // margin-top: 0.2em; + // margin-bottom: 0.6em; + justify-content: center; + flex: 0 0 auto; + } + + .tx-list-header { + align-self: center; + font-size: 12px; + color: $dusty-gray; + font-family: Roboto; + text-transform: uppercase; + } +} + +@media screen and (min-width: $break-large) { + .tx-list-header { + font-size: 16px; + margin: 1.1em 2.37em .8em; + } + + .tx-list-container::-webkit-scrollbar { + display: none; + } +} + +.tx-list-content-divider { + height: 1px; + background: rgb(231, 231, 231); + flex: 0 0 1px; + + @media screen and (max-width: $break-small) { + margin: .1em 0; + } + + @media screen and (min-width: $break-large) { + margin: .1em 2.37em; + } +} + +.tx-list-item-wrapper { + flex: 1 1 auto; + width: 0; + align-items: stretch; + justify-content: flex-start; + display: flex; + flex-flow: column nowrap; + + @media screen and (max-width: $break-small) { + padding: 0 1.3em .8em; + } + + @media screen and (min-width: $break-large) { + padding-bottom: 8px; + } +} + +.tx-list-clickable { + cursor: pointer; + + &:hover { + background: rgba($alto, .2); + } +} + +.tx-list-pending-item-container { + cursor: pointer; + opacity: .5; +} + +.tx-list-date-wrapper { + margin-top: 6px; + flex: 1 1 auto; +} + +.tx-list-content-wrapper { + align-items: stretch; + margin-bottom: 4px; + flex: 1 0 auto; + width: 100%; + display: flex; + flex-flow: row nowrap; + + @media screen and (max-width: $break-small) { + font-size: 12px; + + .tx-list-status { + font-size: 12px !important; + } + + .tx-list-account { + font-size: 14px !important; + } + + .tx-list-value { + font-size: 14px; + line-height: 18px; + } + + .tx-list-fiat-value { + font-size: 12px; + line-height: 22px; + } + } +} + +.tx-list-date { + color: $dusty-gray; + font-size: 12px; + font-family: Roboto; +} + +.tx-list-identicon-wrapper { + align-self: center; + flex: 0 0 auto; + margin-right: 16px; +} + +.tx-list-account-and-status-wrapper { + display: flex; + flex: 1 1 auto; + flex-flow: row wrap; + width: 0; + + @media screen and (max-width: $break-small) { + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + align-self: center; + + .tx-list-account-wrapper { + height: 18px; + + .tx-list-account { + line-height: 14px; + } + } + } + + @media screen and (min-width: $break-large) { + flex-direction: row; + justify-content: flex-start; + align-items: center; + + .tx-list-account-wrapper { + flex: 1.3 2 auto; + min-width: 153px; + } + + .tx-list-status-wrapper { + flex: 6 6 auto; + } + } + + .tx-list-account { + font-size: 16px; + color: $scorpion; + } + + .tx-list-status { + color: $dusty-gray; + font-size: 16px; + text-transform: capitalize; + } + + .tx-list-status--rejected, + .tx-list-status--failed { + color: $monzo; + } +} + +.tx-list-item { + border-top: 1px solid rgb(231, 231, 231); + flex: 0 0 auto; + display: flex; + flex-flow: row nowrap; + + @media screen and (max-width: $break-small) { + // margin: 0 1.3em .95em; !important + } + + @media screen and (min-width: $break-large) { + padding: 0 2.37em; + } + + &:last-of-type { + border-bottom: 1px solid rgb(231, 231, 231); + margin-bottom: 32px; + } + + &__wrapper { + align-self: center; + flex: 2 2 auto; + color: $dusty-gray; + + .tx-list-value { + font-size: 16px; + text-align: right; + } + + .tx-list-value--confirmed { + color: $caribbean-green; + } + + .tx-list-fiat-value { + font-size: 12px; + text-align: right; + } + } + + &--empty { + text-align: center; + border-bottom: none !important; + padding: 16px; + } +} + +.tx-list-details-wrapper { + overflow: hidden; + flex: 0 0 35%; +} + +.tx-list-value { + font-size: 16px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.tx-list-fiat-value { + font-size: 12px; + line-height: initial; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.tx-list-value--confirmed { + color: $caribbean-green; +} diff --git a/ui/app/css/itcss/components/wallet-balance.scss b/ui/app/css/itcss/components/wallet-balance.scss new file mode 100644 index 000000000..293771550 --- /dev/null +++ b/ui/app/css/itcss/components/wallet-balance.scss @@ -0,0 +1,74 @@ +$wallet-balance-bg: #e7e7e7; +$wallet-balance-breakpoint: 890px; +$wallet-balance-breakpoint-range: "screen and (min-width: #{$break-large}) and (max-width: #{$wallet-balance-breakpoint})"; + +.wallet-balance-wrapper { + flex: 0 0 auto; + transition: linear 200ms; + background: rgba($wallet-balance-bg, 0); + + &--active { + background: $manatee; + color: $white; + } +} + +.wallet-balance { + background: inherit; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + flex: 0 0 auto; + cursor: pointer; + border-top: 1px solid $wallet-balance-bg; + + .balance-container { + display: flex; + justify-content: flex-start; + align-items: center; + margin: 20px 24px; + flex-direction: row; + flex-grow: 3; + + @media #{$wallet-balance-breakpoint-range} { + margin: 10% 4%; + } + } + + .balance-display { + margin-left: 15px; + justify-content: flex-start; + align-items: flex-start; + + .token-amount { + font-size: 1.5rem; + } + + .fiat-amount { + margin-top: .25%; + font-size: 105%; + } + + @media #{$wallet-balance-breakpoint-range} { + margin-left: 4%; + + .token-amount { + font-size: 105%; + } + + .fiat-amount { + font-size: 95%; + } + } + } +} + +.balance-icon { + border-radius: 25px; + width: 50px; + height: 50px; + border: 1px solid $alto; + padding: 5px; + background: $white; +} diff --git a/ui/app/css/itcss/generic/index.scss b/ui/app/css/itcss/generic/index.scss new file mode 100644 index 000000000..9d55324e3 --- /dev/null +++ b/ui/app/css/itcss/generic/index.scss @@ -0,0 +1,71 @@ +/* + Generic + */ + +@import './reset.scss'; + +* { + box-sizing: border-box; +} + +html, +body { + font-family: Roboto, Arial; + color: #4d4d4d; + font-weight: 300; + line-height: 1.4em; + background: #f7f7f7; + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +html { + min-height: 500px; +} + +.app-root { + overflow: hidden; + position: relative; +} + +.app-primary { + display: flex; +} + +input:focus, +textarea:focus { + outline: none; +} + +/* stylelint-disable */ +#app-content { + overflow-x: hidden; + height: 100%; + display: flex; + flex-direction: column; + + @media screen and (max-width: $break-small) { + background-color: $white; + } +} +/* stylelint-enable */ + +a { + text-decoration: none; + color: inherit; +} + +a:hover { + color: #df6b0e; +} + +input.large-input, +textarea.large-input { + padding: 8px; +} + +input.large-input { + height: 36px; +} diff --git a/ui/app/css/itcss/generic/reset.scss b/ui/app/css/itcss/generic/reset.scss new file mode 100644 index 000000000..e054d533e --- /dev/null +++ b/ui/app/css/itcss/generic/reset.scss @@ -0,0 +1,147 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + /* stylelint-disable */ + font: inherit; + /* stylelint-enable */ + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ + +/* stylelint-disable */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} + +body { + line-height: 1; +} + +ol, +ul { + list-style: none; +} + +blockquote, +q { + quotes: none; +} + +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +button { + border-style: none; + cursor: pointer; +} + +/* stylelint-enable */ diff --git a/ui/app/css/itcss/objects/index.scss b/ui/app/css/itcss/objects/index.scss new file mode 100644 index 000000000..220775682 --- /dev/null +++ b/ui/app/css/itcss/objects/index.scss @@ -0,0 +1 @@ +// Objects diff --git a/ui/app/css/itcss/settings/index.scss b/ui/app/css/itcss/settings/index.scss new file mode 100644 index 000000000..58a7ca7b7 --- /dev/null +++ b/ui/app/css/itcss/settings/index.scss @@ -0,0 +1,3 @@ +@import './variables.scss'; + +@import './typography.scss'; diff --git a/ui/app/css/itcss/settings/typography.scss b/ui/app/css/itcss/settings/typography.scss new file mode 100644 index 000000000..ac8c41336 --- /dev/null +++ b/ui/app/css/itcss/settings/typography.scss @@ -0,0 +1,71 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900'); + +@import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css'); + +@font-face { + font-family: 'Montserrat Regular'; + src: url('/fonts/Montserrat/Montserrat-Regular.woff') format('woff'); + src: url('/fonts/Montserrat/Montserrat-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-size: 'small'; +} + +@font-face { + font-family: 'Montserrat Bold'; + src: url('/fonts/Montserrat/Montserrat-Bold.woff') format('woff'); + src: url('/fonts/Montserrat/Montserrat-Bold.ttf') format('truetype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Montserrat Light'; + src: url('/fonts/Montserrat/Montserrat-Light.woff') format('woff'); + src: url('/fonts/Montserrat/Montserrat-Light.ttf') format('truetype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Montserrat UltraLight'; + src: url('/fonts/Montserrat/Montserrat-UltraLight.woff') format('woff'); + src: url('/fonts/Montserrat/Montserrat-UltraLight.ttf') format('truetype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'DIN OT'; + src: url('/fonts/DIN_OT/DINOT-2.otf') format('opentype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'DIN OT Light'; + src: url('/fonts/DIN_OT/DINOT-2.otf') format('opentype'); + font-weight: 200; + font-style: normal; +} + +@font-face { + font-family: 'DIN NEXT'; + src: url('/fonts/DIN Next/DIN Next W01 Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'DIN NEXT Light'; + src: url('/fonts/DIN Next/DIN Next W10 Light.otf') format('opentype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Lato'; + src: url('/fonts/Lato/Lato-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; +} diff --git a/ui/app/css/itcss/settings/variables.scss b/ui/app/css/itcss/settings/variables.scss new file mode 100644 index 000000000..4c0972527 --- /dev/null +++ b/ui/app/css/itcss/settings/variables.scss @@ -0,0 +1,81 @@ +/* + Variables + */ + +// Base Colors +$white: #fff; +$black: #000; +$orange: #ffa500; +$red: #f00; +$gray: #808080; + +/* + Colors + http://chir.ag/projects/name-that-color + */ +$white-linen: #faf6f0; // formerly 'faint orange (textfield shades)' +$rajah: #f5c26d; // formerly 'light orange (button shades)' +$buttercup: #f5a623; // formerly 'dark orange (text)' +$tundora: #4a4a4a; // formerly 'borders/font/any gray' +$gallery: #efefef; +$alabaster: #f7f7f7; +$shark: #22232c; +$wild-sand: #f6f6f6; +$white: #fff; +$dusty-gray: #9b9b9b; +$alto: #dedede; +$alabaster: #fafafa; +$silver-chalice: #aeaeae; +$curious-blue: #2f9ae0; +$concrete: #f3f3f3; +$tundora: #4d4d4d; +$nile-blue: #1b344d; +$scorpion: #5d5d5d; +$silver: #cdcdcd; +$caribbean-green: #02c9b1; +$monzo: #d0021b; +$crimson: #e91550; +$blue-lagoon: #038789; +$purple: #690496; +$tulip-tree: #ebb33f; +$malibu-blue: #7ac9fd; +$athens-grey: #e9edf0; +$jaffa: #f28930; +$geyser: #d2d8dd; +$manatee: #93949d; +$spindle: #c7ddec; +$mid-gray: #5b5d67; +$cape-cod: #38393a; + +/* + Z-Indicies + */ +$dropdown-z-index: 30; +$token-icon-z-index: 15; +$container-z-index: 15; +$header-z-index: 12; +$mobile-header-z-index: 26; +$main-container-z-index: 18; +$send-card-z-index: 20; +$sidebar-z-index: 26; +$sidebar-overlay-z-index: 25; + +/* + Z Indicies - Current + app - 11 + hex/bn as decimal input - 1 - remove? + dropdown - 11 + loading - 10 - higher? + mascot - 0 - remove? + */ + +/* + Responsive Breakpoints + */ +$break-small: 575px; +$break-midpoint: 780px; +$break-large: 576px; + + +$primary-font-type: Roboto; + diff --git a/ui/app/css/itcss/tools/index.scss b/ui/app/css/itcss/tools/index.scss new file mode 100644 index 000000000..2236729e8 --- /dev/null +++ b/ui/app/css/itcss/tools/index.scss @@ -0,0 +1 @@ +@import './utilities.scss'; diff --git a/ui/app/css/lib.css b/ui/app/css/itcss/tools/utilities.scss index f3acbee76..ee867640d 100644 --- a/ui/app/css/lib.css +++ b/ui/app/css/itcss/tools/utilities.scss @@ -1,19 +1,34 @@ +/* + Utility Classes + */ + /* color */ .color-orange { - color: #F7861C; + color: #f7861c; // TODO: move to settings/variables } .color-forest { - color: #0A5448; + color: #0a5448; // TODO: move to settings/variables } /* lib */ +.full-size { + height: 100%; + width: 100%; +} + .full-width { width: 100%; } +.full-flex-height { + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + .full-height { height: 100%; } @@ -118,16 +133,19 @@ .pointer { cursor: pointer; } + .cursor-pointer { cursor: pointer; transform-origin: center center; transition: transform 50ms ease-in-out; } + .cursor-pointer:hover { transform: scale(1.1); } + .cursor-pointer:active { - transform: scale(0.95); + transform: scale(.95); } .cursor-disabled { @@ -147,7 +165,7 @@ } .bold { - font-weight: bold; + font-weight: 700; } .text-transform-uppercase { @@ -172,12 +190,12 @@ hr.horizontal-line { } .hover-white:hover { - background: white; + background: $white; } .red-dot { - background: #E91550; - color: white; + background: #e91550; + color: $white; border-radius: 10px; } @@ -192,14 +210,14 @@ hr.horizontal-line { } .golden-square { - background: #EBB33F; + background: #ebb33f; } .pending-dot { - background: red; + background: $red; left: 14px; top: 14px; - color: white; + color: $white; border-radius: 10px; height: 20px; min-width: 20px; @@ -213,19 +231,14 @@ hr.horizontal-line { .keyring-label { z-index: 1; - font-size: 11px; - background: rgba(255,0,0,0.8); - color: white; - bottom: 0px; - left: -8px; + font-size: 8px; + line-height: 8px; + background: rgba(255, 255, 255, 0.4); + color: #fff; border-radius: 10px; - height: 20px; - min-width: 20px; - position: absolute; - display: flex; - align-items: center; - justify-content: center; padding: 4px; + text-align: center; + height: 15px; } .ether-balance { @@ -244,16 +257,13 @@ hr.horizontal-line { margin: 13px; } -i.fa.fa-question-circle.fa-lg.menu-icon { - font-size: 18px; -} - .ether-icon { background: rgb(0, 163, 68); border-radius: 20px; } + .testnet-icon { - background: #2465E1; + background: #2465e1; } .drop-menu-item { @@ -274,33 +284,26 @@ i.fa.fa-question-circle.fa-lg.menu-icon { .critical-error { text-align: center; margin-top: 20px; - color: red; + color: $red; } /* - Hacky breakpoint fix for account + tab sections - Resolves issue from @frankiebee in - https://github.com/MetaMask/metamask-extension/pull/1835 - Please remove this when integrating new designs + Misc */ -@media screen and (min-width: 575px) and (max-width: 800px) { - .account-data-subsection { - flex: 0 0 auto !important; // reset flex - margin-left: 10px !important; // create additional horizontal space - margin-right: 10px !important; - width: 40%; - } - - .tabSection { - flex: 0 0 auto !important; - margin-left: 10px !important; - margin-right: 10px !important; - min-width: 285px; - width: 49%; - } - - .name-label { - width: 80%; - } +// TODO: move into component-level contextual 'active' state +.letter-spacey { + letter-spacing: .1em; +} + +.active { + color: #909090; +} + +.check { + margin-left: 7px; + color: #f7861c; + flex: 1 0 auto; + display: flex; + justify-content: flex-end; } diff --git a/ui/app/css/transitions.css b/ui/app/css/itcss/trumps/index.scss index 393a944f9..d9a4202a4 100644 --- a/ui/app/css/transitions.css +++ b/ui/app/css/itcss/trumps/index.scss @@ -1,3 +1,9 @@ +/* + Trumps + */ + +// Transitions + /* universal */ .app-primary .main-enter { position: absolute; @@ -8,7 +14,7 @@ .app-primary.from-right .main-enter-active, .app-primary.from-left .main-enter-active { overflow-x: hidden; - transform: translateX(0px); + transform: translateX(0); transition: transform 300ms ease-in; } @@ -17,18 +23,27 @@ transform: translateX(360px); transition: transform 300ms ease-in; } + .app-primary.from-right .main-leave-active { transform: translateX(-360px); transition: transform 300ms ease-in; } +.sidebar.from-left { + transform: translateX(-320px); + transition: transform 300ms ease-in; +} + /* loader transitions */ -.loader-enter, .loader-leave-active { - opacity: 0.0; +.loader-enter, +.loader-leave-active { + opacity: 0; transition: opacity 150 ease-in; } -.loader-enter-active, .loader-leave { - opacity: 1.0; + +.loader-enter-active, +.loader-leave { + opacity: 1; transition: opacity 150 ease-in; } @@ -36,7 +51,22 @@ .app-primary.from-right .main-enter:not(.main-enter-active) { transform: translateX(360px); } + .app-primary.from-left .main-enter:not(.main-enter-active) { transform: translateX(-360px); } +i.fa.fa-question-circle.fa-lg.menu-icon { + font-size: 18px; +} + +// This text is contained inside a div. +// ID needed to override user agent stylesheet. +// See components/modal.scss + +/* stylelint-disable */ +#buy-modal-content-footer-text { + font-family: 'DIN OT'; + font-size: 16px; +} +/* stylelint-enable */ diff --git a/ui/app/css/reset.css b/ui/app/css/reset.css deleted file mode 100644 index 9ce89e8bc..000000000 --- a/ui/app/css/reset.css +++ /dev/null @@ -1,48 +0,0 @@ -/* http://meyerweb.com/eric/tools/css/reset/ - v2.0 | 20110126 - License: none (public domain) -*/ - -html, body, div, span, applet, object, iframe, -h1, h2, h3, h4, h5, h6, p, blockquote, pre, -a, abbr, acronym, address, big, cite, code, -del, dfn, em, img, ins, kbd, q, s, samp, -small, strike, strong, sub, sup, tt, var, -b, u, i, center, -dl, dt, dd, ol, ul, li, -fieldset, form, label, legend, -table, caption, tbody, tfoot, thead, tr, th, td, -article, aside, canvas, details, embed, -figure, figcaption, footer, header, hgroup, -menu, nav, output, ruby, section, summary, -time, mark, audio, video { - margin: 0; - padding: 0; - border: 0; - font-size: 100%; - font: inherit; - vertical-align: baseline; -} -/* HTML5 display-role reset for older browsers */ -article, aside, details, figcaption, figure, -footer, header, hgroup, menu, nav, section { - display: block; -} -body { - line-height: 1; -} -ol, ul { - list-style: none; -} -blockquote, q { - quotes: none; -} -blockquote:before, blockquote:after, -q:before, q:after { - content: ''; - content: none; -} -table { - border-collapse: collapse; - border-spacing: 0; -}
\ No newline at end of file diff --git a/ui/app/main-container.js b/ui/app/main-container.js new file mode 100644 index 000000000..031f61e84 --- /dev/null +++ b/ui/app/main-container.js @@ -0,0 +1,67 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const AccountAndTransactionDetails = require('./account-and-transaction-details') +const HDRestoreVaultScreen = require('./keychains/hd/restore-vault') +const Settings = require('./settings') +const UnlockScreen = require('./unlock') + +module.exports = MainContainer + +inherits(MainContainer, Component) +function MainContainer () { + Component.call(this) +} + +MainContainer.prototype.render = function () { + // 3. summarize: + // switch statement goes inside MainContainer, + // or a method in renderPrimary + // - pass resulting h() to MainContainer + // - error checking in separate func + // - router in separate func + let contents = { + component: AccountAndTransactionDetails, + key: 'account-detail', + style: {}, + } + + if (this.props.isUnlocked === false) { + switch (this.props.currentViewName) { + case 'restoreVault': + log.debug('rendering restore vault screen') + contents = { + component: HDRestoreVaultScreen, + key: 'HDRestoreVaultScreen', + } + break + case 'config': + log.debug('rendering config screen from unlock screen.') + return h(Settings, {key: 'config'}) + default: + log.debug('rendering locked screen') + contents = { + component: UnlockScreen, + style: { + boxShadow: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: '#F7F7F7', + // must force 100%, because lock screen is full-width + width: '100%', + }, + key: 'locked', + } + } + } + + return h('div.main-container', { + style: contents.style, + }, [ + h(contents.component, { + key: contents.key, + }, []), + ]) +} + diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js index 8558d6dca..c3ade5cdc 100644 --- a/ui/app/reducers/app.js +++ b/ui/app/reducers/app.js @@ -14,6 +14,7 @@ function reduceApp (state, action) { if (selectedAddress) { name = 'accountDetail' } + if (hasUnconfActions) { log.debug('pending txs detected, defaulting to conf-tx view.') name = 'confTx' @@ -36,6 +37,17 @@ function reduceApp (state, action) { var appState = extend({ shouldClose: false, menuOpen: false, + modal: { + open: false, + modalState: { + name: null, + }, + previousModalState: { + name: null, + }, + }, + sidebarOpen: false, + networkDropdownOpen: false, currentView: seedWords ? seedConfView : defaultView, accountDetail: { subview: 'transactions', @@ -46,12 +58,54 @@ function reduceApp (state, action) { isLoading: false, // Used to display error text warning: null, + buyView: {}, }, state.appState) switch (action.type) { + // dropdown methods + case actions.NETWORK_DROPDOWN_OPEN: + return extend(appState, { + networkDropdownOpen: true, + }) - // transition methods + case actions.NETWORK_DROPDOWN_CLOSE: + return extend(appState, { + networkDropdownOpen: false, + }) + + // sidebar methods + case actions.SIDEBAR_OPEN: + return extend(appState, { + sidebarOpen: true, + }) + case actions.SIDEBAR_CLOSE: + return extend(appState, { + sidebarOpen: false, + }) + + // modal methods: + case actions.MODAL_OPEN: + return extend(appState, { + modal: Object.assign( + state.appState.modal, + { open: true }, + { modalState: action.payload }, + { previousModalState: appState.modal.modalState}, + ), + }) + + case actions.MODAL_CLOSE: + return extend(appState, { + modal: Object.assign( + state.appState.modal, + { open: false }, + { modalState: { name: null } }, + { previousModalState: appState.modal.modalState}, + ), + }) + + // transition methods case actions.TRANSITION_FORWARD: return extend(appState, { transForward: true, @@ -116,7 +170,6 @@ function reduceApp (state, action) { }) case actions.SHOW_IMPORT_PAGE: - return extend(appState, { currentView: { name: 'import-menu', @@ -125,6 +178,24 @@ function reduceApp (state, action) { warning: null, }) + case actions.SHOW_NEW_ACCOUNT_PAGE: + return extend(appState, { + currentView: { + name: 'new-account-page', + context: action.formToSelect, + }, + transForward: true, + warning: null, + }) + + case actions.SET_NEW_ACCOUNT_FORM: + return extend(appState, { + currentView: { + name: appState.currentView.name, + context: action.formToSelect, + }, + }) + case actions.SHOW_INFO_PAGE: return extend(appState, { currentView: { @@ -134,7 +205,7 @@ function reduceApp (state, action) { transForward: true, }) - case actions.CREATE_NEW_VAULT_IN_PROGRESS: + case actions.CREATE_NEW_VAULT_IN_PROGRESS: return extend(appState, { currentView: { name: 'createVault', @@ -173,6 +244,16 @@ function reduceApp (state, action) { warning: null, }) + case actions.SHOW_SEND_TOKEN_PAGE: + return extend(appState, { + currentView: { + name: 'sendToken', + context: appState.currentView.context, + }, + transForward: true, + warning: null, + }) + case actions.SHOW_NEW_KEYCHAIN: return extend(appState, { currentView: { @@ -308,7 +389,7 @@ function reduceApp (state, action) { return extend(appState, { currentView: { name: 'confTx', - context: 0, + context: action.id ? indexForPending(state, action.id) : 0, }, transForward: action.transForward, warning: null, @@ -329,7 +410,7 @@ function reduceApp (state, action) { case actions.COMPLETED_TX: log.debug('reducing COMPLETED_TX for tx ' + action.value) const otherUnconfActions = getUnconfActionList(state) - .filter(tx => tx.id !== action.value) + .filter(tx => tx.id !== action.value) const hasOtherUnconfActions = otherUnconfActions.length > 0 if (hasOtherUnconfActions) { @@ -528,8 +609,8 @@ function reduceApp (state, action) { marketinfo: action.value.marketinfo, coinOptions: action.value.coinOptions, }, - buyAddress: appState.buyView.buyAddress, - amount: appState.buyView.amount, + buyAddress: action.value.buyAddress || appState.buyView.buyAddress, + amount: appState.buyView.amount || 0, }, }) @@ -597,3 +678,7 @@ function indexForPending (state, txId) { const index = unconfTxList.indexOf(match) return index } + +// function indexForLastPending (state) { +// return getUnconfActionList(state).length +// } diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js index 85ac3e201..294c29948 100644 --- a/ui/app/reducers/metamask.js +++ b/ui/app/reducers/metamask.js @@ -1,6 +1,7 @@ const extend = require('xtend') const actions = require('../actions') const MetamascaraPlatform = require('../../../app/scripts/platforms/window') +const { OLD_UI_NETWORK_TYPE } = require('../../../app/scripts/config').enums module.exports = reduceMetamask @@ -11,6 +12,7 @@ function reduceMetamask (state, action) { var metamaskState = extend({ isInitialized: false, isUnlocked: false, + isAccountMenuOpen: false, isMascara: window.platform instanceof MetamascaraPlatform, rpcTarget: 'https://rawtestrpc.metamask.io/', identities: {}, @@ -19,8 +21,26 @@ function reduceMetamask (state, action) { lastUnreadNotice: undefined, frequentRpcList: [], addressBook: [], + selectedTokenAddress: null, tokenExchangeRates: {}, + tokens: [], + send: { + gasLimit: null, + gasPrice: null, + gasTotal: null, + tokenBalance: null, + from: '', + to: '', + amount: '0x0', + memo: '', + errors: {}, + maxModeOn: false, + editingTransactionId: null, + }, coinOptions: {}, + useBlockie: false, + featureFlags: {}, + networkEndpointType: OLD_UI_NETWORK_TYPE, }, state.metamask) switch (action.type) { @@ -94,6 +114,14 @@ function reduceMetamask (state, action) { } return newState + case actions.EDIT_TX: + return extend(metamaskState, { + send: { + ...metamaskState.send, + editingTransactionId: action.value, + }, + }) + case actions.SHOW_NEW_VAULT_SEED: return extend(metamaskState, { isUnlocked: true, @@ -119,12 +147,17 @@ function reduceMetamask (state, action) { delete newState.seedWords return newState + case actions.SET_SELECTED_TOKEN: + return extend(metamaskState, { + selectedTokenAddress: action.value, + }) + case actions.SAVE_ACCOUNT_LABEL: const account = action.value.account const name = action.value.label - var id = {} + const id = {} id[account] = extend(metamaskState.identities[account], { name }) - var identities = extend(metamaskState.identities, id) + const identities = extend(metamaskState.identities, id) return extend(metamaskState, { identities }) case actions.SET_CURRENT_FIAT: @@ -134,6 +167,147 @@ function reduceMetamask (state, action) { conversionDate: action.value.conversionDate, }) + case actions.UPDATE_TOKEN_EXCHANGE_RATE: + const { payload: { pair, marketinfo } } = action + return extend(metamaskState, { + tokenExchangeRates: { + ...metamaskState.tokenExchangeRates, + [pair]: marketinfo, + }, + }) + + case actions.UPDATE_TOKENS: + return extend(metamaskState, { + tokens: action.newTokens, + }) + + // metamask.send + case actions.UPDATE_GAS_LIMIT: + return extend(metamaskState, { + send: { + ...metamaskState.send, + gasLimit: action.value, + }, + }) + + case actions.UPDATE_GAS_PRICE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + gasPrice: action.value, + }, + }) + + case actions.TOGGLE_ACCOUNT_MENU: + return extend(metamaskState, { + isAccountMenuOpen: !metamaskState.isAccountMenuOpen, + }) + + case actions.UPDATE_GAS_TOTAL: + return extend(metamaskState, { + send: { + ...metamaskState.send, + gasTotal: action.value, + }, + }) + + case actions.UPDATE_SEND_TOKEN_BALANCE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + tokenBalance: action.value, + }, + }) + + case actions.UPDATE_SEND_FROM: + return extend(metamaskState, { + send: { + ...metamaskState.send, + from: action.value, + }, + }) + + case actions.UPDATE_SEND_TO: + return extend(metamaskState, { + send: { + ...metamaskState.send, + to: action.value, + }, + }) + + case actions.UPDATE_SEND_AMOUNT: + return extend(metamaskState, { + send: { + ...metamaskState.send, + amount: action.value, + }, + }) + + case actions.UPDATE_SEND_MEMO: + return extend(metamaskState, { + send: { + ...metamaskState.send, + memo: action.value, + }, + }) + + case actions.UPDATE_SEND_ERRORS: + return extend(metamaskState, { + send: { + ...metamaskState.send, + errors: { + ...metamaskState.send.errors, + ...action.value, + }, + }, + }) + + case actions.UPDATE_MAX_MODE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + maxModeOn: action.value, + }, + }) + + case actions.UPDATE_SEND: + return extend(metamaskState, { + send: { + ...metamaskState.send, + ...action.value, + }, + }) + + case actions.CLEAR_SEND: + return extend(metamaskState, { + send: { + gasLimit: null, + gasPrice: null, + gasTotal: null, + tokenBalance: null, + from: '', + to: '', + amount: '0x0', + memo: '', + errors: {}, + editingTransactionId: null, + }, + }) + + case actions.UPDATE_TRANSACTION_PARAMS: + const { id: txId, value } = action + let { selectedAddressTxList } = metamaskState + selectedAddressTxList = selectedAddressTxList.map(tx => { + if (tx.id === txId) { + tx.txParams = value + } + return tx + }) + + return extend(metamaskState, { + selectedAddressTxList, + }) + case actions.PAIR_UPDATE: const { value: { marketinfo: pairMarketInfo } } = action return extend(metamaskState, { @@ -144,15 +318,30 @@ function reduceMetamask (state, action) { }) case actions.SHAPESHIFT_SUBVIEW: - const { value: { marketinfo, coinOptions } } = action + const { value: { marketinfo: ssMarketInfo, coinOptions } } = action return extend(metamaskState, { tokenExchangeRates: { ...metamaskState.tokenExchangeRates, - [marketinfo.pair]: marketinfo, + [ssMarketInfo.pair]: ssMarketInfo, }, coinOptions, }) + case actions.SET_USE_BLOCKIE: + return extend(metamaskState, { + useBlockie: action.value, + }) + + case actions.UPDATE_FEATURE_FLAGS: + return extend(metamaskState, { + featureFlags: action.value, + }) + + case actions.UPDATE_NETWORK_ENDPOINT_TYPE: + return extend(metamaskState, { + networkEndpointType: action.value, + }) + default: return metamaskState diff --git a/ui/app/root.js b/ui/app/root.js index 9e7314b20..21d6d1829 100644 --- a/ui/app/root.js +++ b/ui/app/root.js @@ -2,7 +2,7 @@ const inherits = require('util').inherits const Component = require('react').Component const Provider = require('react-redux').Provider const h = require('react-hyperscript') -const App = require('./app') +const SelectedApp = require('./select-app') module.exports = Root @@ -15,7 +15,7 @@ Root.prototype.render = function () { h(Provider, { store: this.props.store, }, [ - h(App), + h(SelectedApp), ]) ) diff --git a/ui/app/select-app.js b/ui/app/select-app.js new file mode 100644 index 000000000..193c98353 --- /dev/null +++ b/ui/app/select-app.js @@ -0,0 +1,68 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const App = require('./app') +const OldApp = require('../../old-ui/app/app') +const { autoAddToBetaUI } = require('./selectors') +const { setFeatureFlag, setNetworkEndpoints } = require('./actions') +const { BETA_UI_NETWORK_TYPE } = require('../../app/scripts/config').enums + +function mapStateToProps (state) { + return { + betaUI: state.metamask.featureFlags.betaUI, + autoAdd: autoAddToBetaUI(state), + isUnlocked: state.metamask.isUnlocked, + isMascara: state.metamask.isMascara, + firstTime: Object.keys(state.metamask.identities).length === 0, + } +} + +function mapDispatchToProps (dispatch) { + return { + setFeatureFlagWithModal: () => { + return dispatch(setFeatureFlag('betaUI', true, 'BETA_UI_NOTIFICATION_MODAL')) + .then(() => dispatch(setNetworkEndpoints(BETA_UI_NETWORK_TYPE))) + }, + setFeatureFlagWithoutModal: () => { + return dispatch(setFeatureFlag('betaUI', true)) + .then(() => dispatch(setNetworkEndpoints(BETA_UI_NETWORK_TYPE))) + }, + } +} +module.exports = connect(mapStateToProps, mapDispatchToProps)(SelectedApp) + +inherits(SelectedApp, Component) +function SelectedApp () { + Component.call(this) +} + +SelectedApp.prototype.componentWillReceiveProps = function (nextProps) { + // Code commented out until we begin auto adding users to NewUI + const { + // isUnlocked, + // setFeatureFlagWithModal, + setFeatureFlagWithoutModal, + isMascara, + // firstTime, + } = this.props + + // if (isMascara || firstTime) { + if (isMascara) { + setFeatureFlagWithoutModal() + } + // } else if (!isUnlocked && nextProps.isUnlocked && (nextProps.autoAdd)) { + // setFeatureFlagWithModal() + // } +} + +SelectedApp.prototype.render = function () { + // Code commented out until we begin auto adding users to NewUI + // const { betaUI, isMascara, firstTime } = this.props + // const Selected = betaUI || isMascara || firstTime ? App : OldApp + + const { betaUI, isMascara } = this.props + const Selected = betaUI || isMascara ? App : OldApp + + return h(Selected) +} diff --git a/ui/app/selectors.js b/ui/app/selectors.js new file mode 100644 index 000000000..38a96c48b --- /dev/null +++ b/ui/app/selectors.js @@ -0,0 +1,189 @@ +const valuesFor = require('./util').valuesFor +const abi = require('human-standard-token-abi') + +const { + multiplyCurrencies, +} = require('./conversion-util') + +const selectors = { + getSelectedAddress, + getSelectedIdentity, + getSelectedAccount, + getSelectedToken, + getSelectedTokenExchangeRate, + getTokenExchangeRate, + conversionRateSelector, + transactionsSelector, + accountsWithSendEtherInfoSelector, + getCurrentAccountWithSendEtherInfo, + getGasPrice, + getGasLimit, + getAddressBook, + getSendFrom, + getCurrentCurrency, + getSendAmount, + getSelectedTokenToFiatRate, + getSelectedTokenContract, + autoAddToBetaUI, + getSendMaxModeState, + getCurrentViewContext, +} + +module.exports = selectors + +function getSelectedAddress (state) { + const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0] + + return selectedAddress +} + +function getSelectedIdentity (state) { + const selectedAddress = getSelectedAddress(state) + const identities = state.metamask.identities + + return identities[selectedAddress] +} + +function getSelectedAccount (state) { + const accounts = state.metamask.accounts + const selectedAddress = getSelectedAddress(state) + + return accounts[selectedAddress] +} + +function getSelectedToken (state) { + const tokens = state.metamask.tokens || [] + const selectedTokenAddress = state.metamask.selectedTokenAddress + const selectedToken = tokens.filter(({ address }) => address === selectedTokenAddress)[0] + + return selectedToken || null +} + +function getSelectedTokenExchangeRate (state) { + const tokenExchangeRates = state.metamask.tokenExchangeRates + const selectedToken = getSelectedToken(state) || {} + const { symbol = '' } = selectedToken + + const pair = `${symbol.toLowerCase()}_eth` + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} + + return tokenExchangeRate +} + +function getTokenExchangeRate (state, tokenSymbol) { + const pair = `${tokenSymbol.toLowerCase()}_eth` + const tokenExchangeRates = state.metamask.tokenExchangeRates + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} + + return tokenExchangeRate +} + +function conversionRateSelector (state) { + return state.metamask.conversionRate +} + +function getAddressBook (state) { + return state.metamask.addressBook +} + +function accountsWithSendEtherInfoSelector (state) { + const { + accounts, + identities, + } = state.metamask + + const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { + return Object.assign({}, account, identities[key]) + }) + + return accountsWithSendEtherInfo +} + +function getCurrentAccountWithSendEtherInfo (state) { + const currentAddress = getSelectedAddress(state) + const accounts = accountsWithSendEtherInfoSelector(state) + + return accounts.find(({ address }) => address === currentAddress) +} + +function transactionsSelector (state) { + const { network, selectedTokenAddress } = state.metamask + const unapprovedMsgs = valuesFor(state.metamask.unapprovedMsgs) + const shapeShiftTxList = (network === '1') ? state.metamask.shapeShiftTxList : undefined + const transactions = state.metamask.selectedAddressTxList || [] + const txsToRender = !shapeShiftTxList ? transactions.concat(unapprovedMsgs) : transactions.concat(unapprovedMsgs, shapeShiftTxList) + + // console.log({txsToRender, selectedTokenAddress}) + return selectedTokenAddress + ? txsToRender + .filter(({ txParams: { to } }) => to === selectedTokenAddress) + .sort((a, b) => b.time - a.time) + : txsToRender + .sort((a, b) => b.time - a.time) +} + +function getGasPrice (state) { + return state.metamask.send.gasPrice +} + +function getGasLimit (state) { + return state.metamask.send.gasLimit +} + +function getSendFrom (state) { + return state.metamask.send.from +} + +function getSendAmount (state) { + return state.metamask.send.amount +} + +function getSendMaxModeState (state) { + return state.metamask.send.maxModeOn +} + +function getCurrentCurrency (state) { + return state.metamask.currentCurrency +} + +function getSelectedTokenToFiatRate (state) { + const selectedTokenExchangeRate = getSelectedTokenExchangeRate(state) + const conversionRate = conversionRateSelector(state) + + const tokenToFiatRate = multiplyCurrencies( + conversionRate, + selectedTokenExchangeRate, + { toNumericBase: 'dec' } + ) + + return tokenToFiatRate +} + +function getSelectedTokenContract (state) { + const selectedToken = getSelectedToken(state) + return selectedToken + ? global.eth.contract(abi).at(selectedToken.address) + : null +} + +function autoAddToBetaUI (state) { + const autoAddTransactionThreshold = 12 + const autoAddAccountsThreshold = 2 + const autoAddTokensThreshold = 1 + + const numberOfTransactions = state.metamask.selectedAddressTxList.length + const numberOfAccounts = Object.keys(state.metamask.accounts).length + const numberOfTokensAdded = state.metamask.tokens.length + + const userPassesThreshold = (numberOfTransactions > autoAddTransactionThreshold) && + (numberOfAccounts > autoAddAccountsThreshold) && + (numberOfTokensAdded > autoAddTokensThreshold) + const userIsNotInBeta = !state.metamask.featureFlags.betaUI + + return userIsNotInBeta && userPassesThreshold +} + +function getCurrentViewContext (state) { + const { currentView = {} } = state.appState + return currentView.context +}
\ No newline at end of file diff --git a/ui/app/send-v2.js b/ui/app/send-v2.js new file mode 100644 index 000000000..897caf16e --- /dev/null +++ b/ui/app/send-v2.js @@ -0,0 +1,663 @@ +const { inherits } = require('util') +const PersistentForm = require('../lib/persistent-form') +const h = require('react-hyperscript') + +const ethAbi = require('ethereumjs-abi') +const ethUtil = require('ethereumjs-util') + +const Identicon = require('./components/identicon') +const FromDropdown = require('./components/send/from-dropdown') +const ToAutoComplete = require('./components/send/to-autocomplete') +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, +} = require('./token-util') +const { + isBalanceSufficient, + isTokenBalanceSufficient, +} = require('./components/send/send-utils') +const { isValidAddress } = require('./util') + +module.exports = SendTransactionScreen + +inherits(SendTransactionScreen, PersistentForm) +function SendTransactionScreen () { + PersistentForm.call(this) + + this.state = { + fromDropdownOpen: false, + toDropdownOpen: false, + errors: { + to: null, + amount: null, + }, + } + + this.handleToChange = this.handleToChange.bind(this) + this.handleAmountChange = this.handleAmountChange.bind(this) + this.validateAmount = this.validateAmount.bind(this) +} + +const getParamsForGasEstimate = function (selectedAddress, symbol, data) { + const estimatedGasParams = { + from: selectedAddress, + gas: '746a528800', + } + + if (symbol) { + Object.assign(estimatedGasParams, { value: '0x0' }) + } + + if (data) { + Object.assign(estimatedGasParams, { data }) + } + + return estimatedGasParams +} + +SendTransactionScreen.prototype.updateSendTokenBalance = function (usersToken) { + if (!usersToken) return + + const { + selectedToken = {}, + updateSendTokenBalance, + } = this.props + const { decimals } = selectedToken || {} + const tokenBalance = calcTokenAmount(usersToken.balance.toString(), decimals) + + updateSendTokenBalance(tokenBalance) +} + +SendTransactionScreen.prototype.componentWillMount = function () { + const { + updateTokenExchangeRate, + selectedToken = {}, + } = this.props + + const { symbol } = selectedToken || {} + + if (symbol) { + updateTokenExchangeRate(symbol) + } + + this.updateGas() +} + +SendTransactionScreen.prototype.updateGas = function () { + const { + selectedToken = {}, + getGasPrice, + estimateGas, + selectedAddress, + data, + updateGasTotal, + from, + tokenContract, + editingTransactionId, + gasPrice, + gasLimit, + } = this.props + + const { symbol } = selectedToken || {} + + const tokenBalancePromise = tokenContract + ? tokenContract.balanceOf(from.address) + : Promise.resolve() + tokenBalancePromise + .then(usersToken => this.updateSendTokenBalance(usersToken)) + + if (!editingTransactionId) { + const estimateGasParams = getParamsForGasEstimate(selectedAddress, symbol, data) + + Promise + .all([ + getGasPrice(), + estimateGas(estimateGasParams), + ]) + .then(([gasPrice, gas]) => { + const newGasTotal = this.getGasTotal(gas, gasPrice) + updateGasTotal(newGasTotal) + }) + } else { + const newGasTotal = this.getGasTotal(gasLimit, gasPrice) + updateGasTotal(newGasTotal) + } +} + +SendTransactionScreen.prototype.getGasTotal = function (gasLimit, gasPrice) { + return multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) +} + +SendTransactionScreen.prototype.componentDidUpdate = function (prevProps) { + const { + from: { balance }, + gasTotal, + tokenBalance, + amount, + selectedToken, + network, + } = this.props + + const { + from: { balance: prevBalance }, + gasTotal: prevGasTotal, + tokenBalance: prevTokenBalance, + network: prevNetwork, + } = prevProps + + const uninitialized = [prevBalance, prevGasTotal].every(n => n === null) + + const balanceHasChanged = balance !== prevBalance + const gasTotalHasChange = gasTotal !== prevGasTotal + const tokenBalanceHasChanged = selectedToken && tokenBalance !== prevTokenBalance + const amountValidationChange = balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged + + if (!uninitialized) { + if (amountValidationChange) { + this.validateAmount(amount) + } + + if (network !== prevNetwork && network !== 'loading') { + this.updateGas() + } + } +} + +SendTransactionScreen.prototype.renderHeaderIcon = function () { + const { selectedToken } = this.props + + return h('div.send-v2__send-header-icon-container', [ + selectedToken + ? h(Identicon, { + diameter: 40, + address: selectedToken.address, + }) + : h('img.send-v2__send-header-icon', { src: '../images/eth_logo.svg' }), + ]) +} + +SendTransactionScreen.prototype.renderTitle = function () { + const { selectedToken } = this.props + + return h('div.send-v2__title', [selectedToken ? 'Send Tokens' : 'Send Funds']) +} + +SendTransactionScreen.prototype.renderCopy = function () { + const { selectedToken } = this.props + + const tokenText = selectedToken ? 'tokens' : 'ETH' + + return h('div.send-v2__form-header-copy', [ + + h('div.send-v2__copy', `Only send ${tokenText} to an Ethereum address.`), + + h('div.send-v2__copy', 'Sending to a different crytpocurrency that is not Ethereum may result in permanent loss.'), + + ]) +} + +SendTransactionScreen.prototype.renderHeader = function () { + return h('div', [ + h('div.send-v2__header', {}, [ + + this.renderHeaderIcon(), + + h('div.send-v2__arrow-background', [ + h('i.fa.fa-lg.fa-arrow-circle-right.send-v2__send-arrow-icon'), + ]), + + h('div.send-v2__header-tip'), + + ]), + + ]) +} + +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) { + const { + updateSendTo, + updateSendErrors, + from: {address: from}, + } = this.props + let toError = null + + if (!to) { + toError = 'Required' + } else if (!isValidAddress(to)) { + toError = 'Recipient address is invalid' + } else if (to === from) { + toError = 'From and To address cannot be the same' + } + + updateSendTo(to) + updateSendErrors({ to: toError }) +} + +SendTransactionScreen.prototype.renderToRow = function () { + const { toAccounts, errors, to } = this.props + + const { toDropdownOpen } = this.state + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', [ + + 'To:', + + this.renderErrorMessage('to'), + + ]), + + h('div.send-v2__form-field', [ + h(ToAutoComplete, { + 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 { + from: { balance }, + updateSendErrors, + amountConversionRate, + conversionRate, + primaryCurrency, + selectedToken, + gasTotal, + tokenBalance, + } = this.props + const { decimals } = selectedToken || {} + const amount = value + + let amountError = null + + let sufficientBalance = true + + if (gasTotal) { + sufficientBalance = isBalanceSufficient({ + amount: selectedToken ? '0x0' : amount, + gasTotal, + balance, + primaryCurrency, + amountConversionRate, + conversionRate, + }) + } + + let sufficientTokens + if (selectedToken) { + sufficientTokens = isTokenBalanceSufficient({ + tokenBalance, + amount, + decimals, + }) + } + + const amountLessThanZero = conversionGreaterThan( + { value: 0, fromNumericBase: 'dec' }, + { value: amount, fromNumericBase: 'hex' }, + ) + + if (conversionRate && !sufficientBalance) { + amountError = 'Insufficient funds.' + } else if (selectedToken && !sufficientTokens) { + amountError = 'Insufficient tokens.' + } else if (amountLessThanZero) { + amountError = 'Can not send negative amounts of ETH.' + } + + updateSendErrors({ amount: amountError }) +} + +SendTransactionScreen.prototype.renderAmountRow = function () { + const { + selectedToken, + primaryCurrency = 'ETH', + convertedCurrency, + amountConversionRate, + errors, + amount, + setMaxModeTo, + maxModeOn, + } = this.props + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', [ + 'Amount:', + this.renderErrorMessage('amount'), + !errors.amount && h('div.send-v2__amount-max', { + onClick: (event) => { + event.preventDefault() + setMaxModeTo(true) + this.setAmountToMax() + }, + }, [ !maxModeOn ? '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 + + return h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', 'Gas fee:'), + + h('div.send-v2__form-field', [ + + h(GasFeeDisplay, { + gasTotal, + conversionRate, + convertedCurrency, + onClick: showCustomizeGasModal, + }), + + ]), + + ]) +} + +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('div.send-v2__form', {}, [ + + h('div.sendV2__form-header', [ + + this.renderTitle(), + + this.renderCopy(), + + ]), + + this.renderFromRow(), + + this.renderToRow(), + + this.renderAmountRow(), + + this.renderGasRow(), + + // this.renderMemoRow(), + + ]) +} + +SendTransactionScreen.prototype.renderFooter = function () { + const { + goHome, + clearSend, + gasTotal, + errors: { amount: amountError, to: toError }, + } = this.props + + const noErrors = !amountError && toError === null + + return h('div.send-v2__footer', [ + h('button.btn-cancel.send-v2__cancel-btn', { + onClick: () => { + clearSend() + goHome() + }, + }, 'Cancel'), + h('button.btn-clear.send-v2__next-btn', { + disabled: !noErrors || !gasTotal, + onClick: event => this.onSubmit(event), + }, 'Next'), + ]) +} + +SendTransactionScreen.prototype.render = function () { + return ( + + h('div.send-v2__container', [ + + this.renderHeader(), + + this.renderForm(), + + this.renderFooter(), + ]) + + ) +} + +SendTransactionScreen.prototype.addToAddressBookIfNew = function (newAddress) { + const { toAccounts, addToAddressBook } = this.props + if (!toAccounts.find(({ address }) => newAddress === address)) { + // TODO: nickname, i.e. addToAddressBook(recipient, nickname) + addToAddressBook(newAddress) + } +} + +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 { + Object.assign(editingTx.txParams, { + value: ethUtil.addHexPrefix(amount), + to: ethUtil.addHexPrefix(to), + }) + } + + return editingTx +} + +SendTransactionScreen.prototype.onSubmit = function (event) { + event.preventDefault() + const { + from: {address: from}, + to, + amount, + gasLimit: gas, + gasPrice, + signTokenTx, + signTx, + updateTx, + selectedToken, + editingTransactionId, + errors: { amount: amountError, to: toError }, + } = this.props + + const noErrors = !amountError && toError === null + + if (!noErrors) { + return + } + + this.addToAddressBookIfNew(to) + + if (editingTransactionId) { + const editedTx = this.getEditedTx() + + updateTx(editedTx) + } else { + + const txParams = { + from, + value: '0', + gas, + gasPrice, + } + + if (!selectedToken) { + txParams.value = amount + txParams.to = to + } + + selectedToken + ? signTokenTx(selectedToken.address, to, amount, txParams) + : signTx(txParams) + } +} diff --git a/ui/app/send.js b/ui/app/send.js index 09c9e03d4..517b7690d 100644 --- a/ui/app/send.js +++ b/ui/app/send.js @@ -1,309 +1,547 @@ -const inherits = require('util').inherits -const PersistentForm = require('../lib/persistent-form') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const Identicon = require('./components/identicon') -const actions = require('./actions') -const util = require('./util') -const numericBalance = require('./util').numericBalance -const addressSummary = require('./util').addressSummary -const isHex = require('./util').isHex -const EthBalance = require('./components/eth-balance') -const EnsInput = require('./components/ens-input') -const ethUtil = require('ethereumjs-util') -module.exports = connect(mapStateToProps)(SendTransactionScreen) - -function mapStateToProps (state) { - var result = { - address: state.metamask.selectedAddress, - accounts: state.metamask.accounts, - identities: state.metamask.identities, - warning: state.appState.warning, - network: state.metamask.network, - addressBook: state.metamask.addressBook, - conversionRate: state.metamask.conversionRate, - currentCurrency: state.metamask.currentCurrency, - } - - result.error = result.warning && result.warning.split('.')[0] - - result.account = result.accounts[result.address] - result.identity = result.identities[result.address] - result.balance = result.account ? numericBalance(result.account.balance) : null - - return result -} - -inherits(SendTransactionScreen, PersistentForm) -function SendTransactionScreen () { - PersistentForm.call(this) -} - -SendTransactionScreen.prototype.render = function () { - this.persistentFormParentId = 'send-tx-form' - - const props = this.props - const { - address, - account, - identity, - network, - identities, - addressBook, - conversionRate, - currentCurrency, - } = props - - return ( - - h('.send-screen.flex-column.flex-grow', [ - - // - // Sender Profile - // - - h('.account-data-subsection.flex-row.flex-grow', { - style: { - margin: '0 20px', - }, - }, [ - - // header - identicon + nav - h('.flex-row.flex-space-between', { - style: { - marginTop: '15px', - }, - }, [ - // back button - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer.color-orange', { - onClick: this.back.bind(this), - }), - - // large identicon - h('.identicon-wrapper.flex-column.flex-center.select-none', [ - h(Identicon, { - diameter: 62, - address: address, - }), - ]), - - // invisible place holder - h('i.fa.fa-users.fa-lg.invisible', { - style: { - marginTop: '28px', - }, - }), - - ]), - - // account label - - h('.flex-column', { - style: { - marginTop: '10px', - alignItems: 'flex-start', - }, - }, [ - h('h2.font-medium.color-forest.flex-center', { - style: { - paddingTop: '8px', - marginBottom: '8px', - }, - }, identity && identity.name), - - // address and getter actions - h('.flex-row.flex-center', { - style: { - marginBottom: '8px', - }, - }, [ - - h('div', { - style: { - lineHeight: '16px', - }, - }, addressSummary(address)), - - ]), - - // balance - h('.flex-row.flex-center', [ - - h(EthBalance, { - value: account && account.balance, - conversionRate, - currentCurrency, - }), - - ]), - ]), - ]), - - // - // Required Fields - // - - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginTop: '15px', - marginBottom: '16px', - }, - }, [ - 'Send Transaction', - ]), - - // error message - props.error && h('span.error.flex-center', props.error), - - // 'to' field - h('section.flex-row.flex-center', [ - h(EnsInput, { - name: 'address', - placeholder: 'Recipient Address', - onChange: this.recipientDidChange.bind(this), - network, - identities, - addressBook, - }), - ]), - - // 'amount' and send button - h('section.flex-row.flex-center', [ - - h('input.large-input', { - name: 'amount', - placeholder: 'Amount', - type: 'number', - style: { - marginRight: '6px', - }, - dataset: { - persistentFormId: 'tx-amount', - }, - }), - - h('button.primary', { - onClick: this.onSubmit.bind(this), - style: { - textTransform: 'uppercase', - }, - }, 'Next'), - - ]), - - // - // Optional Fields - // - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginTop: '16px', - marginBottom: '16px', - }, - }, [ - 'Transaction Data (optional)', - ]), - - // 'data' field - h('section.flex-column.flex-center', [ - h('input.large-input', { - name: 'txData', - placeholder: '0x01234', - style: { - width: '100%', - resize: 'none', - }, - dataset: { - persistentFormId: 'tx-data', - }, - }), - ]), - ]) - ) -} - -SendTransactionScreen.prototype.navigateToAccounts = function (event) { - event.stopPropagation() - this.props.dispatch(actions.showAccountsPage()) -} - -SendTransactionScreen.prototype.back = function () { - var address = this.props.address - this.props.dispatch(actions.backToAccountDetail(address)) -} - -SendTransactionScreen.prototype.recipientDidChange = function (recipient, nickname) { - this.setState({ - recipient: recipient, - nickname: nickname, - }) -} - -SendTransactionScreen.prototype.onSubmit = function () { - const state = this.state || {} - const recipient = state.recipient || document.querySelector('input[name="address"]').value.replace(/^[.\s]+|[.\s]+$/g, '') - const nickname = state.nickname || ' ' - const input = document.querySelector('input[name="amount"]').value - const parts = input.split('') - - let message - - if (isNaN(input) || input === '') { - message = 'Invalid ether value.' - return this.props.dispatch(actions.displayWarning(message)) - } - - if (parts[1]) { - var decimal = parts[1] - if (decimal.length > 18) { - message = 'Ether amount is too precise.' - return this.props.dispatch(actions.displayWarning(message)) - } - } - - const value = util.normalizeEthStringToWei(input) - const txData = document.querySelector('input[name="txData"]').value - const balance = this.props.balance - - if (value.gt(balance)) { - message = 'Insufficient funds.' - return this.props.dispatch(actions.displayWarning(message)) - } - - if (input < 0) { - message = 'Can not send negative amounts of ETH.' - return this.props.dispatch(actions.displayWarning(message)) - } - - if ((util.isInvalidChecksumAddress(recipient))) { - message = 'Recipient address checksum is invalid.' - return this.props.dispatch(actions.displayWarning(message)) - } - - if ((!util.isValidAddress(recipient) && !txData) || (!recipient && !txData)) { - message = 'Recipient address is invalid.' - return this.props.dispatch(actions.displayWarning(message)) - } - - if (!isHex(ethUtil.stripHexPrefix(txData)) && txData) { - message = 'Transaction data must be hex string.' - return this.props.dispatch(actions.displayWarning(message)) - } - - this.props.dispatch(actions.hideWarning()) - - this.props.dispatch(actions.addToAddressBook(recipient, nickname)) - - var txParams = { - from: this.props.address, - value: '0x' + value.toString(16), - } - - if (recipient) txParams.to = ethUtil.addHexPrefix(recipient) - if (txData) txParams.data = txData - - this.props.dispatch(actions.signTx(txParams)) -} +// const { inherits } = require('util') +// const PersistentForm = require('../lib/persistent-form') +// const h = require('react-hyperscript') +// const connect = require('react-redux').connect +// const Identicon = require('./components/identicon') +// const EnsInput = require('./components/ens-input') +// const GasTooltip = require('./components/send/gas-tooltip') +// const CurrencyToggle = require('./components/send/currency-toggle') +// const GasFeeDisplay = require('./components/send/gas-fee-display') +// const { getSelectedIdentity } = require('./selectors') + +// const { +// showAccountsPage, +// backToAccountDetail, +// displayWarning, +// hideWarning, +// addToAddressBook, +// signTx, +// estimateGas, +// getGasPrice, +// } = require('./actions') +// const { stripHexPrefix, addHexPrefix } = require('ethereumjs-util') +// const { isHex, numericBalance, isValidAddress, allNull } = require('./util') +// const { conversionUtil, conversionGreaterThan } = require('./conversion-util') + +// module.exports = connect(mapStateToProps)(SendTransactionScreen) + +// function mapStateToProps (state) { +// const { +// selectedAddress: address, +// accounts, +// identities, +// network, +// addressBook, +// conversionRate, +// currentBlockGasLimit: blockGasLimit, +// } = state.metamask +// const { warning } = state.appState +// const selectedIdentity = getSelectedIdentity(state) +// const account = accounts[address] + +// return { +// address, +// accounts, +// identities, +// network, +// addressBook, +// conversionRate, +// blockGasLimit, +// warning, +// selectedIdentity, +// error: warning && warning.split('.')[0], +// account, +// identity: identities[address], +// balance: account ? account.balance : null, +// } +// } + +// inherits(SendTransactionScreen, PersistentForm) +// function SendTransactionScreen () { +// PersistentForm.call(this) + +// // [WIP] These are the bare minimum of tx props needed to sign a transaction +// // We will need a few more for contract-related interactions +// this.state = { +// newTx: { +// from: '', +// to: '', +// amountToSend: '0x0', +// gasPrice: null, +// gas: null, +// amount: '0x0', +// txData: null, +// memo: '', +// }, +// activeCurrency: 'USD', +// tooltipIsOpen: false, +// errors: {}, +// isValid: false, +// } + +// this.back = this.back.bind(this) +// this.closeTooltip = this.closeTooltip.bind(this) +// this.onSubmit = this.onSubmit.bind(this) +// this.setActiveCurrency = this.setActiveCurrency.bind(this) +// this.toggleTooltip = this.toggleTooltip.bind(this) +// this.validate = this.validate.bind(this) +// this.getAmountToSend = this.getAmountToSend.bind(this) +// this.setErrorsFor = this.setErrorsFor.bind(this) +// this.clearErrorsFor = this.clearErrorsFor.bind(this) + +// this.renderFromInput = this.renderFromInput.bind(this) +// this.renderToInput = this.renderToInput.bind(this) +// this.renderAmountInput = this.renderAmountInput.bind(this) +// this.renderGasInput = this.renderGasInput.bind(this) +// this.renderMemoInput = this.renderMemoInput.bind(this) +// this.renderErrorMessage = this.renderErrorMessage.bind(this) +// } + +// SendTransactionScreen.prototype.componentWillMount = function () { +// const { newTx } = this.state +// const { address } = this.props + +// Promise.all([ +// this.props.dispatch(getGasPrice()), +// this.props.dispatch(estimateGas({ +// from: address, +// gas: '746a528800', +// })), +// ]) +// .then(([blockGasPrice, estimatedGas]) => { +// console.log({ blockGasPrice, estimatedGas}) +// this.setState({ +// newTx: { +// ...newTx, +// gasPrice: blockGasPrice, +// gas: estimatedGas, +// }, +// }) +// }) +// } + +// SendTransactionScreen.prototype.renderErrorMessage = function(errorType, warning) { +// const { errors } = this.state +// const errorMessage = errors[errorType]; + +// return errorMessage || warning +// ? h('div.send-screen-input-wrapper__error-message', [ errorMessage || warning ]) +// : null +// } + +// SendTransactionScreen.prototype.renderFromInput = function (from, identities) { +// return h('div.send-screen-input-wrapper', [ + +// h('div', 'From:'), + +// h('input.large-input.send-screen-input', { +// list: 'accounts', +// placeholder: 'Account', +// value: from, +// onChange: (event) => { +// this.setState({ +// newTx: { +// ...this.state.newTx, +// from: event.target.value, +// }, +// }) +// }, +// onBlur: () => this.setErrorsFor('from'), +// onFocus: event => { +// this.clearErrorsFor('from') +// this.state.newTx.from && event.target.select() +// }, +// }), + +// h('datalist#accounts', [ +// Object.entries(identities).map(([key, { address, name }]) => { +// return h('option', { +// value: address, +// label: name, +// key: address, +// }) +// }), +// ]), + +// this.renderErrorMessage('from'), + +// ]) +// } + +// SendTransactionScreen.prototype.renderToInput = function (to, identities, addressBook) { +// return h('div.send-screen-input-wrapper', [ + +// h('div', 'To:'), + +// h('input.large-input.send-screen-input', { +// name: 'address', +// list: 'addresses', +// placeholder: 'Address', +// value: to, +// onChange: (event) => { +// this.setState({ +// newTx: { +// ...this.state.newTx, +// to: event.target.value, +// }, +// }) +// }, +// onBlur: () => { +// this.setErrorsFor('to') +// }, +// onFocus: event => { +// this.clearErrorsFor('to') +// this.state.newTx.to && event.target.select() +// }, +// }), + +// h('datalist#addresses', [ +// // Corresponds to the addresses owned. +// ...Object.entries(identities).map(([key, { address, name }]) => { +// return h('option', { +// value: address, +// label: name, +// key: address, +// }) +// }), +// // Corresponds to previously sent-to addresses. +// ...addressBook.map(({ address, name }) => { +// return h('option', { +// value: address, +// label: name, +// key: address, +// }) +// }), +// ]), + +// this.renderErrorMessage('to'), + +// ]) +// } + +// SendTransactionScreen.prototype.renderAmountInput = function (activeCurrency) { +// return h('div.send-screen-input-wrapper', [ + +// h('div.send-screen-amount-labels', [ +// h('span', 'Amount'), +// h(CurrencyToggle, { +// activeCurrency, +// onClick: (newCurrency) => this.setActiveCurrency(newCurrency), +// }), // holding on icon from design +// ]), + +// h('input.large-input.send-screen-input', { +// placeholder: `0 ${activeCurrency}`, +// type: 'number', +// onChange: (event) => { +// const amountToSend = event.target.value +// ? this.getAmountToSend(event.target.value) +// : '0x0' + +// this.setState({ +// newTx: Object.assign( +// this.state.newTx, +// { +// amount: event.target.value, +// amountToSend: amountToSend, +// } +// ), +// }) +// }, +// onBlur: () => { +// this.setErrorsFor('amount') +// }, +// onFocus: () => this.clearErrorsFor('amount'), +// }), + +// this.renderErrorMessage('amount'), + +// ]) +// } + +// SendTransactionScreen.prototype.renderGasInput = function (gasPrice, gas, activeCurrency, conversionRate, blockGasLimit) { +// return h('div.send-screen-input-wrapper', [ +// this.state.tooltipIsOpen && h(GasTooltip, { +// className: 'send-tooltip', +// gasPrice, +// gasLimit: gas, +// onClose: this.closeTooltip, +// onFeeChange: ({gasLimit, gasPrice}) => { +// this.setState({ +// newTx: { +// ...this.state.newTx, +// gas: gasLimit, +// gasPrice, +// }, +// }) +// }, +// }), + +// h('div.send-screen-gas-labels', [ +// h('span', [ +// h('i.fa.fa-bolt'), +// 'Gas fee:', +// ]), +// h('span', 'What\'s this?'), +// ]), + +// // TODO: handle loading time when switching to USD +// h('div.large-input.send-screen-gas-input', {}, [ +// h(GasFeeDisplay, { +// activeCurrency, +// conversionRate, +// gas, +// gasPrice, +// blockGasLimit, +// }), +// h('div.send-screen-gas-input-customize', { +// onClick: this.toggleTooltip, +// }, [ +// 'Customize', +// ]), +// ]), + +// ]) +// } + +// SendTransactionScreen.prototype.renderMemoInput = function () { +// return h('div.send-screen-input-wrapper', [ +// h('div', 'Transaction memo (optional)'), +// h('input.large-input.send-screen-input', { +// onChange: () => { +// this.setState({ +// newTx: Object.assign( +// this.state.newTx, +// { +// memo: event.target.value, +// } +// ), +// }) +// }, +// }), +// ]) +// } + +// SendTransactionScreen.prototype.render = function () { +// this.persistentFormParentId = 'send-tx-form' + +// const props = this.props +// const { +// warning, +// identities, +// addressBook, +// conversionRate, +// } = props + +// const { +// blockGasLimit, +// newTx, +// activeCurrency, +// isValid, +// } = this.state +// const { gas, gasPrice } = newTx + +// return ( + +// h('div.send-screen-wrapper', [ +// // Main Send token Card +// h('div.send-screen-card', [ + +// h('img.send-eth-icon', { src: '../images/eth_logo.svg' }), + +// h('div.send-screen__title', 'Send'), + +// h('div.send-screen__subtitle', 'Send Ethereum to anyone with an Ethereum account'), + +// this.renderFromInput(this.state.newTx.from, identities), + +// this.renderToInput(this.state.newTx.to, identities, addressBook), + +// this.renderAmountInput(activeCurrency), + +// this.renderGasInput( +// gasPrice || '0x0', +// gas || '0x0', +// activeCurrency, +// conversionRate, +// blockGasLimit +// ), + +// this.renderMemoInput(), + +// this.renderErrorMessage(null, warning), + +// ]), + +// // Buttons underneath card +// h('section.flex-column.flex-center', [ +// h('button.btn-secondary.send-screen__send-button', { +// className: !isValid && 'send-screen__send-button__disabled', +// onClick: (event) => isValid && this.onSubmit(event), +// }, 'Next'), +// h('button.btn-tertiary.send-screen__cancel-button', { +// onClick: this.back, +// }, 'Cancel'), +// ]), +// ]) + +// ) +// } + +// SendTransactionScreen.prototype.toggleTooltip = function () { +// this.setState({ tooltipIsOpen: !this.state.tooltipIsOpen }) +// } + +// SendTransactionScreen.prototype.closeTooltip = function () { +// this.setState({ tooltipIsOpen: false }) +// } + +// SendTransactionScreen.prototype.setActiveCurrency = function (newCurrency) { +// this.setState({ activeCurrency: newCurrency }) +// } + +// SendTransactionScreen.prototype.back = function () { +// var address = this.props.address +// this.props.dispatch(backToAccountDetail(address)) +// } + +// SendTransactionScreen.prototype.validate = function (balance, amountToSend, { to, from }) { +// const sufficientBalance = conversionGreaterThan( +// { +// value: balance, +// fromNumericBase: 'hex', +// }, +// { +// value: amountToSend, +// fromNumericBase: 'hex', +// }, +// ) + +// const amountLessThanZero = conversionGreaterThan( +// { +// value: 0, +// fromNumericBase: 'dec', +// }, +// { +// value: amountToSend, +// fromNumericBase: 'hex', +// }, +// ) + +// const errors = {} + +// if (!sufficientBalance) { +// errors.amount = 'Insufficient funds.' +// } + +// if (amountLessThanZero) { +// errors.amount = 'Can not send negative amounts of ETH.' +// } + +// if (!from) { +// errors.from = 'Required' +// } + +// if (from && !isValidAddress(from)) { +// errors.from = 'Sender address is invalid.' +// } + +// if (!to) { +// errors.to = 'Required' +// } + +// if (to && !isValidAddress(to)) { +// errors.to = 'Recipient address is invalid.' +// } + +// // if (txData && !isHex(stripHexPrefix(txData))) { +// // message = 'Transaction data must be hex string.' +// // return this.props.dispatch(displayWarning(message)) +// // } + +// return { +// isValid: allNull(errors), +// errors, +// } +// } + +// SendTransactionScreen.prototype.getAmountToSend = function (amount) { +// const { activeCurrency } = this.state +// const { conversionRate } = this.props + +// return conversionUtil(amount, { +// fromNumericBase: 'dec', +// toNumericBase: 'hex', +// fromCurrency: activeCurrency, +// toCurrency: 'ETH', +// toDenomination: 'WEI', +// conversionRate, +// invertConversionRate: activeCurrency !== 'ETH', +// }) +// } + +// SendTransactionScreen.prototype.setErrorsFor = function (field) { +// const { balance } = this.props +// const { newTx, errors: previousErrors } = this.state +// const { amountToSend } = newTx + +// const { +// isValid, +// errors: newErrors +// } = this.validate(balance, amountToSend, newTx) + +// const nextErrors = Object.assign({}, previousErrors, { +// [field]: newErrors[field] || null +// }) + +// if (!isValid) { +// this.setState({ +// errors: nextErrors, +// isValid, +// }) +// } +// } + +// SendTransactionScreen.prototype.clearErrorsFor = function (field) { +// const { errors: previousErrors } = this.state +// const nextErrors = Object.assign({}, previousErrors, { +// [field]: null +// }) + +// this.setState({ +// errors: nextErrors, +// isValid: allNull(nextErrors), +// }) +// } + +// SendTransactionScreen.prototype.onSubmit = function (event) { +// event.preventDefault() +// const { warning, balance } = this.props +// const state = this.state || {} + +// const recipient = state.newTx.to +// const sender = state.newTx.from +// const nickname = state.nickname || ' ' + +// // TODO: convert this to hex when created and include it in send +// const txData = state.newTx.memo + +// this.props.dispatch(hideWarning()) + +// this.props.dispatch(addToAddressBook(recipient, nickname)) + +// var txParams = { +// from: this.state.newTx.from, +// to: this.state.newTx.to, + +// value: this.state.newTx.amountToSend, + +// gas: this.state.newTx.gas, +// gasPrice: this.state.newTx.gasPrice, +// } + +// if (recipient) txParams.to = addHexPrefix(recipient) +// if (txData) txParams.data = txData + +// this.props.dispatch(signTx(txParams)) +// } diff --git a/ui/app/settings.js b/ui/app/settings.js index 454cc95e0..686e31bb9 100644 --- a/ui/app/settings.js +++ b/ui/app/settings.js @@ -1,59 +1,416 @@ -const inherits = require('util').inherits -const Component = require('react').Component +const { Component } = require('react') +const PropTypes = require('prop-types') const h = require('react-hyperscript') -const connect = require('react-redux').connect +const { connect } = require('react-redux') const actions = require('./actions') +const infuraCurrencies = require('./infura-conversion.json') +const validUrl = require('valid-url') +const { exportAsFile } = require('./util') +const TabBar = require('./components/tab-bar') +const SimpleDropdown = require('./components/dropdowns/simple-dropdown') +const ToggleButton = require('react-toggle-button') +const { OLD_UI_NETWORK_TYPE } = require('../../app/scripts/config').enums -module.exports = connect(mapStateToProps)(AppSettingsPage) +const getInfuraCurrencyOptions = () => { + const sortedCurrencies = infuraCurrencies.objects.sort((a, b) => { + return a.quote.name.toLocaleLowerCase().localeCompare(b.quote.name.toLocaleLowerCase()) + }) -function mapStateToProps (state) { - return {} + return sortedCurrencies.map(({ quote: { code, name } }) => { + return { + displayValue: `${code.toUpperCase()} - ${name}`, + key: code, + value: code, + } + }) } -inherits(AppSettingsPage, Component) -function AppSettingsPage () { - Component.call(this) -} +class Settings extends Component { + constructor (props) { + super(props) -AppSettingsPage.prototype.render = function () { - return ( + const { tab } = props + const activeTab = tab === 'info' ? 'info' : 'settings' - h('.account-detail-section.flex-column.flex-grow', [ + this.state = { + activeTab, + newRpc: '', + } + } - // subtitle and nav - h('.flex-row.flex-center', [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - onClick: this.navigateToAccounts.bind(this), - }), - h('h2.page-subtitle', 'Settings'), - ]), + renderTabs () { + const { activeTab } = this.state - h('label', { - htmlFor: 'settings-rpc-endpoint', - }, 'RPC Endpoint:'), - h('input', { - type: 'url', - id: 'settings-rpc-endpoint', - onKeyPress: this.onKeyPress.bind(this), + return h('div.settings__tabs', [ + h(TabBar, { + tabs: [ + { content: 'Settings', key: 'settings' }, + { content: 'Info', key: 'info' }, + ], + defaultTab: activeTab, + tabSelected: key => this.setState({ activeTab: key }), }), + ]) + } + + renderBlockieOptIn () { + const { metamask: { useBlockie }, setUseBlockie } = this.props + + return h('div.settings__content-row', [ + h('div.settings__content-item', [ + h('span', 'Use Blockies Identicon'), + ]), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h(ToggleButton, { + value: useBlockie, + onToggle: (value) => setUseBlockie(!value), + activeLabel: '', + inactiveLabel: '', + }), + ]), + ]), + ]) + } + renderCurrentConversion () { + const { metamask: { currentCurrency, conversionDate }, setCurrentCurrency } = this.props + + return h('div.settings__content-row', [ + h('div.settings__content-item', [ + h('span', 'Current Conversion'), + h('span.settings__content-description', `Updated ${Date(conversionDate)}`), + ]), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h(SimpleDropdown, { + placeholder: 'Select Currency', + options: getInfuraCurrencyOptions(), + selectedOption: currentCurrency, + onSelect: newCurrency => setCurrentCurrency(newCurrency), + }), + ]), + ]), ]) + } + + renderCurrentProvider () { + const { metamask: { provider = {} } } = this.props + let title, value, color + + switch (provider.type) { + + case 'mainnet': + title = 'Current Network' + value = 'Main Ethereum Network' + color = '#038789' + break + + case 'ropsten': + title = 'Current Network' + value = 'Ropsten Test Network' + color = '#e91550' + break + + case 'kovan': + title = 'Current Network' + value = 'Kovan Test Network' + color = '#690496' + break + + case 'rinkeby': + title = 'Current Network' + value = 'Rinkeby Test Network' + color = '#ebb33f' + break + + default: + title = 'Current RPC' + value = provider.rpcTarget + } + + return h('div.settings__content-row', [ + h('div.settings__content-item', title), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h('div.settings__provider-wrapper', [ + h('div.settings__provider-icon', { style: { background: color } }), + h('div', value), + ]), + ]), + ]), + ]) + } + + renderNewRpcUrl () { + return ( + h('div.settings__content-row', [ + h('div.settings__content-item', [ + h('span', 'New RPC URL'), + ]), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h('input.settings__input', { + placeholder: 'New RPC URL', + onChange: event => this.setState({ newRpc: event.target.value }), + onKeyPress: event => { + if (event.key === 'Enter') { + this.validateRpc(this.state.newRpc) + } + }, + }), + h('div.settings__rpc-save-button', { + onClick: event => { + event.preventDefault() + this.validateRpc(this.state.newRpc) + }, + }, 'Save'), + ]), + ]), + ]) + ) + } + + validateRpc (newRpc) { + const { setRpcTarget, displayWarning } = this.props + + if (validUrl.isWebUri(newRpc)) { + setRpcTarget(newRpc) + } else { + const appendedRpc = `http://${newRpc}` + + if (validUrl.isWebUri(appendedRpc)) { + displayWarning('URIs require the appropriate HTTP/HTTPS prefix.') + } else { + displayWarning('Invalid RPC URI') + } + } + } + + renderStateLogs () { + return ( + h('div.settings__content-row', [ + h('div.settings__content-item', [ + h('div', 'State Logs'), + h( + 'div.settings__content-description', + 'State logs contain your public account addresses and sent transactions.' + ), + ]), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h('button.settings__clear-button', { + onClick (event) { + exportAsFile('MetaMask State Logs', window.logState()) + }, + }, 'Download State Logs'), + ]), + ]), + ]) + ) + } + + renderSeedWords () { + const { revealSeedConfirmation } = this.props + + return ( + h('div.settings__content-row', [ + h('div.settings__content-item', 'Reveal Seed Words'), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h('button.settings__clear-button.settings__clear-button--red', { + onClick (event) { + event.preventDefault() + revealSeedConfirmation() + }, + }, 'Reveal Seed Words'), + ]), + ]), + ]) + ) + } + + renderOldUI () { + const { setFeatureFlagToBeta } = this.props + + return ( + h('div.settings__content-row', [ + h('div.settings__content-item', 'Use old UI'), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h('button.settings__clear-button.settings__clear-button--orange', { + onClick (event) { + event.preventDefault() + setFeatureFlagToBeta() + }, + }, 'Use old UI'), + ]), + ]), + ]) + ) + } + + renderSettingsContent () { + const { warning, isMascara } = this.props - ) + return ( + h('div.settings__content', [ + warning && h('div.settings__error', warning), + this.renderCurrentConversion(), + // this.renderCurrentProvider(), + this.renderNewRpcUrl(), + this.renderStateLogs(), + this.renderSeedWords(), + !isMascara && this.renderOldUI(), + this.renderBlockieOptIn(), + ]) + ) + } + + renderLogo () { + return ( + h('div.settings__info-logo-wrapper', [ + h('img.settings__info-logo', { src: 'images/info-logo.png' }), + ]) + ) + } + + renderInfoLinks () { + return ( + h('div.settings__content-item.settings__content-item--without-height', [ + h('div.settings__info-link-header', 'Links'), + h('div.settings__info-link-item', [ + h('a', { + href: 'https://metamask.io/privacy.html', + target: '_blank', + }, [ + h('span.settings__info-link', 'Privacy Policy'), + ]), + ]), + h('div.settings__info-link-item', [ + h('a', { + href: 'https://metamask.io/terms.html', + target: '_blank', + }, [ + h('span.settings__info-link', 'Terms of Use'), + ]), + ]), + h('div.settings__info-link-item', [ + h('a', { + href: 'https://metamask.io/attributions.html', + target: '_blank', + }, [ + h('span.settings__info-link', 'Attributions'), + ]), + ]), + h('hr.settings__info-separator'), + h('div.settings__info-link-item', [ + h('a', { + href: 'https://support.metamask.io', + target: '_blank', + }, [ + h('span.settings__info-link', 'Visit our Support Center'), + ]), + ]), + h('div.settings__info-link-item', [ + h('a', { + href: 'https://metamask.io/', + target: '_blank', + }, [ + h('span.settings__info-link', 'Visit our web site'), + ]), + ]), + h('div.settings__info-link-item', [ + h('a', { + target: '_blank', + href: 'mailto:help@metamask.io?subject=Feedback', + }, [ + h('span.settings__info-link', 'Email us!'), + ]), + ]), + ]) + ) + } + + renderInfoContent () { + return ( + h('div.settings__content', [ + h('div.settings__content-row', [ + h('div.settings__content-item.settings__content-item--without-height', [ + this.renderLogo(), + h('div.settings__info-item', [ + h('div.settings__info-version-header', 'MetaMask Version'), + h('div.settings__info-version-number', '4.0.0'), + ]), + h('div.settings__info-item', [ + h( + 'div.settings__info-about', + 'MetaMask is designed and built in California.' + ), + ]), + ]), + this.renderInfoLinks(), + ]), + ]) + ) + } + + render () { + const { goHome } = this.props + const { activeTab } = this.state + + return ( + h('.main-container.settings', {}, [ + h('.settings__header', [ + h('div.settings__close-button', { + onClick: goHome, + }), + this.renderTabs(), + ]), + + activeTab === 'settings' + ? this.renderSettingsContent() + : this.renderInfoContent(), + ]) + ) + } } -AppSettingsPage.prototype.componentDidMount = function () { - document.querySelector('input').focus() +Settings.propTypes = { + tab: PropTypes.string, + metamask: PropTypes.object, + setUseBlockie: PropTypes.func, + setCurrentCurrency: PropTypes.func, + setRpcTarget: PropTypes.func, + displayWarning: PropTypes.func, + revealSeedConfirmation: PropTypes.func, + setFeatureFlagToBeta: PropTypes.func, + warning: PropTypes.string, + goHome: PropTypes.func, + isMascara: PropTypes.bool, } -AppSettingsPage.prototype.onKeyPress = function (event) { - // get submit event - if (event.key === 'Enter') { - // this.submitPassword(event) +const mapStateToProps = state => { + return { + metamask: state.metamask, + warning: state.appState.warning, + isMascara: state.metamask.isMascara, } } -AppSettingsPage.prototype.navigateToAccounts = function (event) { - event.stopPropagation() - this.props.dispatch(actions.showAccountsPage()) +const mapDispatchToProps = dispatch => { + return { + goHome: () => dispatch(actions.goHome()), + setCurrentCurrency: currency => dispatch(actions.setCurrentCurrency(currency)), + setRpcTarget: newRpc => dispatch(actions.setRpcTarget(newRpc)), + displayWarning: warning => dispatch(actions.displayWarning(warning)), + revealSeedConfirmation: () => dispatch(actions.revealSeedConfirmation()), + setUseBlockie: value => dispatch(actions.setUseBlockie(value)), + setFeatureFlagToBeta: () => { + return dispatch(actions.setFeatureFlag('betaUI', false, 'OLD_UI_NOTIFICATION_MODAL')) + .then(() => dispatch(actions.setNetworkEndpoints(OLD_UI_NETWORK_TYPE))) + }, + } } + +module.exports = connect(mapStateToProps, mapDispatchToProps)(Settings) diff --git a/ui/app/token-tracker.js b/ui/app/token-tracker.js new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ui/app/token-tracker.js diff --git a/ui/app/token-util.js b/ui/app/token-util.js new file mode 100644 index 000000000..f84051ef5 --- /dev/null +++ b/ui/app/token-util.js @@ -0,0 +1,45 @@ +const abi = require('human-standard-token-abi') +const Eth = require('ethjs-query') +const EthContract = require('ethjs-contract') + +const tokenInfoGetter = function () { + if (typeof global.ethereumProvider === 'undefined') return + + const eth = new Eth(global.ethereumProvider) + const contract = new EthContract(eth) + const TokenContract = contract(abi) + + const tokens = {} + + return async (address) => { + if (tokens[address]) { + return tokens[address] + } + + const contract = TokenContract.at(address) + + const result = await Promise.all([ + contract.symbol(), + contract.decimals(), + ]) + + const [ symbol = [], decimals = [] ] = result + + tokens[address] = { symbol: symbol[0], decimals: decimals[0] } + + return tokens[address] + } +} + +function calcTokenAmount (value, decimals) { + const multiplier = Math.pow(10, Number(decimals || 0)) + const amount = Number(value / multiplier) + + return amount +} + + +module.exports = { + tokenInfoGetter, + calcTokenAmount, +} diff --git a/ui/app/unlock.js b/ui/app/unlock.js index 4180791c4..ec97b03bf 100644 --- a/ui/app/unlock.js +++ b/ui/app/unlock.js @@ -50,7 +50,7 @@ UnlockScreen.prototype.render = function () { id: 'password-box', placeholder: 'enter password', style: { - + background: 'white', }, onKeyPress: this.onKeyPress.bind(this), onInput: this.inputChanged.bind(this), diff --git a/ui/app/util.js b/ui/app/util.js index 293f4228c..800ccb218 100644 --- a/ui/app/util.js +++ b/ui/app/util.js @@ -1,4 +1,16 @@ +const abi = require('human-standard-token-abi') const ethUtil = require('ethereumjs-util') +const hexToBn = require('../../app/scripts/lib/hex-to-bn') +const vreme = new (require('vreme'))() + +const MIN_GAS_PRICE_GWEI_BN = new ethUtil.BN(1) +const GWEI_FACTOR = new ethUtil.BN(1e9) +const MIN_GAS_PRICE_BN = MIN_GAS_PRICE_GWEI_BN.mul(GWEI_FACTOR) + +// formatData :: ( date: <Unix Timestamp> ) -> String +function formatDate (date) { + return vreme.format(new Date(date), 'March 16 2014 14:30') +} var valueTable = { wei: '1000000000000000000', @@ -36,8 +48,15 @@ module.exports = { valueTable: valueTable, bnTable: bnTable, isHex: isHex, + formatDate, + bnMultiplyByFraction, + getTxFeeBn, + shortenBalance, + getContractAtAddress, exportAsFile: exportAsFile, isInvalidChecksumAddress, + allNull, + getTokenAddressFromTokenObject, } function valuesFor (obj) { @@ -227,6 +246,24 @@ function isHex (str) { return Boolean(str.match(/^(0x)?[0-9a-fA-F]+$/)) } +function bnMultiplyByFraction (targetBN, numerator, denominator) { + const numBN = new ethUtil.BN(numerator) + const denomBN = new ethUtil.BN(denominator) + return targetBN.mul(numBN).div(denomBN) +} + +function getTxFeeBn (gas, gasPrice = MIN_GAS_PRICE_BN.toString(16), blockGasLimit) { + const gasBn = hexToBn(gas) + const gasPriceBn = hexToBn(gasPrice) + const txFeeBn = gasBn.mul(gasPriceBn) + + return txFeeBn.toString(16) +} + +function getContractAtAddress (tokenAddress) { + return global.eth.contract(abi).at(tokenAddress) +} + function exportAsFile (filename, data) { // source: https://stackoverflow.com/a/33542499 by Ludovic Feltz const blob = new Blob([data], {type: 'text/csv'}) @@ -241,3 +278,11 @@ function exportAsFile (filename, data) { document.body.removeChild(elem) } } + +function allNull (obj) { + return Object.entries(obj).every(([key, value]) => value === null) +} + +function getTokenAddressFromTokenObject (token) { + return Object.values(token)[0].address.toLowerCase() +} @@ -4,11 +4,7 @@ const path = require('path') module.exports = bundleCss var cssFiles = { - 'fonts.css': fs.readFileSync(path.join(__dirname, '/app/css/fonts.css'), 'utf8'), - 'reset.css': fs.readFileSync(path.join(__dirname, '/app/css/reset.css'), 'utf8'), - 'lib.css': fs.readFileSync(path.join(__dirname, '/app/css/lib.css'), 'utf8'), - 'index.css': fs.readFileSync(path.join(__dirname, '/app/css/index.css'), 'utf8'), - 'transitions.css': fs.readFileSync(path.join(__dirname, '/app/css/transitions.css'), 'utf8'), + 'index.css': fs.readFileSync(path.join(__dirname, '/app/css/output/index.css'), 'utf8'), 'first-time.css': fs.readFileSync(path.join(__dirname, '../mascara/src/app/first-time/index.css'), 'utf8'), 'react-tooltip-component.css': fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'react-tooltip-component', 'dist', 'react-tooltip-component.css'), 'utf8'), 'react-css': fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'react-select', 'dist', 'react-select.css'), 'utf8'), diff --git a/ui/index.js b/ui/index.js index ae05cbe67..bc3676c1f 100644 --- a/ui/index.js +++ b/ui/index.js @@ -4,11 +4,12 @@ const Root = require('./app/root') const actions = require('./app/actions') const configureStore = require('./app/store') const txHelper = require('./lib/tx-helper') +const { OLD_UI_NETWORK_TYPE, BETA_UI_NETWORK_TYPE } = require('../app/scripts/config').enums + global.log = require('loglevel') module.exports = launchMetamaskUi - log.setLevel(global.METAMASK_DEBUG ? 'debug' : 'warn') function launchMetamaskUi (opts, cb) { @@ -36,10 +37,17 @@ function startApp (metamaskState, accountManager, opts) { networkVersion: opts.networkVersion, }) + const useBetaUi = metamaskState.featureFlags.betaUI + const networkEndpointType = useBetaUi ? BETA_UI_NETWORK_TYPE : OLD_UI_NETWORK_TYPE + store.dispatch(actions.setNetworkEndpoints(networkEndpointType)) + // if unconfirmed txs, start on txConf page const unapprovedTxsAll = txHelper(metamaskState.unapprovedTxs, metamaskState.unapprovedMsgs, metamaskState.unapprovedPersonalMsgs, metamaskState.unapprovedTypedMessages, metamaskState.network) - if (unapprovedTxsAll.length > 0) { - store.dispatch(actions.showConfTxPage()) + const numberOfUnapprivedTx = unapprovedTxsAll.length + if (numberOfUnapprivedTx > 0) { + store.dispatch(actions.showConfTxPage({ + id: unapprovedTxsAll[numberOfUnapprivedTx - 1].id, + })) } accountManager.on('update', function (metamaskState) { diff --git a/ui/lib/account-link.js b/ui/lib/account-link.js new file mode 100644 index 000000000..037d990fa --- /dev/null +++ b/ui/lib/account-link.js @@ -0,0 +1,26 @@ +module.exports = function (address, network) { + const net = parseInt(network) + let link + switch (net) { + case 1: // main net + link = `https://etherscan.io/address/${address}` + break + case 2: // morden test net + link = `https://morden.etherscan.io/address/${address}` + break + case 3: // ropsten test net + link = `https://ropsten.etherscan.io/address/${address}` + break + case 4: // rinkeby test net + link = `https://rinkeby.etherscan.io/address/${address}` + break + case 42: // kovan test net + link = `https://kovan.etherscan.io/address/${address}` + break + default: + link = '' + break + } + + return link +} diff --git a/ui/lib/blockies.js b/ui/lib/blockies.js new file mode 100644 index 000000000..ee5a2a5ca --- /dev/null +++ b/ui/lib/blockies.js @@ -0,0 +1,364 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : + typeof define === 'function' && define.amd ? define(['exports'], factory) : + (factory((global.blockies = {}))); +}(this, (function (exports) { 'use strict'; + + /** + * A handy class to calculate color values. + * + * @version 1.0 + * @author Robert Eisele <robert@xarg.org> + * @copyright Copyright (c) 2010, Robert Eisele + * @link http://www.xarg.org/2010/03/generate-client-side-png-files-using-javascript/ + * @license http://www.opensource.org/licenses/bsd-license.php BSD License + * + */ + + +// helper functions for that ctx + function write(buffer, offs) { + for (var i = 2; i < arguments.length; i++) { + for (var j = 0; j < arguments[i].length; j++) { + buffer[offs++] = arguments[i].charAt(j); + } + } + } + + function byte2(w) { + return String.fromCharCode((w >> 8) & 255, w & 255); + } + + function byte4(w) { + return String.fromCharCode((w >> 24) & 255, (w >> 16) & 255, (w >> 8) & 255, w & 255); + } + + function byte2lsb(w) { + return String.fromCharCode(w & 255, (w >> 8) & 255); + } + + var PNG = function(width,height,depth) { + + this.width = width; + this.height = height; + this.depth = depth; + + // pixel data and row filter identifier size + this.pix_size = height * (width + 1); + + // deflate header, pix_size, block headers, adler32 checksum + this.data_size = 2 + this.pix_size + 5 * Math.floor((0xfffe + this.pix_size) / 0xffff) + 4; + + // offsets and sizes of Png chunks + this.ihdr_offs = 0; // IHDR offset and size + this.ihdr_size = 4 + 4 + 13 + 4; + this.plte_offs = this.ihdr_offs + this.ihdr_size; // PLTE offset and size + this.plte_size = 4 + 4 + 3 * depth + 4; + this.trns_offs = this.plte_offs + this.plte_size; // tRNS offset and size + this.trns_size = 4 + 4 + depth + 4; + this.idat_offs = this.trns_offs + this.trns_size; // IDAT offset and size + this.idat_size = 4 + 4 + this.data_size + 4; + this.iend_offs = this.idat_offs + this.idat_size; // IEND offset and size + this.iend_size = 4 + 4 + 4; + this.buffer_size = this.iend_offs + this.iend_size; // total PNG size + + this.buffer = new Array(); + this.palette = new Object(); + this.pindex = 0; + + var _crc32 = new Array(); + + // initialize buffer with zero bytes + for (var i = 0; i < this.buffer_size; i++) { + this.buffer[i] = "\x00"; + } + + // initialize non-zero elements + write(this.buffer, this.ihdr_offs, byte4(this.ihdr_size - 12), 'IHDR', byte4(width), byte4(height), "\x08\x03"); + write(this.buffer, this.plte_offs, byte4(this.plte_size - 12), 'PLTE'); + write(this.buffer, this.trns_offs, byte4(this.trns_size - 12), 'tRNS'); + write(this.buffer, this.idat_offs, byte4(this.idat_size - 12), 'IDAT'); + write(this.buffer, this.iend_offs, byte4(this.iend_size - 12), 'IEND'); + + // initialize deflate header + var header = ((8 + (7 << 4)) << 8) | (3 << 6); + header+= 31 - (header % 31); + + write(this.buffer, this.idat_offs + 8, byte2(header)); + + // initialize deflate block headers + for (var i = 0; (i << 16) - 1 < this.pix_size; i++) { + var size, bits; + if (i + 0xffff < this.pix_size) { + size = 0xffff; + bits = "\x00"; + } else { + size = this.pix_size - (i << 16) - i; + bits = "\x01"; + } + write(this.buffer, this.idat_offs + 8 + 2 + (i << 16) + (i << 2), bits, byte2lsb(size), byte2lsb(~size)); + } + + /* Create crc32 lookup table */ + for (var i = 0; i < 256; i++) { + var c = i; + for (var j = 0; j < 8; j++) { + if (c & 1) { + c = -306674912 ^ ((c >> 1) & 0x7fffffff); + } else { + c = (c >> 1) & 0x7fffffff; + } + } + _crc32[i] = c; + } + + // compute the index into a png for a given pixel + this.index = function(x,y) { + var i = y * (this.width + 1) + x + 1; + var j = this.idat_offs + 8 + 2 + 5 * Math.floor((i / 0xffff) + 1) + i; + return j; + }; + + // convert a color and build up the palette + this.color = function(red, green, blue, alpha) { + + alpha = alpha >= 0 ? alpha : 255; + var color = (((((alpha << 8) | red) << 8) | green) << 8) | blue; + + if (typeof this.palette[color] == "undefined") { + if (this.pindex == this.depth) return "\x00"; + + var ndx = this.plte_offs + 8 + 3 * this.pindex; + + this.buffer[ndx + 0] = String.fromCharCode(red); + this.buffer[ndx + 1] = String.fromCharCode(green); + this.buffer[ndx + 2] = String.fromCharCode(blue); + this.buffer[this.trns_offs+8+this.pindex] = String.fromCharCode(alpha); + + this.palette[color] = String.fromCharCode(this.pindex++); + } + return this.palette[color]; + }; + + // output a PNG string, Base64 encoded + this.getBase64 = function() { + + var s = this.getDump(); + + var ch = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + var c1, c2, c3, e1, e2, e3, e4; + var l = s.length; + var i = 0; + var r = ""; + + do { + c1 = s.charCodeAt(i); + e1 = c1 >> 2; + c2 = s.charCodeAt(i+1); + e2 = ((c1 & 3) << 4) | (c2 >> 4); + c3 = s.charCodeAt(i+2); + if (l < i+2) { e3 = 64; } else { e3 = ((c2 & 0xf) << 2) | (c3 >> 6); } + if (l < i+3) { e4 = 64; } else { e4 = c3 & 0x3f; } + r+= ch.charAt(e1) + ch.charAt(e2) + ch.charAt(e3) + ch.charAt(e4); + } while ((i+= 3) < l); + return r; + }; + + // output a PNG string + this.getDump = function() { + + // compute adler32 of output pixels + row filter bytes + var BASE = 65521; /* largest prime smaller than 65536 */ + var NMAX = 5552; /* NMAX is the largest n such that 255n(n+1)/2 + (n+1)(BASE-1) <= 2^32-1 */ + var s1 = 1; + var s2 = 0; + var n = NMAX; + + for (var y = 0; y < this.height; y++) { + for (var x = -1; x < this.width; x++) { + s1+= this.buffer[this.index(x, y)].charCodeAt(0); + s2+= s1; + if ((n-= 1) == 0) { + s1%= BASE; + s2%= BASE; + n = NMAX; + } + } + } + s1%= BASE; + s2%= BASE; + write(this.buffer, this.idat_offs + this.idat_size - 8, byte4((s2 << 16) | s1)); + + // compute crc32 of the PNG chunks + function crc32(png, offs, size) { + var crc = -1; + for (var i = 4; i < size-4; i += 1) { + crc = _crc32[(crc ^ png[offs+i].charCodeAt(0)) & 0xff] ^ ((crc >> 8) & 0x00ffffff); + } + write(png, offs+size-4, byte4(crc ^ -1)); + } + + crc32(this.buffer, this.ihdr_offs, this.ihdr_size); + crc32(this.buffer, this.plte_offs, this.plte_size); + crc32(this.buffer, this.trns_offs, this.trns_size); + crc32(this.buffer, this.idat_offs, this.idat_size); + crc32(this.buffer, this.iend_offs, this.iend_size); + + // convert PNG to string + return "\x89PNG\r\n\x1A\n"+this.buffer.join(''); + }; + + this.fillRect = function (x, y, w, h, color) { + for(var i = 0; i < w; i++) { + for (var j = 0; j < h; j++) { + this.buffer[this.index(x+i, y+j)] = color; + } + } + }; + }; + +// https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion + /** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h, s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + * + * @param {number} h The hue + * @param {number} s The saturation + * @param {number} l The lightness + * @return {Array} The RGB representation + */ + + function hue2rgb(p, q, t) { + if(t < 0) t += 1; + if(t > 1) t -= 1; + if(t < 1/6) return p + (q - p) * 6 * t; + if(t < 1/2) return q; + if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + } + + function hsl2rgb(h, s, l){ + var r, g, b; + + if(s == 0){ + r = g = b = l; // achromatic + }else{ + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), 255]; + } + +// The random number is a js implementation of the Xorshift PRNG + var randseed = new Array(4); // Xorshift: [x, y, z, w] 32 bit values + + function seedrand(seed) { + for (var i = 0; i < randseed.length; i++) { + randseed[i] = 0; + } + for (var i = 0; i < seed.length; i++) { + randseed[i % 4] = (randseed[i % 4] << 5) - randseed[i % 4] + seed.charCodeAt(i); + } + } + + function rand() { + // based on Java's String.hashCode(), expanded to 4 32bit values + var t = randseed[0] ^ (randseed[0] << 11); + + randseed[0] = randseed[1]; + randseed[1] = randseed[2]; + randseed[2] = randseed[3]; + randseed[3] = randseed[3] ^ (randseed[3] >> 19) ^ t ^ (t >> 8); + + return (randseed[3] >>> 0) / (1 << 31 >>> 0); + } + + function createColor() { + //saturation is the whole color spectrum + var h = Math.floor(rand() * 360); + //saturation goes from 40 to 100, it avoids greyish colors + var s = rand() * 60 + 40; + //lightness can be anything from 0 to 100, but probabilities are a bell curve around 50% + var l = (rand() + rand() + rand() + rand()) * 25; + + return [h / 360,s / 100,l / 100]; + } + + function createImageData(size) { + var width = size; // Only support square icons for now + var height = size; + + var dataWidth = Math.ceil(width / 2); + var mirrorWidth = width - dataWidth; + + var data = []; + for (var y = 0; y < height; y++) { + var row = []; + for (var x = 0; x < dataWidth; x++) { + // this makes foreground and background color to have a 43% (1/2.3) probability + // spot color has 13% chance + row[x] = Math.floor(rand() * 2.3); + } + var r = row.slice(0, mirrorWidth); + r.reverse(); + row = row.concat(r); + + for (var i = 0; i < row.length; i++) { + data.push(row[i]); + } + } + + return data; + } + + function buildOpts(opts) { + if (!opts.seed) { + throw 'No seed provided' + } + + seedrand(opts.seed); + + return Object.assign({ + size: 8, + scale: 16, + color: createColor(), + bgcolor: createColor(), + spotcolor: createColor(), + }, opts) + } + + function toDataUrl(address) { + const opts = buildOpts({seed: address.toLowerCase()}); + + const imageData = createImageData(opts.size); + const width = Math.sqrt(imageData.length); + + const p = new PNG(opts.size*opts.scale, opts.size*opts.scale, 3); + const bgcolor = p.color(...hsl2rgb(...opts.bgcolor)); + const color = p.color(...hsl2rgb(...opts.color)); + const spotcolor = p.color(...hsl2rgb(...opts.spotcolor)); + + for (var i = 0; i < imageData.length; i++) { + var row = Math.floor(i / width); + var col = i % width; + // if data is 0, leave the background + if (imageData[i]) { + // if data is 2, choose spot color, if 1 choose foreground + const pngColor = imageData[i] == 1 ? color : spotcolor; + p.fillRect(col * opts.scale, row * opts.scale, opts.scale, opts.scale, pngColor); + } + } + return `data:image/png;base64,${p.getBase64()}`; + } + + exports.toDataUrl = toDataUrl; + + Object.defineProperty(exports, '__esModule', { value: true }); + +}))); diff --git a/ui/lib/feature-toggle-utils.js b/ui/lib/feature-toggle-utils.js new file mode 100644 index 000000000..6d4e461ca --- /dev/null +++ b/ui/lib/feature-toggle-utils.js @@ -0,0 +1,11 @@ +function checkFeatureToggle (name) { + const queryPairMap = window.location.search.substr(1).split('&') + .map(pair => pair.split('=')) + .reduce((pairs, [key, value]) => ({...pairs, [key]: value }), {}) + const featureToggles = queryPairMap['ft'] ? queryPairMap['ft'].split(',') : [] + return Boolean(featureToggles.find(ft => ft === name)) +} + +module.exports = { + checkFeatureToggle, +} diff --git a/ui/lib/icon-factory.js b/ui/lib/icon-factory.js index 27a74de66..31498a3a9 100644 --- a/ui/lib/icon-factory.js +++ b/ui/lib/icon-factory.js @@ -53,7 +53,7 @@ function imageElFor (address) { const path = `images/contract/${fileName}` const img = document.createElement('img') img.src = path - img.style.width = '75%' + img.style.width = '100%' return img } diff --git a/ui/lib/is-mobile-view.js b/ui/lib/is-mobile-view.js new file mode 100644 index 000000000..78fd6cb54 --- /dev/null +++ b/ui/lib/is-mobile-view.js @@ -0,0 +1,5 @@ +// Checks if viewport at invoke time fits mobile dimensions +// isMobileView :: () => Bool +const isMobileView = () => window.matchMedia('screen and (max-width: 575px)').matches + +module.exports = isMobileView |