diff options
Diffstat (limited to 'ui/app')
754 files changed, 19755 insertions, 5713 deletions
diff --git a/ui/app/accounts/new-account/index.js b/ui/app/accounts/new-account/index.js deleted file mode 100644 index 795bd7ce6..000000000 --- a/ui/app/accounts/new-account/index.js +++ /dev/null @@ -1,87 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -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()), - setAccountLabel: (address, label) => dispatch(actions.setAccountLabel(address, label)), - } -} - -inherits(AccountDetailsModal, Component) -function AccountDetailsModal (props) { - Component.call(this) - - this.state = { - displayedForm: props.displayedForm, - } -} - -AccountDetailsModal.contextTypes = { - t: PropTypes.func, -} - -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', this.context.t('newAccount')), - - 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'), - }, this.context.t('createDen')), - - 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'), - }, this.context.t('import')), - - ]), - - ]), - - h('div.new-account__form', [ - - displayedForm === 'CREATE' - ? h(NewAccountCreateForm) - : h(NewAccountImportForm), - - ]), - - ]) -} diff --git a/ui/app/app.js b/ui/app/app.js deleted file mode 100644 index b3aff1f39..000000000 --- a/ui/app/app.js +++ /dev/null @@ -1,359 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const { Route, Switch, withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const h = require('react-hyperscript') -const actions = require('./actions') -const classnames = require('classnames') -const log = require('loglevel') - -// init -const InitializeScreen = require('../../mascara/src/app/first-time').default -// accounts -const SendTransactionScreen = require('./components/send/send.container') -const ConfirmTransaction = require('./components/pages/confirm-transaction') - -// slideout menu -const Sidebar = require('./components/sidebars').default - -// other views -import Home from './components/pages/home' -import Settings from './components/pages/settings' -const Authenticated = require('./components/pages/authenticated') -const Initialized = require('./components/pages/initialized') -const RestoreVaultPage = require('./components/pages/keychains/restore-vault').default -const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed') -const AddTokenPage = require('./components/pages/add-token') -const ConfirmAddTokenPage = require('./components/pages/confirm-add-token') -const ConfirmAddSuggestedTokenPage = require('./components/pages/confirm-add-suggested-token') -const CreateAccountPage = require('./components/pages/create-account') -const NoticeScreen = require('./components/pages/notice') - -const Loading = require('./components/loading-screen') -const NetworkDropdown = require('./components/dropdowns/network-dropdown') -const AccountMenu = require('./components/account-menu') - -// Global Modals -const Modal = require('./components/modals/index').Modal -// Global Alert -const Alert = require('./components/alert') - -import AppHeader from './components/app-header' -import UnlockPage from './components/pages/unlock-page' - -// Routes -const { - DEFAULT_ROUTE, - UNLOCK_ROUTE, - SETTINGS_ROUTE, - REVEAL_SEED_ROUTE, - RESTORE_VAULT_ROUTE, - ADD_TOKEN_ROUTE, - CONFIRM_ADD_TOKEN_ROUTE, - CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, - NEW_ACCOUNT_ROUTE, - SEND_ROUTE, - CONFIRM_TRANSACTION_ROUTE, - INITIALIZE_ROUTE, - NOTICE_ROUTE, -} = require('./routes') - -class App extends Component { - componentWillMount () { - const { currentCurrency, setCurrentCurrencyToUSD } = this.props - - if (!currentCurrency) { - setCurrentCurrencyToUSD() - } - } - - renderRoutes () { - const exact = true - - return ( - h(Switch, [ - h(Route, { path: INITIALIZE_ROUTE, component: InitializeScreen }), - h(Initialized, { path: UNLOCK_ROUTE, exact, component: UnlockPage }), - h(Initialized, { path: RESTORE_VAULT_ROUTE, exact, component: RestoreVaultPage }), - h(Authenticated, { path: REVEAL_SEED_ROUTE, exact, component: RevealSeedConfirmation }), - h(Authenticated, { path: SETTINGS_ROUTE, component: Settings }), - h(Authenticated, { path: NOTICE_ROUTE, exact, component: NoticeScreen }), - h(Authenticated, { - path: `${CONFIRM_TRANSACTION_ROUTE}/:id?`, - component: ConfirmTransaction, - }), - h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }), - h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }), - h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }), - h(Authenticated, { path: CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, exact, component: ConfirmAddSuggestedTokenPage }), - h(Authenticated, { path: NEW_ACCOUNT_ROUTE, component: CreateAccountPage }), - h(Authenticated, { path: DEFAULT_ROUTE, exact, component: Home }), - ]) - ) - } - - render () { - const { - isLoading, - alertMessage, - loadingMessage, - network, - isMouseUser, - provider, - frequentRpcListDetail, - currentView, - setMouseUserState, - sidebar, - } = this.props - const isLoadingNetwork = network === 'loading' && currentView.name !== 'config' - const loadMessage = loadingMessage || isLoadingNetwork ? - this.getConnectingLabel(loadingMessage) : null - log.debug('Main ui render function') - - return ( - h('.flex-column.full-height', { - className: classnames({ 'mouse-user-styles': isMouseUser }), - style: { - overflowX: 'hidden', - position: 'relative', - alignItems: 'center', - }, - tabIndex: '0', - onClick: () => setMouseUserState(true), - onKeyDown: (e) => { - if (e.keyCode === 9) { - setMouseUserState(false) - } - }, - }, [ - - // global modal - h(Modal, {}, []), - - // global alert - h(Alert, {visible: this.props.alertOpen, msg: alertMessage}), - - h(AppHeader), - - // sidebar - h(Sidebar, { - sidebarOpen: sidebar.isOpen, - hideSidebar: this.props.hideSidebar, - transitionName: sidebar.transitionName, - type: sidebar.type, - }), - - // network dropdown - h(NetworkDropdown, { - provider, - frequentRpcListDetail, - }, []), - - h(AccountMenu), - - h('div.main-container-wrapper', [ - (isLoading || isLoadingNetwork) && h(Loading, { - loadingMessage: loadMessage, - }), - - // content - this.renderRoutes(), - ]), - ]) - ) - } - - toggleMetamaskActive () { - if (!this.props.isUnlocked) { - // currently inactive: redirect to password box - var passwordBox = document.querySelector('input[type=password]') - if (!passwordBox) return - passwordBox.focus() - } else { - // currently active: deactivate - this.props.dispatch(actions.lockMetamask(false)) - } - } - - getConnectingLabel = function (loadingMessage) { - if (loadingMessage) { - return loadingMessage - } - const { provider } = this.props - const providerName = provider.type - - let name - - if (providerName === 'mainnet') { - name = this.context.t('connectingToMainnet') - } else if (providerName === 'ropsten') { - name = this.context.t('connectingToRopsten') - } else if (providerName === 'kovan') { - name = this.context.t('connectingToKovan') - } else if (providerName === 'rinkeby') { - name = this.context.t('connectingToRinkeby') - } else { - name = this.context.t('connectingToUnknown') - } - - return name - } - - getNetworkName () { - const { provider } = this.props - const providerName = provider.type - - let name - - if (providerName === 'mainnet') { - name = this.context.t('mainnet') - } else if (providerName === 'ropsten') { - name = this.context.t('ropsten') - } else if (providerName === 'kovan') { - name = this.context.t('kovan') - } else if (providerName === 'rinkeby') { - name = this.context.t('rinkeby') - } else { - name = this.context.t('unknownNetwork') - } - - return name - } -} - -App.propTypes = { - currentCurrency: PropTypes.string, - setCurrentCurrencyToUSD: PropTypes.func, - isLoading: PropTypes.bool, - loadingMessage: PropTypes.string, - alertMessage: PropTypes.string, - network: PropTypes.string, - provider: PropTypes.object, - frequentRpcListDetail: PropTypes.array, - currentView: PropTypes.object, - sidebar: PropTypes.object, - alertOpen: PropTypes.bool, - hideSidebar: PropTypes.func, - isMascara: PropTypes.bool, - isOnboarding: PropTypes.bool, - isUnlocked: PropTypes.bool, - networkDropdownOpen: PropTypes.bool, - showNetworkDropdown: PropTypes.func, - hideNetworkDropdown: PropTypes.func, - history: PropTypes.object, - location: PropTypes.object, - dispatch: PropTypes.func, - toggleAccountMenu: PropTypes.func, - selectedAddress: PropTypes.string, - noActiveNotices: PropTypes.bool, - lostAccounts: PropTypes.array, - isInitialized: PropTypes.bool, - forgottenPassword: PropTypes.bool, - activeAddress: PropTypes.string, - unapprovedTxs: PropTypes.object, - seedWords: PropTypes.string, - unapprovedMsgCount: PropTypes.number, - unapprovedPersonalMsgCount: PropTypes.number, - unapprovedTypedMessagesCount: PropTypes.number, - welcomeScreenSeen: PropTypes.bool, - isPopup: PropTypes.bool, - betaUI: PropTypes.bool, - isMouseUser: PropTypes.bool, - setMouseUserState: PropTypes.func, - t: PropTypes.func, -} - -function mapStateToProps (state) { - const { appState, metamask } = state - const { - networkDropdownOpen, - sidebar, - alertOpen, - alertMessage, - isLoading, - loadingMessage, - } = appState - - const { - identities, - accounts, - address, - keyrings, - isInitialized, - noActiveNotices, - seedWords, - unapprovedTxs, - nextUnreadNotice, - lostAccounts, - unapprovedMsgCount, - unapprovedPersonalMsgCount, - unapprovedTypedMessagesCount, - } = metamask - const selected = address || Object.keys(accounts)[0] - - return { - // state from plugin - networkDropdownOpen, - sidebar, - alertOpen, - alertMessage, - isLoading, - loadingMessage, - noActiveNotices, - isInitialized, - isUnlocked: state.metamask.isUnlocked, - selectedAddress: state.metamask.selectedAddress, - currentView: state.appState.currentView, - activeAddress: state.appState.activeAddress, - transForward: state.appState.transForward, - isMascara: state.metamask.isMascara, - isOnboarding: Boolean(!noActiveNotices || seedWords || !isInitialized), - isPopup: state.metamask.isPopup, - seedWords: state.metamask.seedWords, - unapprovedTxs, - unapprovedMsgs: state.metamask.unapprovedMsgs, - unapprovedMsgCount, - unapprovedPersonalMsgCount, - unapprovedTypedMessagesCount, - menuOpen: state.appState.menuOpen, - network: state.metamask.network, - provider: state.metamask.provider, - forgottenPassword: state.appState.forgottenPassword, - nextUnreadNotice, - lostAccounts, - frequentRpcListDetail: state.metamask.frequentRpcListDetail || [], - currentCurrency: state.metamask.currentCurrency, - isMouseUser: state.appState.isMouseUser, - betaUI: state.metamask.featureFlags.betaUI, - isRevealingSeedWords: state.metamask.isRevealingSeedWords, - Qr: state.appState.Qr, - welcomeScreenSeen: state.metamask.welcomeScreenSeen, - - // state needed to get account dropdown temporarily rendering from app bar - identities, - selected, - keyrings, - } -} - -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()), - setMouseUserState: (isMouseUser) => dispatch(actions.setMouseUserState(isMouseUser)), - } -} - -App.contextTypes = { - t: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(App) diff --git a/ui/app/components/account-export.js b/ui/app/components/account-export.js deleted file mode 100644 index 865207487..000000000 --- a/ui/app/components/account-export.js +++ /dev/null @@ -1,138 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const inherits = require('util').inherits -const exportAsFile = require('../util').exportAsFile -const copyToClipboard = require('copy-to-clipboard') -const actions = require('../actions') -const ethUtil = require('ethereumjs-util') -const connect = require('react-redux').connect - -ExportAccountView.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(ExportAccountView) - - -inherits(ExportAccountView, Component) -function ExportAccountView () { - Component.call(this) -} - -function mapStateToProps (state) { - return { - warning: state.appState.warning, - } -} - -ExportAccountView.prototype.render = function () { - const state = this.props - const accountDetail = state.accountDetail - const nickname = state.identities[state.address].name - - if (!accountDetail) return h('div') - const accountExport = accountDetail.accountExport - - const notExporting = accountExport === 'none' - const exportRequested = accountExport === 'requested' - const accountExported = accountExport === 'completed' - - if (notExporting) return h('div') - - if (exportRequested) { - const warning = this.context.t('exportPrivateKeyWarning') - return ( - h('div', { - style: { - display: 'inline-block', - textAlign: 'center', - }, - }, - [ - h('div', { - key: 'exporting', - style: { - margin: '0 20px', - }, - }, [ - h('p.error', warning), - h('input#exportAccount.sizing-input', { - type: 'password', - placeholder: this.context.t('confirmPassword').toLowerCase(), - onKeyPress: this.onExportKeyPress.bind(this), - style: { - position: 'relative', - top: '1.5px', - marginBottom: '7px', - }, - }), - ]), - h('div', { - key: 'buttons', - style: { - margin: '0 20px', - }, - }, - [ - h('button', { - onClick: () => this.onExportKeyPress({ key: 'Enter', preventDefault: () => {} }), - style: { - marginRight: '10px', - }, - }, this.context.t('submit')), - h('button', { - onClick: () => this.props.dispatch(actions.backToAccountDetail(this.props.address)), - }, this.context.t('cancel')), - ]), - (this.props.warning) && ( - h('span.error', { - style: { - margin: '20px', - }, - }, this.props.warning.split('-')) - ), - ]) - ) - } - - if (accountExported) { - const plainKey = ethUtil.stripHexPrefix(accountDetail.privateKey) - - return h('div.privateKey', { - style: { - margin: '0 20px', - }, - }, [ - h('label', this.context.t('copyPrivateKey') + ':'), - h('p.error.cursor-pointer', { - style: { - textOverflow: 'ellipsis', - overflow: 'hidden', - webkitUserSelect: 'text', - maxWidth: '275px', - }, - onClick: function (event) { - copyToClipboard(ethUtil.stripHexPrefix(accountDetail.privateKey)) - }, - }, plainKey), - h('button', { - onClick: () => this.props.dispatch(actions.backToAccountDetail(this.props.address)), - }, this.context.t('done')), - h('button', { - style: { - marginLeft: '10px', - }, - onClick: () => exportAsFile(`MetaMask ${nickname} Private Key`, plainKey), - }, this.context.t('saveAsFile')), - ]) - } -} - -ExportAccountView.prototype.onExportKeyPress = function (event) { - if (event.key !== 'Enter') return - event.preventDefault() - - const input = document.getElementById('exportAccount').value - this.props.dispatch(actions.exportAccount(input, this.props.address)) -} diff --git a/ui/app/components/account-menu/index.js b/ui/app/components/account-menu/index.js deleted file mode 100644 index 94eae8d07..000000000 --- a/ui/app/components/account-menu/index.js +++ /dev/null @@ -1,248 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const connect = require('react-redux').connect -const { compose } = require('recompose') -const { withRouter } = require('react-router-dom') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const actions = require('../../actions') -const { Menu, Item, Divider, CloseArea } = require('../dropdowns/components/menu') -const { ENVIRONMENT_TYPE_POPUP } = require('../../../../app/scripts/lib/enums') -const { getEnvironmentType } = require('../../../../app/scripts/lib/util') -const Tooltip = require('../tooltip') -import Identicon from '../identicon' -import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' -import { PRIMARY } from '../../constants/common' - -const { - SETTINGS_ROUTE, - INFO_ROUTE, - NEW_ACCOUNT_ROUTE, - IMPORT_ACCOUNT_ROUTE, - CONNECT_HARDWARE_ROUTE, - DEFAULT_ROUTE, -} = require('../../routes') - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(AccountMenu) - -AccountMenu.contextTypes = { - t: PropTypes.func, -} - -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()) - }, - showInfoPage: () => { - dispatch(actions.showInfoPage()) - dispatch(actions.hideSidebar()) - dispatch(actions.toggleAccountMenu()) - }, - showRemoveAccountConfirmationModal: (identity) => { - return dispatch(actions.showModal({ name: 'CONFIRM_REMOVE_ACCOUNT', identity })) - }, - } -} - -AccountMenu.prototype.render = function () { - const { - isAccountMenuOpen, - toggleAccountMenu, - lockMetamask, - history, - } = this.props - - return h(Menu, { className: 'account-menu', isShowing: isAccountMenuOpen }, [ - h(CloseArea, { onClick: toggleAccountMenu }), - h(Item, { - className: 'account-menu__header', - }, [ - this.context.t('myAccounts'), - h('button.account-menu__logout-button', { - onClick: () => { - lockMetamask() - history.push(DEFAULT_ROUTE) - }, - }, this.context.t('logout')), - ]), - h(Divider), - h('div.account-menu__accounts', this.renderAccounts()), - h(Divider), - h(Item, { - onClick: () => { - toggleAccountMenu() - history.push(NEW_ACCOUNT_ROUTE) - }, - icon: h('img.account-menu__item-icon', { src: 'images/plus-btn-white.svg' }), - text: this.context.t('createAccount'), - }), - h(Item, { - onClick: () => { - toggleAccountMenu() - history.push(IMPORT_ACCOUNT_ROUTE) - }, - icon: h('img.account-menu__item-icon', { src: 'images/import-account.svg' }), - text: this.context.t('importAccount'), - }), - h(Item, { - onClick: () => { - toggleAccountMenu() - if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { - global.platform.openExtensionInBrowser(CONNECT_HARDWARE_ROUTE) - } else { - history.push(CONNECT_HARDWARE_ROUTE) - } - }, - icon: h('img.account-menu__item-icon', { src: 'images/connect-icon.svg' }), - text: this.context.t('connectHardwareWallet'), - }), - h(Divider), - h(Item, { - onClick: () => { - toggleAccountMenu() - history.push(INFO_ROUTE) - }, - icon: h('img', { src: 'images/mm-info-icon.svg' }), - text: this.context.t('infoHelp'), - }), - h(Item, { - onClick: () => { - toggleAccountMenu() - history.push(SETTINGS_ROUTE) - }, - icon: h('img.account-menu__item-icon', { src: 'images/settings.svg' }), - text: this.context.t('settings'), - }), - ]) -} - -AccountMenu.prototype.renderAccounts = function () { - const { - identities, - accounts, - selectedAddress, - keyrings, - showAccountDetail, - } = this.props - - const accountOrder = keyrings.reduce((list, keyring) => list.concat(keyring.accounts), []) - return accountOrder.filter(address => !!identities[address]).map((address) => { - - const identity = identities[address] - const isSelected = identity.address === selectedAddress - - const balanceValue = accounts[address] ? accounts[address].balance : '' - 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(UserPreferencedCurrencyDisplay, { - className: 'account-menu__balance', - value: balanceValue, - type: PRIMARY, - }), - ]), - - this.renderKeyringType(keyring), - this.renderRemoveAccount(keyring, identity), - ], - ) - }) -} - -AccountMenu.prototype.renderRemoveAccount = function (keyring, identity) { - // Any account that's not from the HD wallet Keyring can be removed - const type = keyring.type - const isRemovable = type !== 'HD Key Tree' - if (isRemovable) { - return h(Tooltip, { - title: this.context.t('removeAccount'), - position: 'bottom', - }, [ - h('a.remove-account-icon', { - onClick: (e) => this.removeAccount(e, identity), - }, ''), - ]) - } - return null -} - -AccountMenu.prototype.removeAccount = function (e, identity) { - e.preventDefault() - e.stopPropagation() - const { showRemoveAccountConfirmationModal } = this.props - showRemoveAccountConfirmationModal(identity) -} - -AccountMenu.prototype.renderKeyringType = function (keyring) { - try { // Sometimes keyrings aren't loaded yet: - const type = keyring.type - let label - switch (type) { - case 'Trezor Hardware': - case 'Ledger Hardware': - label = this.context.t('hardware') - break - case 'Simple Key Pair': - label = this.context.t('imported') - break - default: - label = '' - } - - return label !== '' ? h('.keyring-label.allcaps', label) : null - - } catch (e) { return } -} diff --git a/ui/app/components/account-dropdowns.js b/ui/app/components/app/account-dropdowns.js index 06376e48b..e02d17e54 100644 --- a/ui/app/components/account-dropdowns.js +++ b/ui/app/components/app/account-dropdowns.js @@ -1,15 +1,15 @@ const Component = require('react').Component const PropTypes = require('prop-types') const h = require('react-hyperscript') -const actions = require('../actions') +const actions = require('../../store/actions') const genAccountLink = require('etherscan-link').createAccountLink const connect = require('react-redux').connect const Dropdown = require('./dropdown').Dropdown const DropdownMenuItem = require('./dropdown').DropdownMenuItem const copyToClipboard = require('copy-to-clipboard') -const { checksumAddress } = require('../util') +const { checksumAddress } = require('../../helpers/utils/util') -import Identicon from './identicon' +import Identicon from '../ui/identicon' class AccountDropdowns extends Component { constructor (props) { @@ -233,6 +233,7 @@ class AccountDropdowns extends Component { } render () { + const { metricsEvent } = this.context const { style, enableAccountsSelector, enableAccountOptions } = this.props const { optionsMenuActive, accountSelectorActive } = this.state @@ -272,6 +273,17 @@ class AccountDropdowns extends Component { fontSize: '1.8em', }, onClick: (event) => { + metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'userClick', + name: 'accountsOpenedMenu', + }, + pageOpts: { + section: 'header', + component: 'accountDropdownIcon', + }, + }) event.stopPropagation() this.setState({ accountSelectorActive: false, @@ -318,6 +330,7 @@ const mapDispatchToProps = (dispatch) => { AccountDropdowns.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = { diff --git a/ui/app/components/app/account-menu/account-menu.component.js b/ui/app/components/app/account-menu/account-menu.component.js new file mode 100644 index 000000000..972ea492e --- /dev/null +++ b/ui/app/components/app/account-menu/account-menu.component.js @@ -0,0 +1,340 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import debounce from 'lodash.debounce' +import { Menu, Item, Divider, CloseArea } from '../dropdowns/components/menu' +import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../../app/scripts/lib/util' +import Tooltip from '../../ui/tooltip' +import Identicon from '../../ui/identicon' +import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' +import { PRIMARY } from '../../../helpers/constants/common' +import { + SETTINGS_ROUTE, + INFO_ROUTE, + NEW_ACCOUNT_ROUTE, + IMPORT_ACCOUNT_ROUTE, + CONNECT_HARDWARE_ROUTE, + DEFAULT_ROUTE, +} from '../../../helpers/constants/routes' + +export default class AccountMenu extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + accounts: PropTypes.object, + history: PropTypes.object, + identities: PropTypes.object, + isAccountMenuOpen: PropTypes.bool, + prevIsAccountMenuOpen: PropTypes.bool, + keyrings: PropTypes.array, + lockMetamask: PropTypes.func, + selectedAddress: PropTypes.string, + showAccountDetail: PropTypes.func, + showRemoveAccountConfirmationModal: PropTypes.func, + toggleAccountMenu: PropTypes.func, + } + + state = { + atAccountListBottom: false, + } + + componentDidUpdate (prevProps) { + const { prevIsAccountMenuOpen } = prevProps + const { isAccountMenuOpen } = this.props + + if (!prevIsAccountMenuOpen && isAccountMenuOpen) { + this.setAtAccountListBottom() + } + } + + renderAccounts () { + const { + identities, + accounts, + selectedAddress, + keyrings, + showAccountDetail, + } = this.props + + const accountOrder = keyrings.reduce((list, keyring) => list.concat(keyring.accounts), []) + + return accountOrder.filter(address => !!identities[address]).map(address => { + const identity = identities[address] + const isSelected = identity.address === selectedAddress + + const balanceValue = accounts[address] ? accounts[address].balance : '' + const simpleAddress = identity.address.substring(2).toLowerCase() + + const keyring = keyrings.find(kr => { + return kr.accounts.includes(simpleAddress) || kr.accounts.includes(identity.address) + }) + + return ( + <div + className="account-menu__account menu__item--clickable" + onClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Switched Account', + }, + }) + showAccountDetail(identity.address) + }} + key={identity.address} + > + <div className="account-menu__check-mark"> + { isSelected && <div className="account-menu__check-mark-icon" /> } + </div> + <Identicon + address={identity.address} + diameter={24} + /> + <div className="account-menu__account-info"> + <div className="account-menu__name"> + { identity.name || '' } + </div> + <UserPreferencedCurrencyDisplay + className="account-menu__balance" + value={balanceValue} + type={PRIMARY} + /> + </div> + { this.renderKeyringType(keyring) } + { this.renderRemoveAccount(keyring, identity) } + </div> + ) + }) + } + + renderRemoveAccount (keyring, identity) { + const { t } = this.context + // Any account that's not from the HD wallet Keyring can be removed + const { type } = keyring + const isRemovable = type !== 'HD Key Tree' + + return isRemovable && ( + <Tooltip + title={t('removeAccount')} + position="bottom" + > + <a + className="remove-account-icon" + onClick={e => this.removeAccount(e, identity)} + /> + </Tooltip> + ) + } + + removeAccount (e, identity) { + e.preventDefault() + e.stopPropagation() + const { showRemoveAccountConfirmationModal } = this.props + showRemoveAccountConfirmationModal(identity) + } + + renderKeyringType (keyring) { + const { t } = this.context + + // Sometimes keyrings aren't loaded yet + if (!keyring) { + return null + } + + const { type } = keyring + let label + + switch (type) { + case 'Trezor Hardware': + case 'Ledger Hardware': + label = t('hardware') + break + case 'Simple Key Pair': + label = t('imported') + break + } + + return label && ( + <div className="keyring-label allcaps"> + { label } + </div> + ) + } + + setAtAccountListBottom = () => { + const target = document.querySelector('.account-menu__accounts') + const { scrollTop, offsetHeight, scrollHeight } = target + const atAccountListBottom = scrollTop + offsetHeight >= scrollHeight + this.setState({ atAccountListBottom }) + } + + onScroll = debounce(this.setAtAccountListBottom, 25) + + handleScrollDown = e => { + e.stopPropagation() + const target = document.querySelector('.account-menu__accounts') + const { scrollHeight } = target + target.scroll({ left: 0, top: scrollHeight, behavior: 'smooth' }) + this.setAtAccountListBottom() + } + + renderScrollButton () { + const { accounts } = this.props + const { atAccountListBottom } = this.state + + return !atAccountListBottom && Object.keys(accounts).length > 3 && ( + <div + className="account-menu__scroll-button" + onClick={this.handleScrollDown} + > + <img + src="./images/icons/down-arrow.svg" + width={28} + height={28} + /> + </div> + ) + } + + render () { + const { t } = this.context + const { + isAccountMenuOpen, + toggleAccountMenu, + lockMetamask, + history, + } = this.props + const { metricsEvent } = this.context + + return ( + <Menu + className="account-menu" + isShowing={isAccountMenuOpen} + > + <CloseArea onClick={toggleAccountMenu} /> + <Item className="account-menu__header"> + { t('myAccounts') } + <button + className="account-menu__logout-button" + onClick={() => { + lockMetamask() + history.push(DEFAULT_ROUTE) + }} + > + { t('logout') } + </button> + </Item> + <Divider /> + <div className="account-menu__accounts-container"> + <div + className="account-menu__accounts" + onScroll={this.onScroll} + > + { this.renderAccounts() } + </div> + { this.renderScrollButton() } + </div> + <Divider /> + <Item + onClick={() => { + toggleAccountMenu() + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Clicked Create Account', + }, + }) + history.push(NEW_ACCOUNT_ROUTE) + }} + icon={ + <img + className="account-menu__item-icon" + src="images/plus-btn-white.svg" + /> + } + text={t('createAccount')} + /> + <Item + onClick={() => { + toggleAccountMenu() + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Clicked Import Account', + }, + }) + history.push(IMPORT_ACCOUNT_ROUTE) + }} + icon={ + <img + className="account-menu__item-icon" + src="images/import-account.svg" + /> + } + text={t('importAccount')} + /> + <Item + onClick={() => { + toggleAccountMenu() + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Clicked Connect Hardware', + }, + }) + if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { + global.platform.openExtensionInBrowser(CONNECT_HARDWARE_ROUTE) + } else { + history.push(CONNECT_HARDWARE_ROUTE) + } + }} + icon={ + <img + className="account-menu__item-icon" + src="images/connect-icon.svg" + /> + } + text={t('connectHardwareWallet')} + /> + <Divider /> + <Item + onClick={() => { + toggleAccountMenu() + history.push(INFO_ROUTE) + }} + icon={ + <img src="images/mm-info-icon.svg" /> + } + text={t('infoHelp')} + /> + <Item + onClick={() => { + toggleAccountMenu() + history.push(SETTINGS_ROUTE) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Opened Settings', + }, + }) + }} + icon={ + <img + className="account-menu__item-icon" + src="images/settings.svg" + /> + } + text={t('settings')} + /> + </Menu> + ) + } +} diff --git a/ui/app/components/app/account-menu/account-menu.container.js b/ui/app/components/app/account-menu/account-menu.container.js new file mode 100644 index 000000000..ae2e28e76 --- /dev/null +++ b/ui/app/components/app/account-menu/account-menu.container.js @@ -0,0 +1,62 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import { + toggleAccountMenu, + showAccountDetail, + hideSidebar, + lockMetamask, + hideWarning, + showConfigPage, + showInfoPage, + showModal, +} from '../../../store/actions' +import { getMetaMaskAccounts } from '../../../selectors/selectors' +import AccountMenu from './account-menu.component' + +function mapStateToProps (state) { + const { metamask: { selectedAddress, isAccountMenuOpen, keyrings, identities } } = state + + return { + selectedAddress, + isAccountMenuOpen, + keyrings, + identities, + accounts: getMetaMaskAccounts(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + toggleAccountMenu: () => dispatch(toggleAccountMenu()), + showAccountDetail: address => { + dispatch(showAccountDetail(address)) + dispatch(hideSidebar()) + dispatch(toggleAccountMenu()) + }, + lockMetamask: () => { + dispatch(lockMetamask()) + dispatch(hideWarning()) + dispatch(hideSidebar()) + dispatch(toggleAccountMenu()) + }, + showConfigPage: () => { + dispatch(showConfigPage()) + dispatch(hideSidebar()) + dispatch(toggleAccountMenu()) + }, + showInfoPage: () => { + dispatch(showInfoPage()) + dispatch(hideSidebar()) + dispatch(toggleAccountMenu()) + }, + showRemoveAccountConfirmationModal: identity => { + return dispatch(showModal({ name: 'CONFIRM_REMOVE_ACCOUNT', identity })) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(AccountMenu) diff --git a/ui/app/components/app/account-menu/index.js b/ui/app/components/app/account-menu/index.js new file mode 100644 index 000000000..b2b4e4c6f --- /dev/null +++ b/ui/app/components/app/account-menu/index.js @@ -0,0 +1 @@ +export { default } from './account-menu.container' diff --git a/ui/app/css/itcss/components/account-menu.scss b/ui/app/components/app/account-menu/index.scss index b14753e23..9a61bf887 100644 --- a/ui/app/css/itcss/components/account-menu.scss +++ b/ui/app/components/app/account-menu/index.scss @@ -55,7 +55,7 @@ display: flex; flex-flow: column nowrap; overflow-y: auto; - max-height: 240px; + max-height: 256px; position: relative; z-index: 200; @@ -64,7 +64,7 @@ } @media screen and (max-width: 575px) { - max-height: 215px; + max-height: 228px; } .keyring-label { @@ -150,4 +150,28 @@ line-height: 18px; cursor: pointer; } + + &__accounts-container { + position: relative; + } + + &__scroll-button { + position: absolute; + bottom: 12px; + right: 12px; + height: 28px; + width: 28px; + border-radius: 14px; + background: #3f3f3f; + z-index: 201; + cursor: pointer; + opacity: .8; + display: flex; + justify-content: center; + align-items: center; + + &:hover { + opacity: 1; + } + } } diff --git a/ui/app/components/account-panel.js b/ui/app/components/app/account-panel.js index a379ed3ac..79882f34a 100644 --- a/ui/app/components/account-panel.js +++ b/ui/app/components/app/account-panel.js @@ -1,9 +1,9 @@ const inherits = require('util').inherits const Component = require('react').Component const h = require('react-hyperscript') -import Identicon from './identicon' -const formatBalance = require('../util').formatBalance -const addressSummary = require('../util').addressSummary +import Identicon from '../ui/identicon' +const formatBalance = require('../../helpers/utils/util').formatBalance +const addressSummary = require('../../helpers/utils/util').addressSummary module.exports = AccountPanel diff --git a/ui/app/components/add-token-button/add-token-button.component.js b/ui/app/components/app/add-token-button/add-token-button.component.js index 10887aed8..10887aed8 100644 --- a/ui/app/components/add-token-button/add-token-button.component.js +++ b/ui/app/components/app/add-token-button/add-token-button.component.js diff --git a/ui/app/components/add-token-button/index.js b/ui/app/components/app/add-token-button/index.js index 15c4fe6ca..15c4fe6ca 100644 --- a/ui/app/components/add-token-button/index.js +++ b/ui/app/components/app/add-token-button/index.js diff --git a/ui/app/components/add-token-button/index.scss b/ui/app/components/app/add-token-button/index.scss index 39f404716..39f404716 100644 --- a/ui/app/components/add-token-button/index.scss +++ b/ui/app/components/app/add-token-button/index.scss diff --git a/ui/app/components/app-header/app-header.component.js b/ui/app/components/app/app-header/app-header.component.js index c82dc1de9..343e0daab 100644 --- a/ui/app/components/app-header/app-header.component.js +++ b/ui/app/components/app/app-header/app-header.component.js @@ -1,20 +1,13 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { matchPath } from 'react-router-dom' -import Identicon from '../identicon' - -const { - ENVIRONMENT_TYPE_NOTIFICATION, - ENVIRONMENT_TYPE_POPUP, -} = require('../../../../app/scripts/lib/enums') -const { DEFAULT_ROUTE, INITIALIZE_ROUTE, CONFIRM_TRANSACTION_ROUTE } = require('../../routes') +import Identicon from '../../ui/identicon' +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes' const NetworkIndicator = require('../network') export default class AppHeader extends PureComponent { static propTypes = { history: PropTypes.object, - location: PropTypes.object, network: PropTypes.string, provider: PropTypes.object, networkDropdownOpen: PropTypes.bool, @@ -23,10 +16,14 @@ export default class AppHeader extends PureComponent { toggleAccountMenu: PropTypes.func, selectedAddress: PropTypes.string, isUnlocked: PropTypes.bool, + hideNetworkIndicator: PropTypes.bool, + disabled: PropTypes.bool, + isAccountMenuOpen: PropTypes.bool, } static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } handleNetworkIndicatorClick (event) { @@ -35,28 +32,40 @@ export default class AppHeader extends PureComponent { const { networkDropdownOpen, showNetworkDropdown, hideNetworkDropdown } = this.props - return networkDropdownOpen === false - ? showNetworkDropdown() - : hideNetworkDropdown() - } - - isConfirming () { - const { location } = this.props - - return Boolean(matchPath(location.pathname, { - path: CONFIRM_TRANSACTION_ROUTE, exact: false, - })) + if (networkDropdownOpen === false) { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Network Menu', + }, + }) + showNetworkDropdown() + } else { + hideNetworkDropdown() + } } renderAccountMenu () { - const { isUnlocked, toggleAccountMenu, selectedAddress } = this.props + const { isUnlocked, toggleAccountMenu, selectedAddress, disabled, isAccountMenuOpen } = this.props return isUnlocked && ( <div className={classnames('account-menu__icon', { - 'account-menu__icon--disabled': this.isConfirming(), + 'account-menu__icon--disabled': disabled, })} - onClick={() => this.isConfirming() || toggleAccountMenu()} + onClick={() => { + if (!disabled) { + !isAccountMenuOpen && this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Main Menu', + }, + }) + toggleAccountMenu() + } + }} > <Identicon address={selectedAddress} @@ -66,38 +75,16 @@ export default class AppHeader extends PureComponent { ) } - hideAppHeader () { - const { location } = this.props - - const isInitializing = Boolean(matchPath(location.pathname, { - path: INITIALIZE_ROUTE, exact: false, - })) - - if (isInitializing) { - return true - } - - if (window.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { - return true - } - - if (window.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_POPUP && this.isConfirming()) { - return true - } - } - render () { const { + history, network, provider, - history, isUnlocked, + hideNetworkIndicator, + disabled, } = this.props - if (this.hideAppHeader()) { - return null - } - return ( <div className={classnames('app-header', { 'app-header--back-drop': isUnlocked })}> @@ -108,7 +95,7 @@ export default class AppHeader extends PureComponent { > <img className="app-header__metafox-logo app-header__metafox-logo--horizontal" - src="/images/logo/metamask-logo-horizontal-beta.svg" + src="/images/logo/metamask-logo-horizontal.svg" height={30} /> <img @@ -119,14 +106,18 @@ export default class AppHeader extends PureComponent { /> </div> <div className="app-header__account-menu-container"> - <div className="app-header__network-component-wrapper"> - <NetworkIndicator - network={network} - provider={provider} - onClick={event => this.handleNetworkIndicatorClick(event)} - disabled={this.isConfirming()} - /> - </div> + { + !hideNetworkIndicator && ( + <div className="app-header__network-component-wrapper"> + <NetworkIndicator + network={network} + provider={provider} + onClick={event => this.handleNetworkIndicatorClick(event)} + disabled={disabled} + /> + </div> + ) + } { this.renderAccountMenu() } </div> </div> diff --git a/ui/app/components/app-header/app-header.container.js b/ui/app/components/app/app-header/app-header.container.js index 30d3f8cc4..b67338245 100644 --- a/ui/app/components/app-header/app-header.container.js +++ b/ui/app/components/app/app-header/app-header.container.js @@ -3,7 +3,7 @@ import { withRouter } from 'react-router-dom' import { compose } from 'recompose' import AppHeader from './app-header.component' -const actions = require('../../actions') +const actions = require('../../../store/actions') const mapStateToProps = state => { const { appState, metamask } = state @@ -13,6 +13,7 @@ const mapStateToProps = state => { provider, selectedAddress, isUnlocked, + isAccountMenuOpen, } = metamask return { @@ -21,6 +22,7 @@ const mapStateToProps = state => { provider, selectedAddress, isUnlocked, + isAccountMenuOpen, } } diff --git a/ui/app/components/app-header/index.js b/ui/app/components/app/app-header/index.js index 6de2f9c78..6de2f9c78 100644 --- a/ui/app/components/app-header/index.js +++ b/ui/app/components/app/app-header/index.js diff --git a/ui/app/components/app-header/index.scss b/ui/app/components/app/app-header/index.scss index 325844af5..325844af5 100644 --- a/ui/app/components/app-header/index.scss +++ b/ui/app/components/app/app-header/index.scss diff --git a/ui/app/components/bn-as-decimal-input.js b/ui/app/components/app/bn-as-decimal-input.js index 9a033f893..9a033f893 100644 --- a/ui/app/components/bn-as-decimal-input.js +++ b/ui/app/components/app/bn-as-decimal-input.js diff --git a/ui/app/components/coinbase-form.js b/ui/app/components/app/coinbase-form.js index d5915292e..24d287604 100644 --- a/ui/app/components/coinbase-form.js +++ b/ui/app/components/app/coinbase-form.js @@ -3,7 +3,7 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../actions') +const actions = require('../../store/actions') CoinbaseForm.contextTypes = { t: PropTypes.func, diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js b/ui/app/components/app/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js index c7262d2a9..18571eccb 100644 --- a/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js @@ -2,7 +2,7 @@ import React from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../constants/common' +import { PRIMARY, SECONDARY } from '../../../../helpers/constants/common' const ConfirmDetailRow = props => { const { diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/index.js b/ui/app/components/app/confirm-page-container/confirm-detail-row/index.js index 056afff04..056afff04 100644 --- a/ui/app/components/confirm-page-container/confirm-detail-row/index.js +++ b/ui/app/components/app/confirm-page-container/confirm-detail-row/index.js diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/index.scss b/ui/app/components/app/confirm-page-container/confirm-detail-row/index.scss index 580a41fde..1672ef8c6 100644 --- a/ui/app/components/confirm-page-container/confirm-detail-row/index.scss +++ b/ui/app/components/app/confirm-page-container/confirm-detail-row/index.scss @@ -43,4 +43,8 @@ font-size: .625rem; } } + + .advanced-gas-inputs__gas-edit-rows { + margin-bottom: 16px; + } } diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/tests/confirm-detail-row.component.test.js b/ui/app/components/app/confirm-page-container/confirm-detail-row/tests/confirm-detail-row.component.test.js index c8507985d..c8507985d 100644 --- a/ui/app/components/confirm-page-container/confirm-detail-row/tests/confirm-detail-row.component.test.js +++ b/ui/app/components/app/confirm-page-container/confirm-detail-row/tests/confirm-detail-row.component.test.js diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js index 1dca81560..8a5f90c76 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -1,9 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { Tabs, Tab } from '../../tabs' -import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from './' -import ErrorMessage from '../../error-message' +import { Tabs, Tab } from '../../../ui/tabs' +import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.' +import ErrorMessage from '../../../ui/error-message' export default class ConfirmPageContainerContent extends Component { static propTypes = { diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js index 89ceb015f..0cc4d8262 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import Identicon from '../../../identicon' +import Identicon from '../../../../ui/identicon' const ConfirmPageContainerSummary = props => { const { diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js index ed1b28cf2..ed1b28cf2 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss index 7f0f5d37a..7f0f5d37a 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js index 79901c8fc..79901c8fc 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js index 6e48bd144..6e48bd144 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss index 50545a1a2..50545a1a2 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/index.js b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.js index 4dfd89d92..4dfd89d92 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/index.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.js diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss index 698e624f4..602a46848 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss @@ -1,6 +1,6 @@ -@import './confirm-page-container-warning/index'; +@import 'confirm-page-container-warning/index'; -@import './confirm-page-container-summary/index'; +@import 'confirm-page-container-summary/index'; .confirm-page-container-content { overflow-y: auto; @@ -52,6 +52,10 @@ &__gas-fee { border-bottom: 1px solid $geyser; + + .advanced-gas-inputs__gas-edit-rows { + margin-bottom: 16px; + } } &__function-type { diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js index e6fe8f82c..84ca40da5 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types' import { ENVIRONMENT_TYPE_POPUP, ENVIRONMENT_TYPE_NOTIFICATION, -} from '../../../../../app/scripts/lib/enums' +} from '../../../../../../app/scripts/lib/enums' import NetworkDisplay from '../../network-display' export default class ConfirmPageContainer extends Component { diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/index.js b/ui/app/components/app/confirm-page-container/confirm-page-container-header/index.js index 71feb6931..71feb6931 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-header/index.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-header/index.js diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-header/index.scss index 43e1e4427..be77edbdf 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-header/index.scss @@ -7,7 +7,7 @@ display: flex; justify-content: space-between; border-bottom: 1px solid $geyser; - padding: 13px 13px 13px 24px; + padding: 4px 13px 4px 13px; flex: 0 0 auto; } diff --git a/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/confirm-page-container-navigation.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/confirm-page-container-navigation.component.js new file mode 100755 index 000000000..8327f997b --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/confirm-page-container-navigation.component.js @@ -0,0 +1,69 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const ConfirmPageContainerNavigation = props => { + const { onNextTx, totalTx, positionOfCurrentTx, nextTxId, prevTxId, showNavigation, firstTx, lastTx, ofText, requestsWaitingText } = props + + return ( + <div className="confirm-page-container-navigation" + style={{ + display: showNavigation ? 'flex' : 'none', + }} + > + <div className="confirm-page-container-navigation__container" + style={{ + visibility: prevTxId ? 'initial' : 'hidden', + }}> + <div + className="confirm-page-container-navigation__arrow" + onClick={() => onNextTx(firstTx)}> + <img src="/images/double-arrow.svg" /> + </div> + <div + className="confirm-page-container-navigation__arrow" + onClick={() => onNextTx(prevTxId)}> + <img src="/images/single-arrow.svg" /> + </div> + </div> + <div className="confirm-page-container-navigation__textcontainer"> + <div className="confirm-page-container-navigation__navtext"> + {positionOfCurrentTx} {ofText} {totalTx} + </div> + <div className="confirm-page-container-navigation__longtext"> + {requestsWaitingText} + </div> + </div> + <div + className="confirm-page-container-navigation__container" + style={{ + visibility: nextTxId ? 'initial' : 'hidden', + }}> + <div + className="confirm-page-container-navigation__arrow" + onClick={() => onNextTx(nextTxId)}> + <img className="confirm-page-container-navigation__imageflip" src="/images/single-arrow.svg" /> + </div> + <div + className="confirm-page-container-navigation__arrow" + onClick={() => onNextTx(lastTx)}> + <img className="confirm-page-container-navigation__imageflip" src="/images/double-arrow.svg" /> + </div> + </div> + </div> + ) +} + +ConfirmPageContainerNavigation.propTypes = { + totalTx: PropTypes.number, + positionOfCurrentTx: PropTypes.number, + onNextTx: PropTypes.func, + nextTxId: PropTypes.string, + prevTxId: PropTypes.string, + showNavigation: PropTypes.bool, + firstTx: PropTypes.string, + lastTx: PropTypes.string, + ofText: PropTypes.string, + requestsWaitingText: PropTypes.string, +} + +export default ConfirmPageContainerNavigation diff --git a/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.js b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.js new file mode 100755 index 000000000..d97c1b447 --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-navigation.component' diff --git a/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.scss new file mode 100755 index 000000000..0cf184c60 --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.scss @@ -0,0 +1,54 @@ +.confirm-page-container-navigation { + display: flex; + justify-content: space-between; + font: inherit; + padding: 4px 10px 4px 10px; + border-bottom: 1px solid $geyser; + flex: 0 0 auto; + + &__container { + display: flex; + } + + &__arrow { + cursor: pointer; + display: flex; + padding-left: 5px; + padding-right: 5px; + } + + &__arrow:hover { + -webkit-transform: scale(1.1); + -moz-transform: scale(1.1); + -o-transform: scale(1.1); + transform: scale(1.1); + } + + &__arrow:active { + -webkit-transform: scale(0.95); + -moz-transform: scale(0.95); + -o-transform: scale(0.95); + transform: scale(0.95); + } + + &__textcontainer { + text-align: center; + } + + &__navtext { + font-size: 9px; + font-weight: bold; + } + + &__longtext { + color: $oslo-gray; + font-size: 8px; + } + + &__imageflip { + -webkit-transform: scaleX(-1); + -moz-transform: scaleX(-1); + -o-transform: scaleX(-1); + transform: scaleX(-1); + } +}
\ No newline at end of file diff --git a/ui/app/components/confirm-page-container/confirm-page-container.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js index 8b2e47cbb..326e4f83e 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js @@ -1,8 +1,8 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import SenderToRecipient from '../sender-to-recipient' -import { PageContainerFooter } from '../page-container' -import { ConfirmPageContainerHeader, ConfirmPageContainerContent } from './' +import SenderToRecipient from '../../ui/sender-to-recipient' +import { PageContainerFooter } from '../../ui/page-container' +import { ConfirmPageContainerHeader, ConfirmPageContainerContent, ConfirmPageContainerNavigation } from '.' export default class ConfirmPageContainer extends Component { static contextTypes = { @@ -43,6 +43,17 @@ export default class ConfirmPageContainer extends Component { summaryComponent: PropTypes.node, warning: PropTypes.string, unapprovedTxCount: PropTypes.number, + // Navigation + totalTx: PropTypes.number, + positionOfCurrentTx: PropTypes.number, + nextTxId: PropTypes.string, + prevTxId: PropTypes.string, + showNavigation: PropTypes.bool, + onNextTx: PropTypes.func, + firstTx: PropTypes.string, + lastTx: PropTypes.string, + ofText: PropTypes.string, + requestsWaitingText: PropTypes.string, // Footer onCancelAll: PropTypes.func, onCancel: PropTypes.func, @@ -79,11 +90,33 @@ export default class ConfirmPageContainer extends Component { unapprovedTxCount, assetImage, warning, + totalTx, + positionOfCurrentTx, + nextTxId, + prevTxId, + showNavigation, + onNextTx, + firstTx, + lastTx, + ofText, + requestsWaitingText, } = this.props const renderAssetImage = contentComponent || (!contentComponent && !identiconAddress) return ( <div className="page-container"> + <ConfirmPageContainerNavigation + totalTx={totalTx} + positionOfCurrentTx={positionOfCurrentTx} + nextTxId={nextTxId} + prevTxId={prevTxId} + showNavigation={showNavigation} + onNextTx={(txId) => onNextTx(txId)} + firstTx={firstTx} + lastTx={lastTx} + ofText={ofText} + requestsWaitingText={requestsWaitingText} + /> <ConfirmPageContainerHeader showEdit={showEdit} onEdit={() => onEdit()} diff --git a/ui/app/components/confirm-page-container/index.js b/ui/app/components/app/confirm-page-container/index.js index ee88aa5d3..28b17614e 100644 --- a/ui/app/components/confirm-page-container/index.js +++ b/ui/app/components/app/confirm-page-container/index.js @@ -1,6 +1,8 @@ export { default } from './confirm-page-container.component' export { default as ConfirmPageContainerHeader } from './confirm-page-container-header' export { default as ConfirmDetailRow } from './confirm-detail-row' +export { default as ConfirmPageContainerNavigation } from './confirm-page-container-navigation' + export { default as ConfirmPageContainerContent, ConfirmPageContainerSummary, diff --git a/ui/app/components/app/confirm-page-container/index.scss b/ui/app/components/app/confirm-page-container/index.scss new file mode 100644 index 000000000..c0277eff5 --- /dev/null +++ b/ui/app/components/app/confirm-page-container/index.scss @@ -0,0 +1,7 @@ +@import 'confirm-page-container-content/index'; + +@import 'confirm-page-container-header/index'; + +@import 'confirm-detail-row/index'; + +@import 'confirm-page-container-navigation/index'; diff --git a/ui/app/components/copyable.js b/ui/app/components/app/copyable.js index ad504deb8..6869d674d 100644 --- a/ui/app/components/copyable.js +++ b/ui/app/components/app/copyable.js @@ -3,7 +3,7 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits -const Tooltip = require('./tooltip') +const Tooltip = require('../ui/tooltip') const copyToClipboard = require('copy-to-clipboard') const connect = require('react-redux').connect diff --git a/ui/app/components/customize-gas-modal/gas-modal-card.js b/ui/app/components/app/customize-gas-modal/gas-modal-card.js index 23754d819..23754d819 100644 --- a/ui/app/components/customize-gas-modal/gas-modal-card.js +++ b/ui/app/components/app/customize-gas-modal/gas-modal-card.js diff --git a/ui/app/components/customize-gas-modal/gas-slider.js b/ui/app/components/app/customize-gas-modal/gas-slider.js index 69fd6f985..69fd6f985 100644 --- a/ui/app/components/customize-gas-modal/gas-slider.js +++ b/ui/app/components/app/customize-gas-modal/gas-slider.js diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/app/customize-gas-modal/index.js index e67fbe45b..dca77bb00 100644 --- a/ui/app/components/customize-gas-modal/index.js +++ b/ui/app/components/app/customize-gas-modal/index.js @@ -3,15 +3,16 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') +const BigNumber = require('bignumber.js') +const actions = require('../../../store/actions') const GasModalCard = require('./gas-modal-card') -import Button from '../button' +import Button from '../../ui/button' const ethUtil = require('ethereumjs-util') import { updateSendErrors, -} from '../../ducks/send.duck' +} from '../../../ducks/send/send.duck' const { MIN_GAS_PRICE_DEC, @@ -29,7 +30,7 @@ const { conversionGreaterThan, conversionMax, subtractCurrencies, -} = require('../../conversion-util') +} = require('../../../helpers/utils/conversion-util') const { getGasIsLoading, @@ -41,7 +42,7 @@ const { getCurrentAccountWithSendEtherInfo, getSelectedTokenToFiatRate, getSendMaxModeState, -} = require('../../selectors') +} = require('../../../selectors/selectors') const { getGasPrice, @@ -112,6 +113,7 @@ function CustomizeGasModal (props) { CustomizeGasModal.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal) @@ -148,6 +150,7 @@ CustomizeGasModal.prototype.componentWillReceiveProps = function (nextProps) { } CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { + const { metricsEvent } = this.context const { setGasPrice, setGasLimit, @@ -159,6 +162,9 @@ CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { updateSendAmount, updateSendErrors, } = this.props + const { + originalState, + } = this.state if (maxModeOn && !selectedToken) { const maxAmount = subtractCurrencies( @@ -169,6 +175,22 @@ CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { updateSendAmount(maxAmount) } + metricsEvent({ + eventOpts: { + category: 'Activation', + action: 'userCloses', + name: 'closeCustomizeGas', + }, + pageOpts: { + section: 'customizeGasModal', + component: 'customizeGasSaveButton', + }, + customVariables: { + gasPriceChange: (new BigNumber(ethUtil.addHexPrefix(gasPrice))).minus(new BigNumber(ethUtil.addHexPrefix(originalState.gasPrice))).toString(10), + gasLimitChange: (new BigNumber(ethUtil.addHexPrefix(gasLimit))).minus(new BigNumber(ethUtil.addHexPrefix(originalState.gasLimit))).toString(10), + }, + }) + setGasPrice(ethUtil.addHexPrefix(gasPrice)) setGasLimit(ethUtil.addHexPrefix(gasLimit)) setGasTotal(ethUtil.addHexPrefix(gasTotal)) diff --git a/ui/app/components/dropdowns/account-details-dropdown.js b/ui/app/components/app/dropdowns/account-details-dropdown.js index 7476cfdd9..3d4598946 100644 --- a/ui/app/components/dropdowns/account-details-dropdown.js +++ b/ui/app/components/app/dropdowns/account-details-dropdown.js @@ -3,13 +3,14 @@ const PropTypes = require('prop-types') 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 genAccountLink = require('../../../lib/account-link.js') +const actions = require('../../../store/actions') +const { getSelectedIdentity } = require('../../../selectors/selectors') +const genAccountLink = require('../../../../lib/account-link.js') const { Menu, Item, CloseArea } = require('./components/menu') AccountDetailsDropdown.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDetailsDropdown) @@ -72,6 +73,13 @@ AccountDetailsDropdown.prototype.render = function () { h(Item, { onClick: (e) => { e.stopPropagation() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Account Options', + name: 'Clicked Expand View', + }, + }) global.platform.openExtensionInBrowser() this.props.onClose() }, @@ -82,6 +90,13 @@ AccountDetailsDropdown.prototype.render = function () { onClick: (e) => { e.stopPropagation() showAccountDetailModal() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Account Options', + name: 'Viewed Account Details', + }, + }) this.props.onClose() }, text: this.context.t('accountDetails'), @@ -90,6 +105,13 @@ AccountDetailsDropdown.prototype.render = function () { h(Item, { onClick: (e) => { e.stopPropagation() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Account Options', + name: 'Clicked View on Etherscan', + }, + }) viewOnEtherscan(address, network) this.props.onClose() }, diff --git a/ui/app/components/dropdowns/components/account-dropdowns.js b/ui/app/components/app/dropdowns/components/account-dropdowns.js index e6b3e0c0c..c603a9a9f 100644 --- a/ui/app/components/dropdowns/components/account-dropdowns.js +++ b/ui/app/components/app/dropdowns/components/account-dropdowns.js @@ -1,15 +1,15 @@ const Component = require('react').Component const PropTypes = require('prop-types') const h = require('react-hyperscript') -const actions = require('../../../actions') -const genAccountLink = require('../../../../lib/account-link.js') +const actions = require('../../../../store/actions') +const genAccountLink = require('../../../../../lib/account-link.js') const connect = require('react-redux').connect const Dropdown = require('./dropdown').Dropdown const DropdownMenuItem = require('./dropdown').DropdownMenuItem -import Identicon from '../../identicon' -const { checksumAddress } = require('../../../util') +import Identicon from '../../../ui/identicon' +const { checksumAddress } = require('../../../../helpers/utils/util') const copyToClipboard = require('copy-to-clipboard') -const { formatBalance } = require('../../../util') +const { formatBalance } = require('../../../../helpers/utils/util') class AccountDropdowns extends Component { diff --git a/ui/app/components/dropdowns/components/dropdown.js b/ui/app/components/app/dropdowns/components/dropdown.js index 149f063a7..149f063a7 100644 --- a/ui/app/components/dropdowns/components/dropdown.js +++ b/ui/app/components/app/dropdowns/components/dropdown.js diff --git a/ui/app/components/dropdowns/components/menu.js b/ui/app/components/app/dropdowns/components/menu.js index f6d8a139e..f6d8a139e 100644 --- a/ui/app/components/dropdowns/components/menu.js +++ b/ui/app/components/app/dropdowns/components/menu.js diff --git a/ui/app/components/app/dropdowns/components/network-dropdown-icon.js b/ui/app/components/app/dropdowns/components/network-dropdown-icon.js new file mode 100644 index 000000000..d4a2c2ff7 --- /dev/null +++ b/ui/app/components/app/dropdowns/components/network-dropdown-icon.js @@ -0,0 +1,47 @@ +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', + diameter = '12', + loading, + } = this.props + + return loading + ? h('span.pointer.network-indicator', { + style: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + }, + }, [ + h('img', { + style: { + width: '27px', + }, + src: 'images/loading.svg', + }), + ]) + : h(`.menu-icon-circle${isSelected ? '--active' : ''}`, {}, + h('div', { + style: { + background: backgroundColor, + border: innerBorder, + height: `${diameter}px`, + width: `${diameter}px`, + }, + }) + ) +} diff --git a/ui/app/components/dropdowns/index.js b/ui/app/components/app/dropdowns/index.js index 605507058..605507058 100644 --- a/ui/app/components/dropdowns/index.js +++ b/ui/app/components/app/dropdowns/index.js diff --git a/ui/app/components/dropdowns/network-dropdown.js b/ui/app/components/app/dropdowns/network-dropdown.js index d4cc695a6..3d9037a06 100644 --- a/ui/app/components/dropdowns/network-dropdown.js +++ b/ui/app/components/app/dropdowns/network-dropdown.js @@ -5,12 +5,12 @@ const inherits = require('util').inherits const connect = require('react-redux').connect const { withRouter } = require('react-router-dom') const { compose } = require('recompose') -const actions = require('../../actions') +const actions = require('../../../store/actions') const Dropdown = require('./components/dropdown').Dropdown const DropdownMenuItem = require('./components/dropdown').DropdownMenuItem const NetworkDropdownIcon = require('./components/network-dropdown-icon') const R = require('ramda') -const { SETTINGS_ROUTE } = require('../../routes') +const { ADVANCED_ROUTE } = require('../../../helpers/constants/routes') // classes from nodes of the toggle element. const notToggleElementClassnames = [ @@ -60,6 +60,7 @@ function NetworkDropdown () { NetworkDropdown.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( @@ -120,7 +121,7 @@ NetworkDropdown.prototype.render = function () { { key: 'main', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('mainnet'), + onClick: () => this.handleClick('mainnet'), style: { ...dropdownMenuItemStyle, borderColor: '#038789' }, }, [ @@ -142,7 +143,7 @@ NetworkDropdown.prototype.render = function () { { key: 'ropsten', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('ropsten'), + onClick: () => this.handleClick('ropsten'), style: dropdownMenuItemStyle, }, [ @@ -164,7 +165,7 @@ NetworkDropdown.prototype.render = function () { { key: 'kovan', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('kovan'), + onClick: () => this.handleClick('kovan'), style: dropdownMenuItemStyle, }, [ @@ -186,7 +187,7 @@ NetworkDropdown.prototype.render = function () { { key: 'rinkeby', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('rinkeby'), + onClick: () => this.handleClick('rinkeby'), style: dropdownMenuItemStyle, }, [ @@ -208,7 +209,7 @@ NetworkDropdown.prototype.render = function () { { key: 'default', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('localhost'), + onClick: () => this.handleClick('localhost'), style: dropdownMenuItemStyle, }, [ @@ -232,7 +233,7 @@ NetworkDropdown.prototype.render = function () { DropdownMenuItem, { closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.props.history.push(SETTINGS_ROUTE), + onClick: () => this.props.history.push(ADVANCED_ROUTE), style: dropdownMenuItemStyle, }, [ @@ -252,6 +253,23 @@ NetworkDropdown.prototype.render = function () { ]) } +NetworkDropdown.prototype.handleClick = function (newProviderType) { + const { provider: { type: providerType }, setProviderType } = this.props + const { metricsEvent } = this.context + + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Switched Networks', + }, + customVariables: { + fromNetwork: providerType, + toNetwork: newProviderType, + }, + }) + setProviderType(newProviderType) +} NetworkDropdown.prototype.getNetworkName = function () { const { provider } = this.props @@ -277,7 +295,6 @@ NetworkDropdown.prototype.getNetworkName = function () { NetworkDropdown.prototype.renderCommonRpc = function (rpcListDetail, provider) { const props = this.props const reversedRpcListDetail = rpcListDetail.slice().reverse() - const network = props.network return reversedRpcListDetail.map((entry) => { const rpc = entry.rpcUrl @@ -288,7 +305,7 @@ NetworkDropdown.prototype.renderCommonRpc = function (rpcListDetail, provider) { if ((rpc === 'http://localhost:8545') || currentRpcTarget) { return null } else { - const chainId = entry.chainId || network + const chainId = entry.chainId return h( DropdownMenuItem, { diff --git a/ui/app/components/dropdowns/simple-dropdown.js b/ui/app/components/app/dropdowns/simple-dropdown.js index bba088ed1..bba088ed1 100644 --- a/ui/app/components/dropdowns/simple-dropdown.js +++ b/ui/app/components/app/dropdowns/simple-dropdown.js diff --git a/ui/app/components/dropdowns/tests/dropdown.test.js b/ui/app/components/app/dropdowns/tests/dropdown.test.js index 2b026589a..2b026589a 100644 --- a/ui/app/components/dropdowns/tests/dropdown.test.js +++ b/ui/app/components/app/dropdowns/tests/dropdown.test.js diff --git a/ui/app/components/dropdowns/tests/menu.test.js b/ui/app/components/app/dropdowns/tests/menu.test.js index 9f5f13f00..9f5f13f00 100644 --- a/ui/app/components/dropdowns/tests/menu.test.js +++ b/ui/app/components/app/dropdowns/tests/menu.test.js diff --git a/ui/app/components/dropdowns/tests/network-dropdown-icon.test.js b/ui/app/components/app/dropdowns/tests/network-dropdown-icon.test.js index 67b192c11..67b192c11 100644 --- a/ui/app/components/dropdowns/tests/network-dropdown-icon.test.js +++ b/ui/app/components/app/dropdowns/tests/network-dropdown-icon.test.js diff --git a/ui/app/components/dropdowns/tests/network-dropdown.test.js b/ui/app/components/app/dropdowns/tests/network-dropdown.test.js index 88ad56851..91e7899a7 100644 --- a/ui/app/components/dropdowns/tests/network-dropdown.test.js +++ b/ui/app/components/app/dropdowns/tests/network-dropdown.test.js @@ -1,7 +1,7 @@ import React from 'react' import assert from 'assert' import { createMockStore } from 'redux-test-utils' -import { mountWithRouter } from '../../../../../test/lib/render-helpers' +import { mountWithRouter } from '../../../../../../test/lib/render-helpers' import NetworkDropdown from '../network-dropdown' import { DropdownMenuItem } from '../components/dropdown' import NetworkDropdownIcon from '../components/network-dropdown-icon' diff --git a/ui/app/components/dropdowns/token-menu-dropdown.js b/ui/app/components/app/dropdowns/token-menu-dropdown.js index 8a072b1bc..e2730aea2 100644 --- a/ui/app/components/dropdowns/token-menu-dropdown.js +++ b/ui/app/components/app/dropdowns/token-menu-dropdown.js @@ -3,7 +3,7 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') +const actions = require('../../../store/actions') const genAccountLink = require('etherscan-link').createAccountLink const { Menu, Item, CloseArea } = require('./components/menu') diff --git a/ui/app/components/ens-input.js b/ui/app/components/app/ens-input.js index f538fd555..274058a1b 100644 --- a/ui/app/components/ens-input.js +++ b/ui/app/components/app/ens-input.js @@ -12,7 +12,7 @@ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' const connect = require('react-redux').connect const ToAutoComplete = require('./send/to-autocomplete').default const log = require('loglevel') -const { isValidENSAddress } = require('../util') +const { isValidENSAddress } = require('../../helpers/utils/util') EnsInput.contextTypes = { t: PropTypes.func, @@ -128,7 +128,7 @@ EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) { } if (prevState && ensResolution && this.props.onChange && ensResolution !== prevState.ensResolution) { - this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError }) + this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError, toWarning: state.toWarning }) } } diff --git a/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.component.js b/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.component.js new file mode 100644 index 000000000..95894140c --- /dev/null +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.component.js @@ -0,0 +1,156 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import debounce from 'lodash.debounce' + +export default class AdvancedTabContent extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + updateCustomGasPrice: PropTypes.func, + updateCustomGasLimit: PropTypes.func, + customGasPrice: PropTypes.number, + customGasLimit: PropTypes.number, + insufficientBalance: PropTypes.bool, + customPriceIsSafe: PropTypes.bool, + isSpeedUp: PropTypes.bool, + showGasPriceInfoModal: PropTypes.func, + showGasLimitInfoModal: PropTypes.func, + } + + debouncedGasLimitReset = debounce((dVal) => { + if (dVal < 21000) { + this.props.updateCustomGasLimit(21000) + } + }, 1000, { trailing: true }) + + onChangeGasLimit = (val) => { + this.props.updateCustomGasLimit(val) + this.debouncedGasLimitReset(val) + } + + gasInputError ({ labelKey, insufficientBalance, customPriceIsSafe, isSpeedUp, value }) { + const { t } = this.context + let errorText + let errorType + let isInError = true + + + if (insufficientBalance) { + errorText = t('insufficientBalance') + errorType = 'error' + } else if (labelKey === 'gasPrice' && isSpeedUp && value === 0) { + errorText = t('zeroGasPriceOnSpeedUpError') + errorType = 'error' + } else if (labelKey === 'gasPrice' && !customPriceIsSafe) { + errorText = t('gasPriceExtremelyLow') + errorType = 'warning' + } else { + isInError = false + } + + return { + isInError, + errorText, + errorType, + } + } + + gasInput ({ labelKey, value, onChange, insufficientBalance, showGWEI, customPriceIsSafe, isSpeedUp }) { + const { + isInError, + errorText, + errorType, + } = this.gasInputError({ labelKey, insufficientBalance, customPriceIsSafe, isSpeedUp, value }) + + return ( + <div className="advanced-gas-inputs__gas-edit-row__input-wrapper"> + <input + className={classnames('advanced-gas-inputs__gas-edit-row__input', { + 'advanced-gas-inputs__gas-edit-row__input--error': isInError && errorType === 'error', + 'advanced-gas-inputs__gas-edit-row__input--warning': isInError && errorType === 'warning', + })} + type="number" + value={value} + onChange={event => onChange(Number(event.target.value))} + /> + <div className={classnames('advanced-gas-inputs__gas-edit-row__input-arrows', { + 'advanced-gas-inputs__gas-edit-row__input--error': isInError && errorType === 'error', + 'advanced-gas-inputs__gas-edit-row__input--warning': isInError && errorType === 'warning', + })}> + <div + className="advanced-gas-inputs__gas-edit-row__input-arrows__i-wrap" + onClick={() => onChange(value + 1)} + > + <i className="fa fa-sm fa-angle-up" /> + </div> + <div + className="advanced-gas-inputs__gas-edit-row__input-arrows__i-wrap" + onClick={() => onChange(Math.max(value - 1, 0))} + > + <i className="fa fa-sm fa-angle-down" /> + </div> + </div> + { isInError + ? <div className={`advanced-gas-inputs__gas-edit-row__${errorType}-text`}> + { errorText } + </div> + : null } + </div> + ) + } + + infoButton (onClick) { + return <i className="fa fa-info-circle" onClick={onClick} /> + } + + renderGasEditRow (gasInputArgs) { + return ( + <div className="advanced-gas-inputs__gas-edit-row"> + <div className="advanced-gas-inputs__gas-edit-row__label"> + { this.context.t(gasInputArgs.labelKey) } + { this.infoButton(() => gasInputArgs.infoOnClick()) } + </div> + { this.gasInput(gasInputArgs) } + </div> + ) + } + + render () { + const { + customGasPrice, + updateCustomGasPrice, + customGasLimit, + insufficientBalance, + customPriceIsSafe, + isSpeedUp, + showGasPriceInfoModal, + showGasLimitInfoModal, + } = this.props + + return ( + <div className="advanced-gas-inputs__gas-edit-rows"> + { this.renderGasEditRow({ + labelKey: 'gasPrice', + value: customGasPrice, + onChange: updateCustomGasPrice, + insufficientBalance, + customPriceIsSafe, + showGWEI: true, + isSpeedUp, + infoOnClick: showGasPriceInfoModal, + }) } + { this.renderGasEditRow({ + labelKey: 'gasLimit', + value: customGasLimit, + onChange: this.onChangeGasLimit, + insufficientBalance, + customPriceIsSafe, + infoOnClick: showGasLimitInfoModal, + }) } + </div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.container.js b/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.container.js new file mode 100644 index 000000000..90fef1a1b --- /dev/null +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.container.js @@ -0,0 +1,38 @@ +import { connect } from 'react-redux' +import { showModal } from '../../../../store/actions' +import { + decGWEIToHexWEI, + decimalToHex, + hexWEIToDecGWEI, +} from '../../../../helpers/utils/conversions.util' +import AdvancedGasInputs from './advanced-gas-inputs.component' + +function convertGasPriceForInputs (gasPriceInHexWEI) { + return Number(hexWEIToDecGWEI(gasPriceInHexWEI)) +} + +function convertGasLimitForInputs (gasLimitInHexWEI) { + return parseInt(gasLimitInHexWEI, 16) +} + +const mapDispatchToProps = dispatch => { + return { + showGasPriceInfoModal: modalName => dispatch(showModal({ name: 'GAS_PRICE_INFO_MODAL' })), + showGasLimitInfoModal: modalName => dispatch(showModal({ name: 'GAS_LIMIT_INFO_MODAL' })), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const {customGasPrice, customGasLimit, updateCustomGasPrice, updateCustomGasLimit} = ownProps + return { + ...stateProps, + ...dispatchProps, + ...ownProps, + customGasPrice: convertGasPriceForInputs(customGasPrice), + customGasLimit: convertGasLimitForInputs(customGasLimit), + updateCustomGasPrice: (price) => updateCustomGasPrice(decGWEIToHexWEI(price)), + updateCustomGasLimit: (limit) => updateCustomGasLimit(decimalToHex(limit)), + } +} + +export default connect(null, mapDispatchToProps, mergeProps)(AdvancedGasInputs) diff --git a/ui/app/components/app/gas-customization/advanced-gas-inputs/index.js b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.js new file mode 100644 index 000000000..bd8abaa3e --- /dev/null +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.js @@ -0,0 +1 @@ +export { default } from './advanced-gas-inputs.container' diff --git a/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss new file mode 100644 index 000000000..50953cbe5 --- /dev/null +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss @@ -0,0 +1,133 @@ +.advanced-gas-inputs { + &__gas-edit-rows { + display: flex; + flex-flow: row; + justify-content: space-between; + } + + &__gas-edit-row { + display: flex; + flex-flow: column; + width: 47.5%; + + &__label { + color: #313B5E; + font-size: 12px; + display: flex; + justify-content: space-between; + align-items: center; + + @media screen and (max-width: 576px) { + font-size: 10px; + } + + .fa-info-circle { + color: $silver; + margin-left: 10px; + cursor: pointer; + } + + .fa-info-circle:hover { + color: $mid-gray; + } + } + + &__error-text { + font-size: 12px; + color: red; + } + + &__warning-text { + font-size: 12px; + color: orange; + } + + &__input-wrapper { + position: relative; + } + + &__input { + border: 1px solid $dusty-gray; + border-radius: 4px; + color: $mid-gray; + font-size: 16px; + height: 24px; + width: 100%; + padding-left: 8px; + padding-top: 2px; + margin-top: 7px; + } + + &__input--error { + border: 1px solid $red; + } + + &__input--warning { + border: 1px solid $orange; + } + + &__input-arrows { + position: absolute; + top: 7px; + right: 0px; + width: 17px; + height: 24px; + border: 1px solid #dadada; + border-top-right-radius: 4px; + display: flex; + flex-direction: column; + color: #9b9b9b; + font-size: .8em; + border-bottom-right-radius: 4px; + cursor: pointer; + + &__i-wrap { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + cursor: pointer; + } + + &__i-wrap:hover { + background: #4EADE7; + color: $white; + } + + i:hover { + background: #4EADE7; + } + + i { + font-size: 10px; + } + } + + &__input-arrows--error { + border: 1px solid $red; + } + + &__input-arrows--warning { + border: 1px solid $orange; + } + + input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + -moz-appearance: none; + display: none; + } + + input[type="number"]:hover::-webkit-inner-spin-button { + -webkit-appearance: none; + -moz-appearance: none; + display: none; + } + + &__gwei-symbol { + position: absolute; + top: 8px; + right: 10px; + color: $dusty-gray; + } + } +}
\ No newline at end of file diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js new file mode 100644 index 000000000..ad8628621 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js @@ -0,0 +1,226 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Loading from '../../../../ui/loading-screen' +import GasPriceChart from '../../gas-price-chart' +import debounce from 'lodash.debounce' + +export default class AdvancedTabContent extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + updateCustomGasPrice: PropTypes.func, + updateCustomGasLimit: PropTypes.func, + customGasPrice: PropTypes.number, + customGasLimit: PropTypes.number, + gasEstimatesLoading: PropTypes.bool, + millisecondsRemaining: PropTypes.number, + transactionFee: PropTypes.string, + timeRemaining: PropTypes.string, + gasChartProps: PropTypes.object, + insufficientBalance: PropTypes.bool, + customPriceIsSafe: PropTypes.bool, + isSpeedUp: PropTypes.bool, + isEthereumNetwork: PropTypes.bool, + } + + constructor (props) { + super(props) + + this.debouncedGasLimitReset = debounce((dVal) => { + if (dVal < 21000) { + props.updateCustomGasLimit(21000) + } + }, 1000, { trailing: true }) + this.onChangeGasLimit = (val) => { + props.updateCustomGasLimit(val) + this.debouncedGasLimitReset(val) + } + } + + gasInputError ({ labelKey, insufficientBalance, customPriceIsSafe, isSpeedUp, value }) { + const { t } = this.context + let errorText + let errorType + let isInError = true + + + if (insufficientBalance) { + errorText = t('insufficientBalance') + errorType = 'error' + } else if (labelKey === 'gasPrice' && isSpeedUp && value === 0) { + errorText = t('zeroGasPriceOnSpeedUpError') + errorType = 'error' + } else if (labelKey === 'gasPrice' && !customPriceIsSafe) { + errorText = t('gasPriceExtremelyLow') + errorType = 'warning' + } else { + isInError = false + } + + return { + isInError, + errorText, + errorType, + } + } + + gasInput ({ labelKey, value, onChange, insufficientBalance, showGWEI, customPriceIsSafe, isSpeedUp }) { + const { + isInError, + errorText, + errorType, + } = this.gasInputError({ labelKey, insufficientBalance, customPriceIsSafe, isSpeedUp, value }) + + return ( + <div className="advanced-tab__gas-edit-row__input-wrapper"> + <input + className={classnames('advanced-tab__gas-edit-row__input', { + 'advanced-tab__gas-edit-row__input--error': isInError && errorType === 'error', + 'advanced-tab__gas-edit-row__input--warning': isInError && errorType === 'warning', + })} + type="number" + value={value} + onChange={event => onChange(Number(event.target.value))} + /> + <div className={classnames('advanced-tab__gas-edit-row__input-arrows', { + 'advanced-tab__gas-edit-row__input--error': isInError && errorType === 'error', + 'advanced-tab__gas-edit-row__input--warning': isInError && errorType === 'warning', + })}> + <div + className="advanced-tab__gas-edit-row__input-arrows__i-wrap" + onClick={() => onChange(value + 1)} + > + <i className="fa fa-sm fa-angle-up" /> + </div> + <div + className="advanced-tab__gas-edit-row__input-arrows__i-wrap" + onClick={() => onChange(Math.max(value - 1, 0))} + > + <i className="fa fa-sm fa-angle-down" /> + </div> + </div> + { isInError + ? <div className={`advanced-tab__gas-edit-row__${errorType}-text`}> + { errorText } + </div> + : null } + </div> + ) + } + + infoButton (onClick) { + return <i className="fa fa-info-circle" onClick={onClick} /> + } + + renderDataSummary (transactionFee, timeRemaining) { + return ( + <div className="advanced-tab__transaction-data-summary"> + <div className="advanced-tab__transaction-data-summary__titles"> + <span>{ this.context.t('newTransactionFee') }</span> + <span>~{ this.context.t('transactionTime') }</span> + </div> + <div className="advanced-tab__transaction-data-summary__container"> + <div className="advanced-tab__transaction-data-summary__fee"> + {transactionFee} + </div> + <div className="time-remaining">{timeRemaining}</div> + </div> + </div> + ) + } + + renderGasEditRow (gasInputArgs) { + return ( + <div className="advanced-tab__gas-edit-row"> + <div className="advanced-tab__gas-edit-row__label"> + { this.context.t(gasInputArgs.labelKey) } + { this.infoButton(() => {}) } + </div> + { this.gasInput(gasInputArgs) } + </div> + ) + } + + renderGasEditRows ({ + customGasPrice, + updateCustomGasPrice, + customGasLimit, + updateCustomGasLimit, + insufficientBalance, + customPriceIsSafe, + isSpeedUp, + }) { + return ( + <div className="advanced-tab__gas-edit-rows"> + { this.renderGasEditRow({ + labelKey: 'gasPrice', + value: customGasPrice, + onChange: updateCustomGasPrice, + insufficientBalance, + customPriceIsSafe, + showGWEI: true, + isSpeedUp, + }) } + { this.renderGasEditRow({ + labelKey: 'gasLimit', + value: customGasLimit, + onChange: this.onChangeGasLimit, + insufficientBalance, + customPriceIsSafe, + }) } + </div> + ) + } + + render () { + const { t } = this.context + const { + updateCustomGasPrice, + updateCustomGasLimit, + timeRemaining, + customGasPrice, + customGasLimit, + insufficientBalance, + gasChartProps, + gasEstimatesLoading, + customPriceIsSafe, + isSpeedUp, + transactionFee, + isEthereumNetwork, + } = this.props + + return ( + <div className="advanced-tab"> + { this.renderDataSummary(transactionFee, timeRemaining) } + <div className="advanced-tab__fee-chart"> + { this.renderGasEditRows({ + customGasPrice, + updateCustomGasPrice, + customGasLimit, + updateCustomGasLimit, + insufficientBalance, + customPriceIsSafe, + isSpeedUp, + }) } + { isEthereumNetwork + ? <div> + <div className="advanced-tab__fee-chart__title">{ t('liveGasPricePredictions') }</div> + {!gasEstimatesLoading + ? <GasPriceChart {...gasChartProps} updateCustomGasPrice={updateCustomGasPrice} /> + : <Loading /> + } + <div className="advanced-tab__fee-chart__speed-buttons"> + <span>{ t('slower') }</span> + <span>{ t('faster') }</span> + </div> + </div> + : <div className="advanced-tab__fee-chart__title">{ t('chartOnlyAvailableEth') }</div> + } + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/index.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/index.js new file mode 100644 index 000000000..492037f25 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/index.js @@ -0,0 +1 @@ +export { default } from './advanced-tab-content.component' diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/index.scss b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/index.scss new file mode 100644 index 000000000..20a503018 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/index.scss @@ -0,0 +1,207 @@ +@import './time-remaining/index'; + +.advanced-tab { + display: flex; + flex-flow: column; + + &__transaction-data-summary, + &__fee-chart-title { + padding-left: 24px; + padding-right: 24px; + } + + &__transaction-data-summary { + display: flex; + flex-flow: column; + color: $mid-gray; + margin-top: 12px; + padding-left: 18px; + padding-right: 18px; + + &__titles, + &__container { + display: flex; + flex-flow: row; + justify-content: space-between; + font-size: 12px; + color: #888EA3; + } + + &__container { + font-size: 16px; + margin-top: 0px; + } + + &__fee { + font-size: 16px; + color: #313A5E; + } + } + + &__fee-chart { + margin-top: 8px; + height: 265px; + background: #F8F9FB; + border-bottom: 1px solid #d2d8dd; + border-top: 1px solid #d2d8dd; + position: relative; + + &__title { + font-size: 12px; + color: #313A5E; + margin-left: 22px; + } + + &__speed-buttons { + position: absolute; + bottom: 13px; + display: flex; + justify-content: space-between; + padding-left: 20px; + padding-right: 19px; + width: 100%; + font-size: 10px; + color: #888EA3; + } + + .loading-overlay { + height: auto; + } + } + + &__slider-container { + padding-left: 27px; + padding-right: 27px; + } + + &__gas-edit-rows { + height: 73px; + display: flex; + flex-flow: row; + justify-content: space-between; + margin-left: 20px; + margin-right: 10px; + margin-top: 9px; + } + + &__gas-edit-row { + display: flex; + flex-flow: column; + + &__label { + color: #313B5E; + font-size: 14px; + display: flex; + justify-content: space-between; + align-items: center; + + .fa-info-circle { + color: $silver; + margin-left: 10px; + cursor: pointer; + } + + .fa-info-circle:hover { + color: $mid-gray; + } + } + + &__error-text { + font-size: 12px; + color: red; + } + + &__warning-text { + font-size: 12px; + color: orange; + } + + &__input-wrapper { + position: relative; + } + + &__input { + border: 1px solid $dusty-gray; + border-radius: 4px; + color: $mid-gray; + font-size: 16px; + height: 24px; + width: 155px; + padding-left: 8px; + padding-top: 2px; + margin-top: 7px; + } + + &__input--error { + border: 1px solid $red; + } + + &__input--warning { + border: 1px solid $orange; + } + + &__input-arrows { + position: absolute; + top: 7px; + right: 0px; + width: 17px; + height: 24px; + border: 1px solid #dadada; + border-top-right-radius: 4px; + display: flex; + flex-direction: column; + color: #9b9b9b; + font-size: .8em; + border-bottom-right-radius: 4px; + cursor: pointer; + + &__i-wrap { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + cursor: pointer; + } + + &__i-wrap:hover { + background: #4EADE7; + color: $white; + } + + i:hover { + background: #4EADE7; + } + + i { + font-size: 10px; + } + } + + &__input-arrows--error { + border: 1px solid $red; + } + + &__input-arrows--warning { + border: 1px solid $orange; + } + + input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + -moz-appearance: none; + display: none; + } + + input[type="number"]:hover::-webkit-inner-spin-button { + -webkit-appearance: none; + -moz-appearance: none; + display: none; + } + + &__gwei-symbol { + position: absolute; + top: 8px; + right: 10px; + color: $dusty-gray; + } + } +}
\ No newline at end of file diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js new file mode 100644 index 000000000..5f7d90922 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js @@ -0,0 +1,365 @@ +import React from 'react' +import assert from 'assert' +import shallow from '../../../../../../../lib/shallow-with-context' +import sinon from 'sinon' +import AdvancedTabContent from '../advanced-tab-content.component.js' + +import GasPriceChart from '../../../gas-price-chart' +import Loading from '../../../../../ui/loading-screen' + +const propsMethodSpies = { + updateCustomGasPrice: sinon.spy(), + updateCustomGasLimit: sinon.spy(), +} + +sinon.spy(AdvancedTabContent.prototype, 'renderGasEditRow') +sinon.spy(AdvancedTabContent.prototype, 'gasInput') +sinon.spy(AdvancedTabContent.prototype, 'renderGasEditRows') +sinon.spy(AdvancedTabContent.prototype, 'renderDataSummary') +sinon.spy(AdvancedTabContent.prototype, 'gasInputError') + +describe('AdvancedTabContent Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<AdvancedTabContent + updateCustomGasPrice={propsMethodSpies.updateCustomGasPrice} + updateCustomGasLimit={propsMethodSpies.updateCustomGasLimit} + customGasPrice={11} + customGasLimit={23456} + timeRemaining={21500} + transactionFee={'$0.25'} + insufficientBalance={false} + customPriceIsSafe={true} + isSpeedUp={false} + isEthereumNetwork={true} + />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }) + }) + + afterEach(() => { + propsMethodSpies.updateCustomGasPrice.resetHistory() + propsMethodSpies.updateCustomGasLimit.resetHistory() + AdvancedTabContent.prototype.renderGasEditRow.resetHistory() + AdvancedTabContent.prototype.gasInput.resetHistory() + AdvancedTabContent.prototype.renderGasEditRows.resetHistory() + AdvancedTabContent.prototype.renderDataSummary.resetHistory() + }) + + describe('render()', () => { + it('should render the advanced-tab root node', () => { + assert(wrapper.hasClass('advanced-tab')) + }) + + it('should render the expected four children of the advanced-tab div', () => { + const advancedTabChildren = wrapper.children() + assert.equal(advancedTabChildren.length, 2) + + assert(advancedTabChildren.at(0).hasClass('advanced-tab__transaction-data-summary')) + assert(advancedTabChildren.at(1).hasClass('advanced-tab__fee-chart')) + + const feeChartDiv = advancedTabChildren.at(1) + + assert(feeChartDiv.childAt(0).hasClass('advanced-tab__gas-edit-rows')) + assert(feeChartDiv.childAt(1).childAt(0).hasClass('advanced-tab__fee-chart__title')) + assert(feeChartDiv.childAt(1).childAt(1).is(GasPriceChart)) + assert(feeChartDiv.childAt(1).childAt(2).hasClass('advanced-tab__fee-chart__speed-buttons')) + }) + + it('should render a loading component instead of the chart if gasEstimatesLoading is true', () => { + wrapper.setProps({ gasEstimatesLoading: true }) + const advancedTabChildren = wrapper.children() + assert.equal(advancedTabChildren.length, 2) + + assert(advancedTabChildren.at(0).hasClass('advanced-tab__transaction-data-summary')) + assert(advancedTabChildren.at(1).hasClass('advanced-tab__fee-chart')) + + const feeChartDiv = advancedTabChildren.at(1) + + assert(feeChartDiv.childAt(0).hasClass('advanced-tab__gas-edit-rows')) + assert(feeChartDiv.childAt(1).childAt(0).hasClass('advanced-tab__fee-chart__title')) + assert(feeChartDiv.childAt(1).childAt(1).is(Loading)) + assert(feeChartDiv.childAt(1).childAt(2).hasClass('advanced-tab__fee-chart__speed-buttons')) + }) + + it('should call renderDataSummary with the expected params', () => { + assert.equal(AdvancedTabContent.prototype.renderGasEditRows.callCount, 1) + const renderDataSummaryArgs = AdvancedTabContent.prototype.renderDataSummary.getCall(0).args + assert.deepEqual(renderDataSummaryArgs, ['$0.25', 21500]) + }) + + it('should call renderGasEditRows with the expected params', () => { + assert.equal(AdvancedTabContent.prototype.renderGasEditRows.callCount, 1) + const renderGasEditRowArgs = AdvancedTabContent.prototype.renderGasEditRows.getCall(0).args + assert.deepEqual(renderGasEditRowArgs, [{ + customGasPrice: 11, + customGasLimit: 23456, + insufficientBalance: false, + customPriceIsSafe: true, + updateCustomGasPrice: propsMethodSpies.updateCustomGasPrice, + updateCustomGasLimit: propsMethodSpies.updateCustomGasLimit, + isSpeedUp: false, + }]) + }) + }) + + describe('renderDataSummary()', () => { + let dataSummary + + beforeEach(() => { + dataSummary = shallow(wrapper.instance().renderDataSummary('mockTotalFee', 'mockMsRemaining')) + }) + + it('should render the transaction-data-summary root node', () => { + assert(dataSummary.hasClass('advanced-tab__transaction-data-summary')) + }) + + it('should render titles of the data', () => { + const titlesNode = dataSummary.children().at(0) + assert(titlesNode.hasClass('advanced-tab__transaction-data-summary__titles')) + assert.equal(titlesNode.children().at(0).text(), 'newTransactionFee') + assert.equal(titlesNode.children().at(1).text(), '~transactionTime') + }) + + it('should render the data', () => { + const dataNode = dataSummary.children().at(1) + assert(dataNode.hasClass('advanced-tab__transaction-data-summary__container')) + assert.equal(dataNode.children().at(0).text(), 'mockTotalFee') + assert(dataNode.children().at(1).hasClass('time-remaining')) + assert.equal(dataNode.children().at(1).text(), 'mockMsRemaining') + }) + }) + + describe('renderGasEditRow()', () => { + let gasEditRow + + beforeEach(() => { + AdvancedTabContent.prototype.gasInput.resetHistory() + gasEditRow = shallow(wrapper.instance().renderGasEditRow({ + labelKey: 'mockLabelKey', + someArg: 'argA', + })) + }) + + it('should render the gas-edit-row root node', () => { + assert(gasEditRow.hasClass('advanced-tab__gas-edit-row')) + }) + + it('should render a label and an input', () => { + const gasEditRowChildren = gasEditRow.children() + assert.equal(gasEditRowChildren.length, 2) + assert(gasEditRowChildren.at(0).hasClass('advanced-tab__gas-edit-row__label')) + assert(gasEditRowChildren.at(1).hasClass('advanced-tab__gas-edit-row__input-wrapper')) + }) + + it('should render the label key and info button', () => { + const gasRowLabelChildren = gasEditRow.children().at(0).children() + assert.equal(gasRowLabelChildren.length, 2) + assert(gasRowLabelChildren.at(0), 'mockLabelKey') + assert(gasRowLabelChildren.at(1).hasClass('fa-info-circle')) + }) + + it('should call this.gasInput with the correct args', () => { + const gasInputSpyArgs = AdvancedTabContent.prototype.gasInput.args + assert.deepEqual(gasInputSpyArgs[0], [ { labelKey: 'mockLabelKey', someArg: 'argA' } ]) + }) + }) + + describe('renderGasEditRows()', () => { + let gasEditRows + let tempOnChangeGasLimit + + beforeEach(() => { + tempOnChangeGasLimit = wrapper.instance().onChangeGasLimit + wrapper.instance().onChangeGasLimit = () => 'mockOnChangeGasLimit' + AdvancedTabContent.prototype.renderGasEditRow.resetHistory() + gasEditRows = shallow(wrapper.instance().renderGasEditRows( + 'mockGasPrice', + () => 'mockUpdateCustomGasPriceReturn', + 'mockGasLimit', + () => 'mockUpdateCustomGasLimitReturn', + false + )) + }) + + afterEach(() => { + wrapper.instance().onChangeGasLimit = tempOnChangeGasLimit + }) + + it('should render the gas-edit-rows root node', () => { + assert(gasEditRows.hasClass('advanced-tab__gas-edit-rows')) + }) + + it('should render two rows', () => { + const gasEditRowsChildren = gasEditRows.children() + assert.equal(gasEditRowsChildren.length, 2) + assert(gasEditRowsChildren.at(0).hasClass('advanced-tab__gas-edit-row')) + assert(gasEditRowsChildren.at(1).hasClass('advanced-tab__gas-edit-row')) + }) + + it('should call this.renderGasEditRow twice, with the expected args', () => { + const renderGasEditRowSpyArgs = AdvancedTabContent.prototype.renderGasEditRow.args + assert.equal(renderGasEditRowSpyArgs.length, 2) + assert.deepEqual(renderGasEditRowSpyArgs[0].map(String), [{ + labelKey: 'gasPrice', + value: 'mockGasLimit', + onChange: () => 'mockOnChangeGasLimit', + insufficientBalance: false, + customPriceIsSafe: true, + showGWEI: true, + }].map(String)) + assert.deepEqual(renderGasEditRowSpyArgs[1].map(String), [{ + labelKey: 'gasPrice', + value: 'mockGasPrice', + onChange: () => 'mockUpdateCustomGasPriceReturn', + insufficientBalance: false, + customPriceIsSafe: true, + showGWEI: true, + }].map(String)) + }) + }) + + describe('infoButton()', () => { + let infoButton + + beforeEach(() => { + AdvancedTabContent.prototype.renderGasEditRow.resetHistory() + infoButton = shallow(wrapper.instance().infoButton(() => 'mockOnClickReturn')) + }) + + it('should render the i element', () => { + assert(infoButton.hasClass('fa-info-circle')) + }) + + it('should pass the onClick argument to the i tag onClick prop', () => { + assert(infoButton.props().onClick(), 'mockOnClickReturn') + }) + }) + + describe('gasInput()', () => { + let gasInput + + beforeEach(() => { + AdvancedTabContent.prototype.renderGasEditRow.resetHistory() + AdvancedTabContent.prototype.gasInputError.resetHistory() + gasInput = shallow(wrapper.instance().gasInput({ + labelKey: 'gasPrice', + value: 321, + onChange: value => value + 7, + insufficientBalance: false, + showGWEI: true, + customPriceIsSafe: true, + isSpeedUp: false, + })) + }) + + it('should render the input-wrapper root node', () => { + assert(gasInput.hasClass('advanced-tab__gas-edit-row__input-wrapper')) + }) + + it('should render two children, including an input', () => { + assert.equal(gasInput.children().length, 2) + assert(gasInput.children().at(0).hasClass('advanced-tab__gas-edit-row__input')) + }) + + it('should call the passed onChange method with the value of the input onChange event', () => { + const inputOnChange = gasInput.find('input').props().onChange + assert.equal(inputOnChange({ target: { value: 8} }), 15) + }) + + it('should have two input arrows', () => { + const upArrow = gasInput.find('.fa-angle-up') + assert.equal(upArrow.length, 1) + const downArrow = gasInput.find('.fa-angle-down') + assert.equal(downArrow.length, 1) + }) + + it('should call onChange with the value incremented decremented when its onchange method is called', () => { + const upArrow = gasInput.find('.advanced-tab__gas-edit-row__input-arrows__i-wrap').at(0) + assert.equal(upArrow.props().onClick(), 329) + const downArrow = gasInput.find('.advanced-tab__gas-edit-row__input-arrows__i-wrap').at(1) + assert.equal(downArrow.props().onClick(), 327) + }) + + it('should call gasInputError with the expected params', () => { + assert.equal(AdvancedTabContent.prototype.gasInputError.callCount, 1) + const gasInputErrorArgs = AdvancedTabContent.prototype.gasInputError.getCall(0).args + assert.deepEqual(gasInputErrorArgs, [{ + labelKey: 'gasPrice', + insufficientBalance: false, + customPriceIsSafe: true, + value: 321, + isSpeedUp: false, + }]) + }) + }) + + describe('gasInputError()', () => { + let gasInputError + + beforeEach(() => { + AdvancedTabContent.prototype.renderGasEditRow.resetHistory() + gasInputError = wrapper.instance().gasInputError({ + labelKey: '', + insufficientBalance: false, + customPriceIsSafe: true, + isSpeedUp: false, + }) + }) + + it('should return an insufficientBalance error', () => { + const gasInputError = wrapper.instance().gasInputError({ + labelKey: 'gasPrice', + insufficientBalance: true, + customPriceIsSafe: true, + isSpeedUp: false, + value: 1, + }) + assert.deepEqual(gasInputError, { + isInError: true, + errorText: 'insufficientBalance', + errorType: 'error', + }) + }) + + it('should return a zero gas on retry error', () => { + const gasInputError = wrapper.instance().gasInputError({ + labelKey: 'gasPrice', + insufficientBalance: false, + customPriceIsSafe: false, + isSpeedUp: true, + value: 0, + }) + assert.deepEqual(gasInputError, { + isInError: true, + errorText: 'zeroGasPriceOnSpeedUpError', + errorType: 'error', + }) + }) + + it('should return a low gas warning', () => { + const gasInputError = wrapper.instance().gasInputError({ + labelKey: 'gasPrice', + insufficientBalance: false, + customPriceIsSafe: false, + isSpeedUp: false, + value: 1, + }) + assert.deepEqual(gasInputError, { + isInError: true, + errorText: 'gasPriceExtremelyLow', + errorType: 'warning', + }) + }) + + it('should return isInError false if there is no error', () => { + gasInputError = wrapper.instance().gasInputError({ + labelKey: 'gasPrice', + insufficientBalance: false, + customPriceIsSafe: true, + value: 1, + }) + assert.equal(gasInputError.isInError, false) + }) + }) + +}) diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.js new file mode 100644 index 000000000..61b681e1a --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.js @@ -0,0 +1 @@ +export { default } from './time-remaining.component' diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.scss b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.scss new file mode 100644 index 000000000..e2115af7f --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.scss @@ -0,0 +1,17 @@ +.time-remaining { + color: #313A5E; + font-size: 16px; + + .minutes-num, .seconds-num { + font-size: 16px; + } + + .seconds-num { + margin-left: 7px; + font-size: 16px; + } + + .minutes-label, .seconds-label { + font-size: 16px; + } +}
\ No newline at end of file diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js new file mode 100644 index 000000000..17f0345d5 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js @@ -0,0 +1,30 @@ +import React from 'react' +import assert from 'assert' +import shallow from '../../../../../../../../lib/shallow-with-context' +import TimeRemaining from '../time-remaining.component.js' + +describe('TimeRemaining Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<TimeRemaining + milliseconds={495000} + />) + }) + + describe('render()', () => { + it('should render the time-remaining root node', () => { + assert(wrapper.hasClass('time-remaining')) + }) + + it('should render minutes and seconds numbers and labels', () => { + const timeRemainingChildren = wrapper.children() + assert.equal(timeRemainingChildren.length, 4) + assert.equal(timeRemainingChildren.at(0).text(), 8) + assert.equal(timeRemainingChildren.at(1).text(), 'minutesShorthand') + assert.equal(timeRemainingChildren.at(2).text(), 15) + assert.equal(timeRemainingChildren.at(3).text(), 'secondsShorthand') + }) + }) + +}) diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.component.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.component.js new file mode 100644 index 000000000..826d41f9c --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.component.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { getTimeBreakdown } from './time-remaining.utils' + +export default class TimeRemaining extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + milliseconds: PropTypes.number, + } + + render () { + const { + milliseconds, + } = this.props + + const { + minutes, + seconds, + } = getTimeBreakdown(milliseconds) + + return ( + <div className="time-remaining"> + <span className="minutes-num">{minutes}</span> + <span className="minutes-label">{this.context.t('minutesShorthand')}</span> + <span className="seconds-num">{seconds}</span> + <span className="seconds-label">{this.context.t('secondsShorthand')}</span> + </div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.utils.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.utils.js new file mode 100644 index 000000000..cf43e0acb --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.utils.js @@ -0,0 +1,11 @@ +function getTimeBreakdown (milliseconds) { + return { + hours: Math.floor(milliseconds / 3600000), + minutes: Math.floor((milliseconds % 3600000) / 60000), + seconds: Math.floor((milliseconds % 60000) / 1000), + } +} + +module.exports = { + getTimeBreakdown, +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js new file mode 100644 index 000000000..5f3925fa5 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js @@ -0,0 +1,35 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import Loading from '../../../../ui/loading-screen' +import GasPriceButtonGroup from '../../gas-price-button-group' + +export default class BasicTabContent extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + gasPriceButtonGroupProps: PropTypes.object, + } + + render () { + const { t } = this.context + const { gasPriceButtonGroupProps } = this.props + + return ( + <div className="basic-tab-content"> + <div className="basic-tab-content__title">{ t('estimatedProcessingTimes') }</div> + <div className="basic-tab-content__blurb">{ t('selectAHigherGasFee') }</div> + {!gasPriceButtonGroupProps.loading + ? <GasPriceButtonGroup + className="gas-price-button-group--alt" + showCheck={true} + {...gasPriceButtonGroupProps} + /> + : <Loading /> + } + <div className="basic-tab-content__footer-blurb">{ t('acceleratingATransaction') }</div> + </div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/index.js b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/index.js new file mode 100644 index 000000000..078d50fce --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/index.js @@ -0,0 +1 @@ +export { default } from './basic-tab-content.component' diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/index.scss b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/index.scss new file mode 100644 index 000000000..e34e4e328 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/index.scss @@ -0,0 +1,28 @@ +.basic-tab-content { + display: flex; + flex-direction: column; + align-items: flex-start; + padding-left: 21px; + height: 324px; + background: #F5F7F8; + border-bottom: 1px solid #d2d8dd; + + &__title { + margin-top: 19px; + font-size: 16px; + color: $black; + } + + &__blurb { + font-size: 12px; + color: $black; + margin-top: 5px; + margin-bottom: 15px; + } + + &__footer-blurb { + font-size: 12px; + color: #979797; + margin-top: 15px; + } +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js new file mode 100644 index 000000000..0989ac677 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js @@ -0,0 +1,82 @@ +import React from 'react' +import assert from 'assert' +import shallow from '../../../../../../../lib/shallow-with-context' +import BasicTabContent from '../basic-tab-content.component' + +import GasPriceButtonGroup from '../../../gas-price-button-group' +import Loading from '../../../../../ui/loading-screen' + +const mockGasPriceButtonGroupProps = { + buttonDataLoading: false, + className: 'gas-price-button-group', + gasButtonInfo: [ + { + feeInPrimaryCurrency: '$0.52', + feeInSecondaryCurrency: '0.0048 ETH', + timeEstimate: '~ 1 min 0 sec', + priceInHexWei: '0xa1b2c3f', + }, + { + feeInPrimaryCurrency: '$0.39', + feeInSecondaryCurrency: '0.004 ETH', + timeEstimate: '~ 1 min 30 sec', + priceInHexWei: '0xa1b2c39', + }, + { + feeInPrimaryCurrency: '$0.30', + feeInSecondaryCurrency: '0.00354 ETH', + timeEstimate: '~ 2 min 1 sec', + priceInHexWei: '0xa1b2c30', + }, + ], + handleGasPriceSelection: newPrice => console.log('NewPrice: ', newPrice), + noButtonActiveByDefault: true, + showCheck: true, +} + +describe('BasicTabContent Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<BasicTabContent + gasPriceButtonGroupProps={mockGasPriceButtonGroupProps} + />) + }) + + describe('render', () => { + it('should have a title', () => { + assert(wrapper.find('.basic-tab-content').childAt(0).hasClass('basic-tab-content__title')) + }) + + it('should render a GasPriceButtonGroup compenent', () => { + assert.equal(wrapper.find(GasPriceButtonGroup).length, 1) + }) + + it('should pass correct props to GasPriceButtonGroup', () => { + const { + buttonDataLoading, + className, + gasButtonInfo, + handleGasPriceSelection, + noButtonActiveByDefault, + showCheck, + } = wrapper.find(GasPriceButtonGroup).props() + assert.equal(wrapper.find(GasPriceButtonGroup).length, 1) + assert.equal(buttonDataLoading, mockGasPriceButtonGroupProps.buttonDataLoading) + assert.equal(className, mockGasPriceButtonGroupProps.className) + assert.equal(noButtonActiveByDefault, mockGasPriceButtonGroupProps.noButtonActiveByDefault) + assert.equal(showCheck, mockGasPriceButtonGroupProps.showCheck) + assert.deepEqual(gasButtonInfo, mockGasPriceButtonGroupProps.gasButtonInfo) + assert.equal(JSON.stringify(handleGasPriceSelection), JSON.stringify(mockGasPriceButtonGroupProps.handleGasPriceSelection)) + }) + + it('should render a loading component instead of the GasPriceButtonGroup if gasPriceButtonGroupProps.loading is true', () => { + wrapper.setProps({ + gasPriceButtonGroupProps: { ...mockGasPriceButtonGroupProps, loading: true }, + }) + + assert.equal(wrapper.find(GasPriceButtonGroup).length, 0) + assert.equal(wrapper.find(Loading).length, 1) + }) + }) +}) diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js new file mode 100644 index 000000000..d242f59f5 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js @@ -0,0 +1,186 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import PageContainer from '../../../ui/page-container' +import { Tabs, Tab } from '../../../ui/tabs' +import AdvancedTabContent from './advanced-tab-content' +import BasicTabContent from './basic-tab-content' + +export default class GasModalPageContainer extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + hideModal: PropTypes.func, + hideBasic: PropTypes.bool, + updateCustomGasPrice: PropTypes.func, + updateCustomGasLimit: PropTypes.func, + customGasPrice: PropTypes.number, + customGasLimit: PropTypes.number, + fetchBasicGasAndTimeEstimates: PropTypes.func, + fetchGasEstimates: PropTypes.func, + gasPriceButtonGroupProps: PropTypes.object, + infoRowProps: PropTypes.shape({ + originalTotalFiat: PropTypes.string, + originalTotalEth: PropTypes.string, + newTotalFiat: PropTypes.string, + newTotalEth: PropTypes.string, + }), + onSubmit: PropTypes.func, + customModalGasPriceInHex: PropTypes.string, + customModalGasLimitInHex: PropTypes.string, + cancelAndClose: PropTypes.func, + transactionFee: PropTypes.string, + blockTime: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + customPriceIsSafe: PropTypes.bool, + isSpeedUp: PropTypes.bool, + disableSave: PropTypes.bool, + } + + state = {} + + componentDidMount () { + const promise = this.props.hideBasic + ? Promise.resolve(this.props.blockTime) + : this.props.fetchBasicGasAndTimeEstimates() + .then(basicEstimates => basicEstimates.blockTime) + + promise + .then(blockTime => { + this.props.fetchGasEstimates(blockTime) + }) + } + + renderBasicTabContent (gasPriceButtonGroupProps) { + return ( + <BasicTabContent + gasPriceButtonGroupProps={gasPriceButtonGroupProps} + /> + ) + } + + renderAdvancedTabContent ({ + convertThenUpdateCustomGasPrice, + convertThenUpdateCustomGasLimit, + customGasPrice, + customGasLimit, + newTotalFiat, + gasChartProps, + currentTimeEstimate, + insufficientBalance, + gasEstimatesLoading, + customPriceIsSafe, + isSpeedUp, + transactionFee, + }) { + return ( + <AdvancedTabContent + updateCustomGasPrice={convertThenUpdateCustomGasPrice} + updateCustomGasLimit={convertThenUpdateCustomGasLimit} + customGasPrice={customGasPrice} + customGasLimit={customGasLimit} + timeRemaining={currentTimeEstimate} + transactionFee={transactionFee} + totalFee={newTotalFiat} + gasChartProps={gasChartProps} + insufficientBalance={insufficientBalance} + gasEstimatesLoading={gasEstimatesLoading} + customPriceIsSafe={customPriceIsSafe} + isSpeedUp={isSpeedUp} + /> + ) + } + + renderInfoRows (newTotalFiat, newTotalEth, sendAmount, transactionFee) { + return ( + <div className="gas-modal-content__info-row-wrapper"> + <div className="gas-modal-content__info-row"> + <div className="gas-modal-content__info-row__send-info"> + <span className="gas-modal-content__info-row__send-info__label">{this.context.t('sendAmount')}</span> + <span className="gas-modal-content__info-row__send-info__value">{sendAmount}</span> + </div> + <div className="gas-modal-content__info-row__transaction-info"> + <span className={'gas-modal-content__info-row__transaction-info__label'}>{this.context.t('transactionFee')}</span> + <span className="gas-modal-content__info-row__transaction-info__value">{transactionFee}</span> + </div> + <div className="gas-modal-content__info-row__total-info"> + <span className="gas-modal-content__info-row__total-info__label">{this.context.t('newTotal')}</span> + <span className="gas-modal-content__info-row__total-info__value">{newTotalEth}</span> + </div> + <div className="gas-modal-content__info-row__fiat-total-info"> + <span className="gas-modal-content__info-row__fiat-total-info__value">{newTotalFiat}</span> + </div> + </div> + </div> + ) + } + + renderTabs ({ + originalTotalFiat, + originalTotalEth, + newTotalFiat, + newTotalEth, + sendAmount, + transactionFee, + }, + { + gasPriceButtonGroupProps, + hideBasic, + ...advancedTabProps + }) { + let tabsToRender = [ + { name: 'basic', content: this.renderBasicTabContent(gasPriceButtonGroupProps) }, + { name: 'advanced', content: this.renderAdvancedTabContent({ transactionFee, ...advancedTabProps }) }, + ] + + if (hideBasic) { + tabsToRender = tabsToRender.slice(1) + } + + return ( + <Tabs> + {tabsToRender.map(({ name, content }, i) => <Tab name={this.context.t(name)} key={`gas-modal-tab-${i}`}> + <div className="gas-modal-content"> + { content } + { this.renderInfoRows(newTotalFiat, newTotalEth, sendAmount, transactionFee) } + </div> + </Tab> + )} + </Tabs> + ) + } + + render () { + const { + cancelAndClose, + infoRowProps, + onSubmit, + customModalGasPriceInHex, + customModalGasLimitInHex, + disableSave, + ...tabProps + } = this.props + + return ( + <div className="gas-modal-page-container"> + <PageContainer + title={this.context.t('customGas')} + subtitle={this.context.t('customGasSubTitle')} + tabsComponent={this.renderTabs(infoRowProps, tabProps)} + disabled={disableSave} + onCancel={() => cancelAndClose()} + onClose={() => cancelAndClose()} + onSubmit={() => { + onSubmit(customModalGasLimitInHex, customModalGasPriceInHex) + }} + submitText={this.context.t('save')} + headerCloseText={this.context.t('close')} + hideCancel={true} + /> + </div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js new file mode 100644 index 000000000..d541056f4 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js @@ -0,0 +1,295 @@ +import { connect } from 'react-redux' +import { pipe, partialRight } from 'ramda' +import GasModalPageContainer from './gas-modal-page-container.component' +import { + hideModal, + setGasLimit, + setGasPrice, + createSpeedUpTransaction, + hideSidebar, +} from '../../../../store/actions' +import { + setCustomGasPrice, + setCustomGasLimit, + resetCustomData, + setCustomTimeEstimate, + fetchGasEstimates, + fetchBasicGasAndTimeEstimates, +} from '../../../../ducks/gas/gas.duck' +import { + hideGasButtonGroup, +} from '../../../../ducks/send/send.duck' +import { + updateGasAndCalculate, +} from '../../../../ducks/confirm-transaction/confirm-transaction.duck' +import { + conversionRateSelector as getConversionRate, + getCurrentCurrency, + getCurrentEthBalance, + getIsMainnet, + getSelectedToken, + isEthereumNetwork, + preferencesSelector, +} from '../../../../selectors/selectors.js' +import { + formatTimeEstimate, + getFastPriceEstimateInHexWEI, + getBasicGasEstimateLoadingStatus, + getGasEstimatesLoadingStatus, + getCustomGasLimit, + getCustomGasPrice, + getDefaultActiveButtonIndex, + getEstimatedGasPrices, + getEstimatedGasTimes, + getRenderableBasicEstimateData, + getBasicGasEstimateBlockTime, + isCustomPriceSafe, +} from '../../../../selectors/custom-gas' +import { + submittedPendingTransactionsSelector, +} from '../../../../selectors/transactions' +import { + formatCurrency, +} from '../../../../helpers/utils/confirm-tx.util' +import { + addHexWEIsToDec, + decEthToConvertedCurrency as ethTotalToConvertedCurrency, + decGWEIToHexWEI, + hexWEIToDecGWEI, +} from '../../../../helpers/utils/conversions.util' +import { + formatETHFee, +} from '../../../../helpers/utils/formatters' +import { + calcGasTotal, + isBalanceSufficient, +} from '../../send/send.utils' +import { addHexPrefix } from 'ethereumjs-util' +import { getAdjacentGasPrices, extrapolateY } from '../gas-price-chart/gas-price-chart.utils' + +const mapStateToProps = (state, ownProps) => { + const { transaction = {} } = ownProps + const buttonDataLoading = getBasicGasEstimateLoadingStatus(state) + const gasEstimatesLoading = getGasEstimatesLoadingStatus(state) + + const { gasPrice: currentGasPrice, gas: currentGasLimit, value } = getTxParams(state, transaction.id) + const customModalGasPriceInHex = getCustomGasPrice(state) || currentGasPrice + const customModalGasLimitInHex = getCustomGasLimit(state) || currentGasLimit + const gasTotal = calcGasTotal(customModalGasLimitInHex, customModalGasPriceInHex) + + const customGasTotal = calcGasTotal(customModalGasLimitInHex, customModalGasPriceInHex) + + const gasButtonInfo = getRenderableBasicEstimateData(state, customModalGasLimitInHex) + + const currentCurrency = getCurrentCurrency(state) + const conversionRate = getConversionRate(state) + + const newTotalFiat = addHexWEIsToRenderableFiat(value, customGasTotal, currentCurrency, conversionRate) + + const hideBasic = state.appState.modal.modalState.props.hideBasic + + const customGasPrice = calcCustomGasPrice(customModalGasPriceInHex) + + const gasPrices = getEstimatedGasPrices(state) + const estimatedTimes = getEstimatedGasTimes(state) + const balance = getCurrentEthBalance(state) + + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const showFiat = Boolean(isMainnet || showFiatInTestnets) + + const insufficientBalance = !isBalanceSufficient({ + amount: value, + gasTotal, + balance, + conversionRate, + }) + + return { + hideBasic, + isConfirm: isConfirm(state), + customModalGasPriceInHex, + customModalGasLimitInHex, + customGasPrice, + customGasLimit: calcCustomGasLimit(customModalGasLimitInHex), + newTotalFiat, + currentTimeEstimate: getRenderableTimeEstimate(customGasPrice, gasPrices, estimatedTimes), + blockTime: getBasicGasEstimateBlockTime(state), + customPriceIsSafe: isCustomPriceSafe(state), + gasPriceButtonGroupProps: { + buttonDataLoading, + defaultActiveButtonIndex: getDefaultActiveButtonIndex(gasButtonInfo, customModalGasPriceInHex), + gasButtonInfo, + }, + gasChartProps: { + currentPrice: customGasPrice, + gasPrices, + estimatedTimes, + gasPricesMax: gasPrices[gasPrices.length - 1], + estimatedTimesMax: estimatedTimes[0], + }, + infoRowProps: { + originalTotalFiat: addHexWEIsToRenderableFiat(value, gasTotal, currentCurrency, conversionRate), + originalTotalEth: addHexWEIsToRenderableEth(value, gasTotal), + newTotalFiat: showFiat ? newTotalFiat : '', + newTotalEth: addHexWEIsToRenderableEth(value, customGasTotal), + transactionFee: addHexWEIsToRenderableEth('0x0', customGasTotal), + sendAmount: addHexWEIsToRenderableEth(value, '0x0'), + }, + isSpeedUp: transaction.status === 'submitted', + txId: transaction.id, + insufficientBalance, + gasEstimatesLoading, + isMainnet, + isEthereumNetwork: isEthereumNetwork(state), + } +} + +const mapDispatchToProps = dispatch => { + const updateCustomGasPrice = newPrice => dispatch(setCustomGasPrice(addHexPrefix(newPrice))) + + return { + cancelAndClose: () => { + dispatch(resetCustomData()) + dispatch(hideModal()) + }, + hideModal: () => dispatch(hideModal()), + updateCustomGasPrice, + convertThenUpdateCustomGasPrice: newPrice => updateCustomGasPrice(decGWEIToHexWEI(newPrice)), + convertThenUpdateCustomGasLimit: newLimit => dispatch(setCustomGasLimit(addHexPrefix(newLimit.toString(16)))), + setGasData: (newLimit, newPrice) => { + dispatch(setGasLimit(newLimit)) + dispatch(setGasPrice(newPrice)) + }, + updateConfirmTxGasAndCalculate: (gasLimit, gasPrice) => { + updateCustomGasPrice(gasPrice) + dispatch(setCustomGasLimit(addHexPrefix(gasLimit.toString(16)))) + return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) + }, + createSpeedUpTransaction: (txId, gasPrice) => { + return dispatch(createSpeedUpTransaction(txId, gasPrice)) + }, + hideGasButtonGroup: () => dispatch(hideGasButtonGroup()), + setCustomTimeEstimate: (timeEstimateInSeconds) => dispatch(setCustomTimeEstimate(timeEstimateInSeconds)), + hideSidebar: () => dispatch(hideSidebar()), + fetchGasEstimates: (blockTime) => dispatch(fetchGasEstimates(blockTime)), + fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { gasPriceButtonGroupProps, isConfirm, txId, isSpeedUp, insufficientBalance, customGasPrice } = stateProps + const { + updateCustomGasPrice: dispatchUpdateCustomGasPrice, + hideGasButtonGroup: dispatchHideGasButtonGroup, + setGasData: dispatchSetGasData, + updateConfirmTxGasAndCalculate: dispatchUpdateConfirmTxGasAndCalculate, + createSpeedUpTransaction: dispatchCreateSpeedUpTransaction, + hideSidebar: dispatchHideSidebar, + cancelAndClose: dispatchCancelAndClose, + hideModal: dispatchHideModal, + ...otherDispatchProps + } = dispatchProps + + return { + ...stateProps, + ...otherDispatchProps, + ...ownProps, + onSubmit: (gasLimit, gasPrice) => { + if (isConfirm) { + dispatchUpdateConfirmTxGasAndCalculate(gasLimit, gasPrice) + dispatchHideModal() + } else if (isSpeedUp) { + dispatchCreateSpeedUpTransaction(txId, gasPrice) + dispatchHideSidebar() + dispatchCancelAndClose() + } else { + dispatchSetGasData(gasLimit, gasPrice) + dispatchHideGasButtonGroup() + dispatchCancelAndClose() + } + }, + gasPriceButtonGroupProps: { + ...gasPriceButtonGroupProps, + handleGasPriceSelection: dispatchUpdateCustomGasPrice, + }, + cancelAndClose: () => { + dispatchCancelAndClose() + if (isSpeedUp) { + dispatchHideSidebar() + } + }, + disableSave: insufficientBalance || (isSpeedUp && customGasPrice === 0), + } +} + +export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(GasModalPageContainer) + +function isConfirm (state) { + return Boolean(Object.keys(state.confirmTransaction.txData).length) +} + +function calcCustomGasPrice (customGasPriceInHex) { + return Number(hexWEIToDecGWEI(customGasPriceInHex)) +} + +function calcCustomGasLimit (customGasLimitInHex) { + return parseInt(customGasLimitInHex, 16) +} + +function getTxParams (state, transactionId) { + const { confirmTransaction: { txData }, metamask: { send } } = state + const pendingTransactions = submittedPendingTransactionsSelector(state) + const pendingTransaction = pendingTransactions.find(({ id }) => id === transactionId) + const { txParams: pendingTxParams } = pendingTransaction || {} + return txData.txParams || pendingTxParams || { + from: send.from, + gas: send.gasLimit || '0x5208', + gasPrice: send.gasPrice || getFastPriceEstimateInHexWEI(state, true), + to: send.to, + value: getSelectedToken(state) ? '0x0' : send.amount, + } +} + +function addHexWEIsToRenderableEth (aHexWEI, bHexWEI) { + return pipe( + addHexWEIsToDec, + formatETHFee + )(aHexWEI, bHexWEI) +} + +function addHexWEIsToRenderableFiat (aHexWEI, bHexWEI, convertedCurrency, conversionRate) { + return pipe( + addHexWEIsToDec, + partialRight(ethTotalToConvertedCurrency, [convertedCurrency, conversionRate]), + partialRight(formatCurrency, [convertedCurrency]), + )(aHexWEI, bHexWEI) +} + +function getRenderableTimeEstimate (currentGasPrice, gasPrices, estimatedTimes) { + const minGasPrice = gasPrices[0] + const maxGasPrice = gasPrices[gasPrices.length - 1] + let priceForEstimation = currentGasPrice + if (currentGasPrice < minGasPrice) { + priceForEstimation = minGasPrice + } else if (currentGasPrice > maxGasPrice) { + priceForEstimation = maxGasPrice + } + + const { + closestLowerValueIndex, + closestHigherValueIndex, + closestHigherValue, + closestLowerValue, + } = getAdjacentGasPrices({ gasPrices, priceToPosition: priceForEstimation }) + + const newTimeEstimate = extrapolateY({ + higherY: estimatedTimes[closestHigherValueIndex], + lowerY: estimatedTimes[closestLowerValueIndex], + higherX: closestHigherValue, + lowerX: closestLowerValue, + xForExtrapolation: priceForEstimation, + }) + + return formatTimeEstimate(newTimeEstimate, currentGasPrice > maxGasPrice, currentGasPrice < minGasPrice) +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/index.js b/ui/app/components/app/gas-customization/gas-modal-page-container/index.js new file mode 100644 index 000000000..ec0ebad22 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/index.js @@ -0,0 +1 @@ +export { default } from './gas-modal-page-container.container' diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/index.scss b/ui/app/components/app/gas-customization/gas-modal-page-container/index.scss new file mode 100644 index 000000000..b9e0f59c4 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/index.scss @@ -0,0 +1,146 @@ +@import './advanced-tab-content/index'; +@import './basic-tab-content/index'; + +.gas-modal-page-container { + .page-container { + max-width: 391px; + min-height: 585px; + overflow-y: initial; + + @media screen and (max-width: $break-small) { + &__content { + display: flex; + overflow-y: initial; + } + } + + &__header { + padding: 0px; + padding-top: 16px; + + &--no-padding-bottom { + padding-bottom: 0; + } + } + + &__footer { + header { + padding-top: 12px; + padding-bottom: 12px; + } + } + + &__header-close-text { + font-size: 14px; + color: #4EADE7; + position: absolute; + top: 16px; + right: 16px; + cursor: pointer; + overflow: hidden; + } + + &__title { + color: $black; + font-size: 16px; + font-weight: 500; + line-height: 16px; + display: flex; + justify-content: center; + align-items: flex-start; + margin-right: 0; + } + + &__subtitle { + display: none; + } + + &__tabs { + margin-top: 0px; + } + + &__tab { + width: 100%; + font-size: 14px; + + &:last-of-type { + margin-right: 0; + } + + &--selected { + color: $curious-blue; + border-bottom: 2px solid $curious-blue; + } + } + } +} + +.gas-modal-content { + @media screen and (max-width: $break-small) { + width: 100%; + } + + &__basic-tab { + height: 219px; + } + + + &__info-row, &__info-row--fade { + width: 100%; + background: $polar; + padding: 15px 21px; + display: flex; + flex-flow: column; + color: $scorpion; + font-size: 12px; + + @media screen and (max-width: $break-small) { + padding: 4px 21px; + } + + &__send-info, &__transaction-info, &__total-info, &__fiat-total-info { + display: flex; + flex-flow: row; + justify-content: space-between; + } + + &__fiat-total-info { + justify-content: flex-end; + } + + &__total-info { + &__label { + font-size: 16px; + + @media screen and (max-width: $break-small) { + font-size: 14px; + } + } + + &__value { + font-size: 16px; + font-weight: bold; + + @media screen and (max-width: $break-small) { + font-size: 14px; + } + } + } + + &__transaction-info, &__send-info { + &__label { + font-size: 12px; + } + + &__value { + font-size: 14px; + } + } + } + + &__info-row--fade { + background: white; + color: $dusty-gray; + border-top: 1px solid $mischka; + } +} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js new file mode 100644 index 000000000..7557eefe5 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js @@ -0,0 +1,274 @@ +import React from 'react' +import assert from 'assert' +import shallow from '../../../../../../lib/shallow-with-context' +import sinon from 'sinon' +import GasModalPageContainer from '../gas-modal-page-container.component.js' +import timeout from '../../../../../../lib/test-timeout' + +import PageContainer from '../../../../ui/page-container' + +import { Tab } from '../../../../ui/tabs' + +const mockBasicGasEstimates = { + blockTime: 'mockBlockTime', +} + +const propsMethodSpies = { + cancelAndClose: sinon.spy(), + onSubmit: sinon.spy(), + fetchBasicGasAndTimeEstimates: sinon.stub().returns(Promise.resolve(mockBasicGasEstimates)), + fetchGasEstimates: sinon.spy(), +} + +const mockGasPriceButtonGroupProps = { + buttonDataLoading: false, + className: 'gas-price-button-group', + gasButtonInfo: [ + { + feeInPrimaryCurrency: '$0.52', + feeInSecondaryCurrency: '0.0048 ETH', + timeEstimate: '~ 1 min 0 sec', + priceInHexWei: '0xa1b2c3f', + }, + { + feeInPrimaryCurrency: '$0.39', + feeInSecondaryCurrency: '0.004 ETH', + timeEstimate: '~ 1 min 30 sec', + priceInHexWei: '0xa1b2c39', + }, + { + feeInPrimaryCurrency: '$0.30', + feeInSecondaryCurrency: '0.00354 ETH', + timeEstimate: '~ 2 min 1 sec', + priceInHexWei: '0xa1b2c30', + }, + ], + handleGasPriceSelection: 'mockSelectionFunction', + noButtonActiveByDefault: true, + showCheck: true, + newTotalFiat: 'mockNewTotalFiat', + newTotalEth: 'mockNewTotalEth', +} +const mockInfoRowProps = { + originalTotalFiat: 'mockOriginalTotalFiat', + originalTotalEth: 'mockOriginalTotalEth', + newTotalFiat: 'mockNewTotalFiat', + newTotalEth: 'mockNewTotalEth', + sendAmount: 'mockSendAmount', + transactionFee: 'mockTransactionFee', +} + +const GP = GasModalPageContainer.prototype +describe('GasModalPageContainer Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<GasModalPageContainer + cancelAndClose={propsMethodSpies.cancelAndClose} + onSubmit={propsMethodSpies.onSubmit} + fetchBasicGasAndTimeEstimates={propsMethodSpies.fetchBasicGasAndTimeEstimates} + fetchGasEstimates={propsMethodSpies.fetchGasEstimates} + updateCustomGasPrice={() => 'mockupdateCustomGasPrice'} + updateCustomGasLimit={() => 'mockupdateCustomGasLimit'} + customGasPrice={21} + customGasLimit={54321} + gasPriceButtonGroupProps={mockGasPriceButtonGroupProps} + infoRowProps={mockInfoRowProps} + currentTimeEstimate={'1 min 31 sec'} + customGasPriceInHex={'mockCustomGasPriceInHex'} + customGasLimitInHex={'mockCustomGasLimitInHex'} + insufficientBalance={false} + disableSave={false} + />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }) + }) + + afterEach(() => { + propsMethodSpies.cancelAndClose.resetHistory() + }) + + describe('componentDidMount', () => { + it('should call props.fetchBasicGasAndTimeEstimates', () => { + propsMethodSpies.fetchBasicGasAndTimeEstimates.resetHistory() + assert.equal(propsMethodSpies.fetchBasicGasAndTimeEstimates.callCount, 0) + wrapper.instance().componentDidMount() + assert.equal(propsMethodSpies.fetchBasicGasAndTimeEstimates.callCount, 1) + }) + + it('should call props.fetchGasEstimates with the block time returned by fetchBasicGasAndTimeEstimates', async () => { + propsMethodSpies.fetchGasEstimates.resetHistory() + assert.equal(propsMethodSpies.fetchGasEstimates.callCount, 0) + wrapper.instance().componentDidMount() + await timeout(250) + assert.equal(propsMethodSpies.fetchGasEstimates.callCount, 1) + assert.equal(propsMethodSpies.fetchGasEstimates.getCall(0).args[0], 'mockBlockTime') + }) + }) + + describe('render', () => { + it('should render a PageContainer compenent', () => { + assert.equal(wrapper.find(PageContainer).length, 1) + }) + + it('should pass correct props to PageContainer', () => { + const { + title, + subtitle, + disabled, + } = wrapper.find(PageContainer).props() + assert.equal(title, 'customGas') + assert.equal(subtitle, 'customGasSubTitle') + assert.equal(disabled, false) + }) + + it('should pass the correct onCancel and onClose methods to PageContainer', () => { + const { + onCancel, + onClose, + } = wrapper.find(PageContainer).props() + assert.equal(propsMethodSpies.cancelAndClose.callCount, 0) + onCancel() + assert.equal(propsMethodSpies.cancelAndClose.callCount, 1) + onClose() + assert.equal(propsMethodSpies.cancelAndClose.callCount, 2) + }) + + it('should pass the correct renderTabs property to PageContainer', () => { + sinon.stub(GP, 'renderTabs').returns('mockTabs') + const renderTabsWrapperTester = shallow(<GasModalPageContainer + fetchBasicGasAndTimeEstimates={propsMethodSpies.fetchBasicGasAndTimeEstimates} + fetchGasEstimates={propsMethodSpies.fetchGasEstimates} + />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }) + const { tabsComponent } = renderTabsWrapperTester.find(PageContainer).props() + assert.equal(tabsComponent, 'mockTabs') + GasModalPageContainer.prototype.renderTabs.restore() + }) + }) + + describe('renderTabs', () => { + beforeEach(() => { + sinon.spy(GP, 'renderBasicTabContent') + sinon.spy(GP, 'renderAdvancedTabContent') + sinon.spy(GP, 'renderInfoRows') + }) + + afterEach(() => { + GP.renderBasicTabContent.restore() + GP.renderAdvancedTabContent.restore() + GP.renderInfoRows.restore() + }) + + it('should render a Tabs component with "Basic" and "Advanced" tabs', () => { + const renderTabsResult = wrapper.instance().renderTabs(mockInfoRowProps, { + gasPriceButtonGroupProps: mockGasPriceButtonGroupProps, + otherProps: 'mockAdvancedTabProps', + }) + const renderedTabs = shallow(renderTabsResult) + assert.equal(renderedTabs.props().className, 'tabs') + + const tabs = renderedTabs.find(Tab) + assert.equal(tabs.length, 2) + + assert.equal(tabs.at(0).props().name, 'basic') + assert.equal(tabs.at(1).props().name, 'advanced') + + assert.equal(tabs.at(0).childAt(0).props().className, 'gas-modal-content') + assert.equal(tabs.at(1).childAt(0).props().className, 'gas-modal-content') + }) + + it('should call renderBasicTabContent and renderAdvancedTabContent with the expected props', () => { + assert.equal(GP.renderBasicTabContent.callCount, 0) + assert.equal(GP.renderAdvancedTabContent.callCount, 0) + + wrapper.instance().renderTabs(mockInfoRowProps, { gasPriceButtonGroupProps: mockGasPriceButtonGroupProps, otherProps: 'mockAdvancedTabProps' }) + + assert.equal(GP.renderBasicTabContent.callCount, 1) + assert.equal(GP.renderAdvancedTabContent.callCount, 1) + + assert.deepEqual(GP.renderBasicTabContent.getCall(0).args[0], mockGasPriceButtonGroupProps) + assert.deepEqual(GP.renderAdvancedTabContent.getCall(0).args[0], { transactionFee: 'mockTransactionFee', otherProps: 'mockAdvancedTabProps' }) + }) + + it('should call renderInfoRows with the expected props', () => { + assert.equal(GP.renderInfoRows.callCount, 0) + + wrapper.instance().renderTabs(mockInfoRowProps, { gasPriceButtonGroupProps: mockGasPriceButtonGroupProps, otherProps: 'mockAdvancedTabProps' }) + + assert.equal(GP.renderInfoRows.callCount, 2) + + assert.deepEqual(GP.renderInfoRows.getCall(0).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee']) + assert.deepEqual(GP.renderInfoRows.getCall(1).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee']) + }) + + it('should not render the basic tab if hideBasic is true', () => { + const renderTabsResult = wrapper.instance().renderTabs(mockInfoRowProps, { + gasPriceButtonGroupProps: mockGasPriceButtonGroupProps, + otherProps: 'mockAdvancedTabProps', + hideBasic: true, + }) + + const renderedTabs = shallow(renderTabsResult) + const tabs = renderedTabs.find(Tab) + assert.equal(tabs.length, 1) + assert.equal(tabs.at(0).props().name, 'advanced') + }) + }) + + describe('renderBasicTabContent', () => { + it('should render', () => { + const renderBasicTabContentResult = wrapper.instance().renderBasicTabContent(mockGasPriceButtonGroupProps) + + assert.deepEqual( + renderBasicTabContentResult.props.gasPriceButtonGroupProps, + mockGasPriceButtonGroupProps + ) + }) + }) + + describe('renderAdvancedTabContent', () => { + it('should render with the correct props', () => { + const renderAdvancedTabContentResult = wrapper.instance().renderAdvancedTabContent({ + convertThenUpdateCustomGasPrice: () => 'mockConvertThenUpdateCustomGasPrice', + convertThenUpdateCustomGasLimit: () => 'mockConvertThenUpdateCustomGasLimit', + customGasPrice: 123, + customGasLimit: 456, + newTotalFiat: '$0.30', + currentTimeEstimate: '1 min 31 sec', + gasEstimatesLoading: 'mockGasEstimatesLoading', + }) + const advancedTabContentProps = renderAdvancedTabContentResult.props + assert.equal(advancedTabContentProps.updateCustomGasPrice(), 'mockConvertThenUpdateCustomGasPrice') + assert.equal(advancedTabContentProps.updateCustomGasLimit(), 'mockConvertThenUpdateCustomGasLimit') + assert.equal(advancedTabContentProps.customGasPrice, 123) + assert.equal(advancedTabContentProps.customGasLimit, 456) + assert.equal(advancedTabContentProps.timeRemaining, '1 min 31 sec') + assert.equal(advancedTabContentProps.totalFee, '$0.30') + assert.equal(advancedTabContentProps.gasEstimatesLoading, 'mockGasEstimatesLoading') + }) + }) + + describe('renderInfoRows', () => { + it('should render the info rows with the passed data', () => { + const baseClassName = 'gas-modal-content__info-row' + const renderedInfoRowsContainer = shallow(wrapper.instance().renderInfoRows( + 'mockNewTotalFiat', + ' mockNewTotalEth', + ' mockSendAmount', + ' mockTransactionFee' + )) + + assert(renderedInfoRowsContainer.childAt(0).hasClass(baseClassName)) + + const renderedInfoRows = renderedInfoRowsContainer.childAt(0).children() + assert.equal(renderedInfoRows.length, 4) + assert(renderedInfoRows.at(0).hasClass(`${baseClassName}__send-info`)) + assert(renderedInfoRows.at(1).hasClass(`${baseClassName}__transaction-info`)) + assert(renderedInfoRows.at(2).hasClass(`${baseClassName}__total-info`)) + assert(renderedInfoRows.at(3).hasClass(`${baseClassName}__fiat-total-info`)) + + assert.equal(renderedInfoRows.at(0).text(), 'sendAmount mockSendAmount') + assert.equal(renderedInfoRows.at(1).text(), 'transactionFee mockTransactionFee') + assert.equal(renderedInfoRows.at(2).text(), 'newTotal mockNewTotalEth') + assert.equal(renderedInfoRows.at(3).text(), 'mockNewTotalFiat') + }) + }) +}) diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js new file mode 100644 index 000000000..b9eb67d2b --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js @@ -0,0 +1,431 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps +let mergeProps + +const actionSpies = { + hideModal: sinon.spy(), + setGasLimit: sinon.spy(), + setGasPrice: sinon.spy(), +} + +const gasActionSpies = { + setCustomGasPrice: sinon.spy(), + setCustomGasLimit: sinon.spy(), + resetCustomData: sinon.spy(), +} + +const confirmTransactionActionSpies = { + updateGasAndCalculate: sinon.spy(), +} + +const sendActionSpies = { + hideGasButtonGroup: sinon.spy(), +} + +proxyquire('../gas-modal-page-container.container.js', { + 'react-redux': { + connect: (ms, md, mp) => { + mapStateToProps = ms + mapDispatchToProps = md + mergeProps = mp + return () => ({}) + }, + }, + '../../../../selectors/custom-gas': { + getBasicGasEstimateLoadingStatus: (s) => `mockBasicGasEstimateLoadingStatus:${Object.keys(s).length}`, + getRenderableBasicEstimateData: (s) => `mockRenderableBasicEstimateData:${Object.keys(s).length}`, + getDefaultActiveButtonIndex: (a, b) => a + b, + }, + '../../../../store/actions': actionSpies, + '../../../../ducks/gas/gas.duck': gasActionSpies, + '../../../../ducks/confirm-transaction/confirm-transaction.duck': confirmTransactionActionSpies, + '../../../../ducks/send/send.duck': sendActionSpies, + '../../../../selectors/selectors.js': { + getCurrentEthBalance: (state) => state.metamask.balance || '0x0', + }, +}) + +describe('gas-modal-page-container container', () => { + + describe('mapStateToProps()', () => { + it('should map the correct properties to props', () => { + const baseMockState = { + appState: { + modal: { + modalState: { + props: { + hideBasic: true, + }, + }, + }, + }, + metamask: { + send: { + gasLimit: '16', + gasPrice: '32', + amount: '64', + }, + currentCurrency: 'abc', + conversionRate: 50, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + gas: { + basicEstimates: { + blockTime: 12, + safeLow: 2, + }, + customData: { + limit: 'aaaaaaaa', + price: 'ffffffff', + }, + gasEstimatesLoading: false, + priceAndTimeEstimates: [ + { gasprice: 3, expectedTime: 31 }, + { gasprice: 4, expectedTime: 62 }, + { gasprice: 5, expectedTime: 93 }, + { gasprice: 6, expectedTime: 124 }, + ], + }, + confirmTransaction: { + txData: { + txParams: { + gas: '0x1600000', + gasPrice: '0x3200000', + value: '0x640000000000000', + }, + }, + }, + } + const baseExpectedResult = { + isConfirm: true, + customGasPrice: 4.294967295, + customGasLimit: 2863311530, + currentTimeEstimate: '~1 min 11 sec', + newTotalFiat: '637.41', + blockTime: 12, + customModalGasLimitInHex: 'aaaaaaaa', + customModalGasPriceInHex: 'ffffffff', + customPriceIsSafe: true, + gasChartProps: { + 'currentPrice': 4.294967295, + estimatedTimes: [31, 62, 93, 124], + estimatedTimesMax: 31, + gasPrices: [3, 4, 5, 6], + gasPricesMax: 6, + }, + gasPriceButtonGroupProps: { + buttonDataLoading: 'mockBasicGasEstimateLoadingStatus:4', + defaultActiveButtonIndex: 'mockRenderableBasicEstimateData:4ffffffff', + gasButtonInfo: 'mockRenderableBasicEstimateData:4', + }, + gasEstimatesLoading: false, + hideBasic: true, + infoRowProps: { + originalTotalFiat: '637.41', + originalTotalEth: '12.748189 ETH', + newTotalFiat: '637.41', + newTotalEth: '12.748189 ETH', + sendAmount: '0.45036 ETH', + transactionFee: '12.297829 ETH', + }, + insufficientBalance: true, + isSpeedUp: false, + txId: 34, + isEthereumNetwork: false, + isMainnet: true, + } + const baseMockOwnProps = { transaction: { id: 34 } } + const tests = [ + { mockState: baseMockState, expectedResult: baseExpectedResult, mockOwnProps: baseMockOwnProps }, + { + mockState: Object.assign({}, baseMockState, { + metamask: { ...baseMockState.metamask, balance: '0xfffffffffffffffffffff' }, + }), + expectedResult: Object.assign({}, baseExpectedResult, { insufficientBalance: false }), + mockOwnProps: baseMockOwnProps, + }, + { + mockState: baseMockState, + mockOwnProps: Object.assign({}, baseMockOwnProps, { + transaction: { id: 34, status: 'submitted' }, + }), + expectedResult: Object.assign({}, baseExpectedResult, { isSpeedUp: true }), + }, + { + mockState: Object.assign({}, baseMockState, { + metamask: { + ...baseMockState.metamask, + preferences: { + ...baseMockState.metamask.preferences, + showFiatInTestnets: false, + }, + provider: { + ...baseMockState.metamask.provider, + type: 'rinkeby', + }, + }, + }), + mockOwnProps: baseMockOwnProps, + expectedResult: { + ...baseExpectedResult, + infoRowProps: { + ...baseExpectedResult.infoRowProps, + newTotalFiat: '', + }, + isMainnet: false, + }, + }, + { + mockState: Object.assign({}, baseMockState, { + metamask: { + ...baseMockState.metamask, + preferences: { + ...baseMockState.metamask.preferences, + showFiatInTestnets: true, + }, + provider: { + ...baseMockState.metamask.provider, + type: 'rinkeby', + }, + }, + }), + mockOwnProps: baseMockOwnProps, + expectedResult: { + ...baseExpectedResult, + isMainnet: false, + }, + }, + { + mockState: Object.assign({}, baseMockState, { + metamask: { + ...baseMockState.metamask, + preferences: { + ...baseMockState.metamask.preferences, + showFiatInTestnets: true, + }, + provider: { + ...baseMockState.metamask.provider, + type: 'mainnet', + }, + }, + }), + mockOwnProps: baseMockOwnProps, + expectedResult: baseExpectedResult, + }, + ] + + let result + tests.forEach(({ mockState, mockOwnProps, expectedResult}) => { + result = mapStateToProps(mockState, mockOwnProps) + assert.deepEqual(result, expectedResult) + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + }) + + afterEach(() => { + actionSpies.hideModal.resetHistory() + gasActionSpies.setCustomGasPrice.resetHistory() + gasActionSpies.setCustomGasLimit.resetHistory() + }) + + describe('hideGasButtonGroup()', () => { + it('should dispatch a hideGasButtonGroup action', () => { + mapDispatchToPropsObject.hideGasButtonGroup() + assert(dispatchSpy.calledOnce) + assert(sendActionSpies.hideGasButtonGroup.calledOnce) + }) + }) + + describe('cancelAndClose()', () => { + it('should dispatch a hideModal action', () => { + mapDispatchToPropsObject.cancelAndClose() + assert(dispatchSpy.calledTwice) + assert(actionSpies.hideModal.calledOnce) + assert(gasActionSpies.resetCustomData.calledOnce) + }) + }) + + describe('updateCustomGasPrice()', () => { + it('should dispatch a setCustomGasPrice action with the arg passed to updateCustomGasPrice hex prefixed', () => { + mapDispatchToPropsObject.updateCustomGasPrice('ffff') + assert(dispatchSpy.calledOnce) + assert(gasActionSpies.setCustomGasPrice.calledOnce) + assert.equal(gasActionSpies.setCustomGasPrice.getCall(0).args[0], '0xffff') + }) + }) + + describe('convertThenUpdateCustomGasPrice()', () => { + it('should dispatch a setCustomGasPrice action with the arg passed to convertThenUpdateCustomGasPrice converted to WEI', () => { + mapDispatchToPropsObject.convertThenUpdateCustomGasPrice('0xffff') + assert(dispatchSpy.calledOnce) + assert(gasActionSpies.setCustomGasPrice.calledOnce) + assert.equal(gasActionSpies.setCustomGasPrice.getCall(0).args[0], '0x3b9a8e653600') + }) + }) + + + describe('convertThenUpdateCustomGasLimit()', () => { + it('should dispatch a setCustomGasLimit action with the arg passed to convertThenUpdateCustomGasLimit converted to hex', () => { + mapDispatchToPropsObject.convertThenUpdateCustomGasLimit(16) + assert(dispatchSpy.calledOnce) + assert(gasActionSpies.setCustomGasLimit.calledOnce) + assert.equal(gasActionSpies.setCustomGasLimit.getCall(0).args[0], '0x10') + }) + }) + + describe('setGasData()', () => { + it('should dispatch a setGasPrice and setGasLimit action with the correct props', () => { + mapDispatchToPropsObject.setGasData('ffff', 'aaaa') + assert(dispatchSpy.calledTwice) + assert(actionSpies.setGasPrice.calledOnce) + assert(actionSpies.setGasLimit.calledOnce) + assert.equal(actionSpies.setGasLimit.getCall(0).args[0], 'ffff') + assert.equal(actionSpies.setGasPrice.getCall(0).args[0], 'aaaa') + }) + }) + + describe('updateConfirmTxGasAndCalculate()', () => { + it('should dispatch a updateGasAndCalculate action with the correct props', () => { + mapDispatchToPropsObject.updateConfirmTxGasAndCalculate('ffff', 'aaaa') + assert.equal(dispatchSpy.callCount, 3) + assert(confirmTransactionActionSpies.updateGasAndCalculate.calledOnce) + assert.deepEqual(confirmTransactionActionSpies.updateGasAndCalculate.getCall(0).args[0], { gasLimit: 'ffff', gasPrice: 'aaaa' }) + }) + }) + + }) + + describe('mergeProps', () => { + let stateProps + let dispatchProps + let ownProps + + beforeEach(() => { + stateProps = { + gasPriceButtonGroupProps: { + someGasPriceButtonGroupProp: 'foo', + anotherGasPriceButtonGroupProp: 'bar', + }, + isConfirm: true, + someOtherStateProp: 'baz', + } + dispatchProps = { + updateCustomGasPrice: sinon.spy(), + hideGasButtonGroup: sinon.spy(), + setGasData: sinon.spy(), + updateConfirmTxGasAndCalculate: sinon.spy(), + someOtherDispatchProp: sinon.spy(), + createSpeedUpTransaction: sinon.spy(), + hideSidebar: sinon.spy(), + hideModal: sinon.spy(), + cancelAndClose: sinon.spy(), + } + ownProps = { someOwnProp: 123 } + }) + + afterEach(() => { + dispatchProps.updateCustomGasPrice.resetHistory() + dispatchProps.hideGasButtonGroup.resetHistory() + dispatchProps.setGasData.resetHistory() + dispatchProps.updateConfirmTxGasAndCalculate.resetHistory() + dispatchProps.someOtherDispatchProp.resetHistory() + dispatchProps.createSpeedUpTransaction.resetHistory() + dispatchProps.hideSidebar.resetHistory() + dispatchProps.hideModal.resetHistory() + }) + it('should return the expected props when isConfirm is true', () => { + const result = mergeProps(stateProps, dispatchProps, ownProps) + + assert.equal(result.isConfirm, true) + assert.equal(result.someOtherStateProp, 'baz') + assert.equal(result.gasPriceButtonGroupProps.someGasPriceButtonGroupProp, 'foo') + assert.equal(result.gasPriceButtonGroupProps.anotherGasPriceButtonGroupProp, 'bar') + assert.equal(result.someOwnProp, 123) + + assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0) + assert.equal(dispatchProps.setGasData.callCount, 0) + assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0) + assert.equal(dispatchProps.hideModal.callCount, 0) + + result.onSubmit() + + assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 1) + assert.equal(dispatchProps.setGasData.callCount, 0) + assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0) + assert.equal(dispatchProps.hideModal.callCount, 1) + + assert.equal(dispatchProps.updateCustomGasPrice.callCount, 0) + result.gasPriceButtonGroupProps.handleGasPriceSelection() + assert.equal(dispatchProps.updateCustomGasPrice.callCount, 1) + + assert.equal(dispatchProps.someOtherDispatchProp.callCount, 0) + result.someOtherDispatchProp() + assert.equal(dispatchProps.someOtherDispatchProp.callCount, 1) + }) + + it('should return the expected props when isConfirm is false', () => { + const result = mergeProps(Object.assign({}, stateProps, { isConfirm: false }), dispatchProps, ownProps) + + assert.equal(result.isConfirm, false) + assert.equal(result.someOtherStateProp, 'baz') + assert.equal(result.gasPriceButtonGroupProps.someGasPriceButtonGroupProp, 'foo') + assert.equal(result.gasPriceButtonGroupProps.anotherGasPriceButtonGroupProp, 'bar') + assert.equal(result.someOwnProp, 123) + + assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0) + assert.equal(dispatchProps.setGasData.callCount, 0) + assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0) + assert.equal(dispatchProps.cancelAndClose.callCount, 0) + + result.onSubmit('mockNewLimit', 'mockNewPrice') + + assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0) + assert.equal(dispatchProps.setGasData.callCount, 1) + assert.deepEqual(dispatchProps.setGasData.getCall(0).args, ['mockNewLimit', 'mockNewPrice']) + assert.equal(dispatchProps.hideGasButtonGroup.callCount, 1) + assert.equal(dispatchProps.cancelAndClose.callCount, 1) + + assert.equal(dispatchProps.updateCustomGasPrice.callCount, 0) + result.gasPriceButtonGroupProps.handleGasPriceSelection() + assert.equal(dispatchProps.updateCustomGasPrice.callCount, 1) + + assert.equal(dispatchProps.someOtherDispatchProp.callCount, 0) + result.someOtherDispatchProp() + assert.equal(dispatchProps.someOtherDispatchProp.callCount, 1) + }) + + it('should dispatch the expected actions from obSubmit when isConfirm is false and isSpeedUp is true', () => { + const result = mergeProps(Object.assign({}, stateProps, { isSpeedUp: true, isConfirm: false }), dispatchProps, ownProps) + + result.onSubmit() + + assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0) + assert.equal(dispatchProps.setGasData.callCount, 0) + assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0) + assert.equal(dispatchProps.cancelAndClose.callCount, 1) + + assert.equal(dispatchProps.createSpeedUpTransaction.callCount, 1) + assert.equal(dispatchProps.hideSidebar.callCount, 1) + }) + }) + +}) diff --git a/ui/app/components/app/gas-customization/gas-price-button-group/gas-price-button-group.component.js b/ui/app/components/app/gas-customization/gas-price-button-group/gas-price-button-group.component.js new file mode 100644 index 000000000..0456f5262 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-button-group/gas-price-button-group.component.js @@ -0,0 +1,89 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ButtonGroup from '../../../ui/button-group' +import Button from '../../../ui/button' + +const GAS_OBJECT_PROPTYPES_SHAPE = { + label: PropTypes.string, + feeInPrimaryCurrency: PropTypes.string, + feeInSecondaryCurrency: PropTypes.string, + timeEstimate: PropTypes.string, + priceInHexWei: PropTypes.string, +} + +export default class GasPriceButtonGroup extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + buttonDataLoading: PropTypes.bool, + className: PropTypes.string, + defaultActiveButtonIndex: PropTypes.number, + gasButtonInfo: PropTypes.arrayOf(PropTypes.shape(GAS_OBJECT_PROPTYPES_SHAPE)), + handleGasPriceSelection: PropTypes.func, + newActiveButtonIndex: PropTypes.number, + noButtonActiveByDefault: PropTypes.bool, + showCheck: PropTypes.bool, + } + + renderButtonContent ({ + labelKey, + feeInPrimaryCurrency, + feeInSecondaryCurrency, + timeEstimate, + }, { + className, + showCheck, + }) { + return (<div> + { labelKey && <div className={`${className}__label`}>{ this.context.t(labelKey) }</div> } + { timeEstimate && <div className={`${className}__time-estimate`}>{ timeEstimate }</div> } + { feeInPrimaryCurrency && <div className={`${className}__primary-currency`}>{ feeInPrimaryCurrency }</div> } + { feeInSecondaryCurrency && <div className={`${className}__secondary-currency`}>{ feeInSecondaryCurrency }</div> } + { showCheck && <div className="button-check-wrapper"><i className="fa fa-check fa-sm" /></div> } + </div>) + } + + renderButton ({ + priceInHexWei, + ...renderableGasInfo + }, { + buttonDataLoading, + handleGasPriceSelection, + ...buttonContentPropsAndFlags + }, index) { + return ( + <Button + onClick={() => handleGasPriceSelection(priceInHexWei)} + key={`gas-price-button-${index}`} + > + {this.renderButtonContent(renderableGasInfo, buttonContentPropsAndFlags)} + </Button> + ) + } + + render () { + const { + gasButtonInfo, + defaultActiveButtonIndex = 1, + newActiveButtonIndex, + noButtonActiveByDefault = false, + buttonDataLoading, + ...buttonPropsAndFlags + } = this.props + + return ( + !buttonDataLoading + ? <ButtonGroup + className={buttonPropsAndFlags.className} + defaultActiveButtonIndex={defaultActiveButtonIndex} + newActiveButtonIndex={newActiveButtonIndex} + noButtonActiveByDefault={noButtonActiveByDefault} + > + { gasButtonInfo.map((obj, index) => this.renderButton(obj, buttonPropsAndFlags, index)) } + </ButtonGroup> + : <div className={`${buttonPropsAndFlags.className}__loading-container`}>{ this.context.t('loading') }</div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/gas-price-button-group/index.js b/ui/app/components/app/gas-customization/gas-price-button-group/index.js new file mode 100644 index 000000000..775648330 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-button-group/index.js @@ -0,0 +1 @@ +export { default } from './gas-price-button-group.component' diff --git a/ui/app/components/app/gas-customization/gas-price-button-group/index.scss b/ui/app/components/app/gas-customization/gas-price-button-group/index.scss new file mode 100644 index 000000000..cb2f3ecf1 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-button-group/index.scss @@ -0,0 +1,238 @@ +.gas-price-button-group { + margin-top: 22px; + display: flex; + justify-content: space-evenly; + width: 100%; + padding-left: 20px; + padding-right: 20px; + + &__primary-currency { + font-size: 18px; + height: 20.5px; + margin-bottom: 7.5px; + } + + &__time-estimate { + margin-top: 5.5px; + color: $silver-chalice; + height: 15.4px; + } + + &__loading-container { + height: 130px; + } + + .button-group__button, .button-group__button--active { + height: 130px; + max-width: 108px; + font-size: 12px; + flex-direction: column; + align-items: center; + display: flex; + padding-top: 17px; + border-radius: 4px; + border: 2px solid $spindle; + background: $white; + color: $scorpion; + + div { + display: flex; + flex-direction: column; + align-items: center; + } + + i { + &:last-child { + display: none; + } + } + } + + .button-group__button--active { + border: 2px solid $curious-blue; + color: $scorpion; + + i { + &:last-child { + display: flex; + color: $curious-blue; + margin-top: 8px + } + } + } +} + +.gas-price-button-group--small { + display: flex; + justify-content: stretch; + + @media screen and (max-width: $break-small) { + max-width: 260px; + } + + &__button-fiat-price { + font-size: 13px; + } + + &__button-label { + font-size: 16px; + } + + &__label { + font-weight: 500; + } + + &__primary-currency { + font-size: 12px; + + @media screen and (max-width: 575px) { + font-size: 10px; + } + } + + &__secondary-currency { + font-size: 12px; + + @media screen and (max-width: 575px) { + font-size: 10px; + } + } + + &__loading-container { + height: 78px; + } + + .button-group__button, .button-group__button--active { + height: 78px; + background: white; + color: $scorpion; + padding-top: 9px; + padding-left: 8.5px; + + @media screen and (max-width: $break-small) { + padding-left: 4px; + } + + div { + display: flex; + flex-flow: column; + align-items: flex-start; + justify-content: flex-start; + } + + i { + &:last-child { + display: none; + } + } + } + + .button-group__button--active { + color: $white; + background: $dodger-blue; + + i { + &:last-child { + display: flex; + color: $curious-blue; + margin-top: 10px + } + } + } +} + +.gas-price-button-group--alt { + display: flex; + justify-content: stretch; + width: 95%; + + &__button-fiat-price { + font-size: 13px; + } + + &__button-label { + font-size: 16px; + } + + &__label { + font-weight: 500; + font-size: 10px; + text-transform: capitalize; + } + + &__primary-currency { + font-size: 11px; + margin-top: 3px; + } + + &__secondary-currency { + font-size: 11px; + } + + &__loading-container { + height: 78px; + } + + &__time-estimate { + font-size: 14px; + font-weight: 500; + margin-top: 4px; + color: $black; + } + + .button-group__button, .button-group__button--active { + height: 78px; + background: white; + color: #2A4055; + width: 108px; + height: 97px; + box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.151579); + border-radius: 6px; + border: none; + + div { + display: flex; + flex-flow: column;; + align-items: flex-start; + justify-content: flex-start; + position: relative; + } + + .button-check-wrapper { + display: none; + } + + &:first-child { + margin-right: 6px; + } + + &:last-child { + margin-left: 6px; + } + } + + .button-group__button--active { + background: #F7FCFF; + border: 2px solid #2C8BDC; + + .button-check-wrapper { + height: 16px; + width: 16px; + border-radius: 8px; + position: absolute; + top: -11px; + right: -10px; + background: #D5ECFA; + display: flex; + flex-flow: row; + justify-content: center; + align-items: center; + } + + i { + display: flex; + color: $curious-blue; + font-size: 12px; + } + } +} diff --git a/ui/app/components/app/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js b/ui/app/components/app/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js new file mode 100644 index 000000000..37840a8a5 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js @@ -0,0 +1,233 @@ +import React from 'react' +import assert from 'assert' +import shallow from '../../../../../../lib/shallow-with-context' +import sinon from 'sinon' +import GasPriceButtonGroup from '../gas-price-button-group.component' + +import ButtonGroup from '../../../../ui/button-group' + +const mockGasPriceButtonGroupProps = { + buttonDataLoading: false, + className: 'gas-price-button-group', + gasButtonInfo: [ + { + feeInPrimaryCurrency: '$0.52', + feeInSecondaryCurrency: '0.0048 ETH', + timeEstimate: '~ 1 min 0 sec', + priceInHexWei: '0xa1b2c3f', + }, + { + feeInPrimaryCurrency: '$0.39', + feeInSecondaryCurrency: '0.004 ETH', + timeEstimate: '~ 1 min 30 sec', + priceInHexWei: '0xa1b2c39', + }, + { + feeInPrimaryCurrency: '$0.30', + feeInSecondaryCurrency: '0.00354 ETH', + timeEstimate: '~ 2 min 1 sec', + priceInHexWei: '0xa1b2c30', + }, + ], + handleGasPriceSelection: sinon.spy(), + noButtonActiveByDefault: true, + defaultActiveButtonIndex: 2, + showCheck: true, +} + +const mockButtonPropsAndFlags = Object.assign({}, { + className: mockGasPriceButtonGroupProps.className, + handleGasPriceSelection: mockGasPriceButtonGroupProps.handleGasPriceSelection, + showCheck: mockGasPriceButtonGroupProps.showCheck, +}) + +sinon.spy(GasPriceButtonGroup.prototype, 'renderButton') +sinon.spy(GasPriceButtonGroup.prototype, 'renderButtonContent') + +describe('GasPriceButtonGroup Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<GasPriceButtonGroup + {...mockGasPriceButtonGroupProps} + />) + }) + + afterEach(() => { + GasPriceButtonGroup.prototype.renderButton.resetHistory() + GasPriceButtonGroup.prototype.renderButtonContent.resetHistory() + mockGasPriceButtonGroupProps.handleGasPriceSelection.resetHistory() + }) + + describe('render', () => { + it('should render a ButtonGroup', () => { + assert(wrapper.is(ButtonGroup)) + }) + + it('should render the correct props on the ButtonGroup', () => { + const { + className, + defaultActiveButtonIndex, + noButtonActiveByDefault, + } = wrapper.props() + assert.equal(className, 'gas-price-button-group') + assert.equal(defaultActiveButtonIndex, 2) + assert.equal(noButtonActiveByDefault, true) + }) + + function renderButtonArgsTest (i, mockButtonPropsAndFlags) { + assert.deepEqual( + GasPriceButtonGroup.prototype.renderButton.getCall(i).args, + [ + Object.assign({}, mockGasPriceButtonGroupProps.gasButtonInfo[i]), + mockButtonPropsAndFlags, + i, + ] + ) + } + + it('should call this.renderButton 3 times, with the correct args', () => { + assert.equal(GasPriceButtonGroup.prototype.renderButton.callCount, 3) + renderButtonArgsTest(0, mockButtonPropsAndFlags) + renderButtonArgsTest(1, mockButtonPropsAndFlags) + renderButtonArgsTest(2, mockButtonPropsAndFlags) + }) + + it('should show loading if buttonDataLoading', () => { + wrapper.setProps({ buttonDataLoading: true }) + assert(wrapper.is('div')) + assert(wrapper.hasClass('gas-price-button-group__loading-container')) + assert.equal(wrapper.text(), 'loading') + }) + }) + + describe('renderButton', () => { + let wrappedRenderButtonResult + + beforeEach(() => { + GasPriceButtonGroup.prototype.renderButtonContent.resetHistory() + const renderButtonResult = GasPriceButtonGroup.prototype.renderButton( + Object.assign({}, mockGasPriceButtonGroupProps.gasButtonInfo[0]), + mockButtonPropsAndFlags + ) + wrappedRenderButtonResult = shallow(renderButtonResult) + }) + + it('should render a button', () => { + assert.equal(wrappedRenderButtonResult.type(), 'button') + }) + + it('should call the correct method when clicked', () => { + assert.equal(mockGasPriceButtonGroupProps.handleGasPriceSelection.callCount, 0) + wrappedRenderButtonResult.props().onClick() + assert.equal(mockGasPriceButtonGroupProps.handleGasPriceSelection.callCount, 1) + assert.deepEqual( + mockGasPriceButtonGroupProps.handleGasPriceSelection.getCall(0).args, + [mockGasPriceButtonGroupProps.gasButtonInfo[0].priceInHexWei] + ) + }) + + it('should call this.renderButtonContent with the correct args', () => { + assert.equal(GasPriceButtonGroup.prototype.renderButtonContent.callCount, 1) + const { + feeInPrimaryCurrency, + feeInSecondaryCurrency, + timeEstimate, + } = mockGasPriceButtonGroupProps.gasButtonInfo[0] + const { + showCheck, + className, + } = mockGasPriceButtonGroupProps + assert.deepEqual( + GasPriceButtonGroup.prototype.renderButtonContent.getCall(0).args, + [ + { + feeInPrimaryCurrency, + feeInSecondaryCurrency, + timeEstimate, + }, + { + showCheck, + className, + }, + ] + ) + }) + }) + + describe('renderButtonContent', () => { + it('should render a label if passed a labelKey', () => { + const renderButtonContentResult = wrapper.instance().renderButtonContent({ + labelKey: 'mockLabelKey', + }, { + className: 'someClass', + }) + const wrappedRenderButtonContentResult = shallow(renderButtonContentResult) + assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1) + assert.equal(wrappedRenderButtonContentResult.find('.someClass__label').text(), 'mockLabelKey') + }) + + it('should render a feeInPrimaryCurrency if passed a feeInPrimaryCurrency', () => { + const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({ + feeInPrimaryCurrency: 'mockFeeInPrimaryCurrency', + }, { + className: 'someClass', + }) + const wrappedRenderButtonContentResult = shallow(renderButtonContentResult) + assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1) + assert.equal(wrappedRenderButtonContentResult.find('.someClass__primary-currency').text(), 'mockFeeInPrimaryCurrency') + }) + + it('should render a feeInSecondaryCurrency if passed a feeInSecondaryCurrency', () => { + const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({ + feeInSecondaryCurrency: 'mockFeeInSecondaryCurrency', + }, { + className: 'someClass', + }) + const wrappedRenderButtonContentResult = shallow(renderButtonContentResult) + assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1) + assert.equal(wrappedRenderButtonContentResult.find('.someClass__secondary-currency').text(), 'mockFeeInSecondaryCurrency') + }) + + it('should render a timeEstimate if passed a timeEstimate', () => { + const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({ + timeEstimate: 'mockTimeEstimate', + }, { + className: 'someClass', + }) + const wrappedRenderButtonContentResult = shallow(renderButtonContentResult) + assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1) + assert.equal(wrappedRenderButtonContentResult.find('.someClass__time-estimate').text(), 'mockTimeEstimate') + }) + + it('should render a check if showCheck is true', () => { + const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({}, { + className: 'someClass', + showCheck: true, + }) + const wrappedRenderButtonContentResult = shallow(renderButtonContentResult) + assert.equal(wrappedRenderButtonContentResult.find('.fa-check').length, 1) + }) + + it('should render all elements if all args passed', () => { + const renderButtonContentResult = wrapper.instance().renderButtonContent({ + labelKey: 'mockLabel', + feeInPrimaryCurrency: 'mockFeeInPrimaryCurrency', + feeInSecondaryCurrency: 'mockFeeInSecondaryCurrency', + timeEstimate: 'mockTimeEstimate', + }, { + className: 'someClass', + showCheck: true, + }) + const wrappedRenderButtonContentResult = shallow(renderButtonContentResult) + assert.equal(wrappedRenderButtonContentResult.children().length, 5) + }) + + + it('should render no elements if all args passed', () => { + const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({}, {}) + const wrappedRenderButtonContentResult = shallow(renderButtonContentResult) + assert.equal(wrappedRenderButtonContentResult.children().length, 0) + }) + }) +}) diff --git a/ui/app/components/app/gas-customization/gas-price-chart/gas-price-chart.component.js b/ui/app/components/app/gas-customization/gas-price-chart/gas-price-chart.component.js new file mode 100644 index 000000000..c0eaf4852 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-chart/gas-price-chart.component.js @@ -0,0 +1,108 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import * as d3 from 'd3' +import { + generateChart, + getCoordinateData, + handleChartUpdate, + hideDataUI, + setTickPosition, + handleMouseMove, +} from './gas-price-chart.utils.js' + +export default class GasPriceChart extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + gasPrices: PropTypes.array, + estimatedTimes: PropTypes.array, + gasPricesMax: PropTypes.number, + estimatedTimesMax: PropTypes.number, + currentPrice: PropTypes.number, + updateCustomGasPrice: PropTypes.func, + } + + renderChart ({ + currentPrice, + gasPrices, + estimatedTimes, + gasPricesMax, + estimatedTimesMax, + updateCustomGasPrice, + }) { + const chart = generateChart(gasPrices, estimatedTimes, gasPricesMax, estimatedTimesMax, this.context.t) + setTimeout(function () { + setTickPosition('y', 0, -5, 8) + setTickPosition('y', 1, -3, -5) + setTickPosition('x', 0, 3) + setTickPosition('x', 1, 3, -8) + + const { x: domainX } = getCoordinateData('.domain') + const { x: yAxisX } = getCoordinateData('.c3-axis-y-label') + const { x: tickX } = getCoordinateData('.c3-axis-x .tick') + + d3.select('.c3-axis-x .tick').attr('transform', 'translate(' + (domainX - tickX) / 2 + ', 0)') + d3.select('.c3-axis-x-label').attr('transform', 'translate(0,-15)') + d3.select('.c3-axis-y-label').attr('transform', 'translate(' + (domainX - yAxisX - 12) + ', 2) rotate(-90)') + d3.select('.c3-xgrid-focus line').attr('y2', 98) + + d3.select('.c3-chart').on('mouseout', () => { + hideDataUI(chart, '#overlayed-circle') + }) + + d3.select('.c3-chart').on('click', () => { + const { x: newGasPrice } = d3.select('#overlayed-circle').datum() + updateCustomGasPrice(newGasPrice) + }) + + const { x: chartXStart, width: chartWidth } = getCoordinateData('.c3-areas-data1') + + handleChartUpdate({ + chart, + gasPrices, + newPrice: currentPrice, + cssId: '#set-circle', + }) + + d3.select('.c3-chart').on('mousemove', function () { + handleMouseMove({ + xMousePos: d3.event.clientX, + chartXStart, + chartWidth, + gasPrices, + estimatedTimes, + chart, + }) + }) + }, 0) + + this.chart = chart + } + + componentDidUpdate (prevProps) { + const { gasPrices, currentPrice: newPrice } = this.props + + if (prevProps.currentPrice !== newPrice) { + handleChartUpdate({ + chart: this.chart, + gasPrices, + newPrice, + cssId: '#set-circle', + }) + } + } + + componentDidMount () { + this.renderChart(this.props) + } + + render () { + return ( + <div className="gas-price-chart" id="container"> + <div className="gas-price-chart__root" id="chart"></div> + </div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/gas-price-chart/gas-price-chart.utils.js b/ui/app/components/app/gas-customization/gas-price-chart/gas-price-chart.utils.js new file mode 100644 index 000000000..f19dafcc1 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-chart/gas-price-chart.utils.js @@ -0,0 +1,354 @@ +import * as d3 from 'd3' +import c3 from 'c3' +import BigNumber from 'bignumber.js' + +const newBigSigDig = n => (new BigNumber(n.toPrecision(15))) +const createOp = (a, b, op) => (newBigSigDig(a))[op](newBigSigDig(b)) +const bigNumMinus = (a = 0, b = 0) => createOp(a, b, 'minus') +const bigNumDiv = (a = 0, b = 1) => createOp(a, b, 'div') + +export function handleMouseMove ({ xMousePos, chartXStart, chartWidth, gasPrices, estimatedTimes, chart }) { + const { currentPosValue, newTimeEstimate } = getNewXandTimeEstimate({ + xMousePos, + chartXStart, + chartWidth, + gasPrices, + estimatedTimes, + }) + + if (currentPosValue === null && newTimeEstimate === null) { + hideDataUI(chart, '#overlayed-circle') + return + } + + const indexOfNewCircle = estimatedTimes.length + 1 + const dataUIObj = generateDataUIObj(currentPosValue, indexOfNewCircle, newTimeEstimate) + + chart.internal.overlayPoint(dataUIObj, indexOfNewCircle) + chart.internal.showTooltip([dataUIObj], d3.select('.c3-areas-data1')._groups[0]) + chart.internal.showXGridFocus([dataUIObj]) +} + +export function getCoordinateData (selector) { + const node = d3.select(selector).node() + return node ? node.getBoundingClientRect() : {} +} + +export function generateDataUIObj (x, index, value) { + return { + x, + value, + index, + id: 'data1', + name: 'data1', + } +} + +export function handleChartUpdate ({ chart, gasPrices, newPrice, cssId }) { + const { + closestLowerValueIndex, + closestLowerValue, + closestHigherValueIndex, + closestHigherValue, + } = getAdjacentGasPrices({ gasPrices, priceToPosition: newPrice }) + + if (closestLowerValue && closestHigherValue) { + setSelectedCircle({ + chart, + newPrice, + closestLowerValueIndex, + closestLowerValue, + closestHigherValueIndex, + closestHigherValue, + }) + } else { + hideDataUI(chart, cssId) + } +} + +export function getAdjacentGasPrices ({ gasPrices, priceToPosition }) { + const closestLowerValueIndex = gasPrices.findIndex((e, i, a) => e <= priceToPosition && a[i + 1] >= priceToPosition) + const closestHigherValueIndex = gasPrices.findIndex((e, i, a) => e > priceToPosition) + return { + closestLowerValueIndex, + closestHigherValueIndex, + closestHigherValue: gasPrices[closestHigherValueIndex], + closestLowerValue: gasPrices[closestLowerValueIndex], + } +} + +export function extrapolateY ({ higherY = 0, lowerY = 0, higherX = 0, lowerX = 0, xForExtrapolation = 0 }) { + const slope = bigNumMinus(higherY, lowerY).div(bigNumMinus(higherX, lowerX)) + const newTimeEstimate = slope.times(bigNumMinus(higherX, xForExtrapolation)).minus(newBigSigDig(higherY)).negated() + + return newTimeEstimate.toNumber() +} + + +export function getNewXandTimeEstimate ({ xMousePos, chartXStart, chartWidth, gasPrices, estimatedTimes }) { + const chartMouseXPos = bigNumMinus(xMousePos, chartXStart) + const posPercentile = bigNumDiv(chartMouseXPos, chartWidth) + + const currentPosValue = (bigNumMinus(gasPrices[gasPrices.length - 1], gasPrices[0])) + .times(newBigSigDig(posPercentile)) + .plus(newBigSigDig(gasPrices[0])) + .toNumber() + + const { + closestLowerValueIndex, + closestLowerValue, + closestHigherValueIndex, + closestHigherValue, + } = getAdjacentGasPrices({ gasPrices, priceToPosition: currentPosValue }) + + return !closestHigherValue || !closestLowerValue + ? { + currentPosValue: null, + newTimeEstimate: null, + } + : { + currentPosValue, + newTimeEstimate: extrapolateY({ + higherY: estimatedTimes[closestHigherValueIndex], + lowerY: estimatedTimes[closestLowerValueIndex], + higherX: closestHigherValue, + lowerX: closestLowerValue, + xForExtrapolation: currentPosValue, + }), + } +} + +export function hideDataUI (chart, dataNodeId) { + const overLayedCircle = d3.select(dataNodeId) + if (!overLayedCircle.empty()) { + overLayedCircle.remove() + } + d3.select('.c3-tooltip-container').style('display', 'none !important') + chart.internal.hideXGridFocus() +} + +export function setTickPosition (axis, n, newPosition, secondNewPosition) { + const positionToShift = axis === 'y' ? 'x' : 'y' + const secondPositionToShift = axis === 'y' ? 'y' : 'x' + d3.select('#chart') + .select(`.c3-axis-${axis}`) + .selectAll('.tick') + .filter((d, i) => i === n) + .select('text') + .attr(positionToShift, 0) + .select('tspan') + .attr(positionToShift, newPosition) + .attr(secondPositionToShift, secondNewPosition || 0) + .style('visibility', 'visible') +} + +export function appendOrUpdateCircle ({ data, itemIndex, cx, cy, cssId, appendOnly }) { + const circle = this.main + .select('.c3-selected-circles' + this.getTargetSelectorSuffix(data.id)) + .selectAll(`.c3-selected-circle-${itemIndex}`) + + if (appendOnly || circle.empty()) { + circle.data([data]) + .enter().append('circle') + .attr('class', () => this.generateClass('c3-selected-circle', itemIndex)) + .attr('id', cssId) + .attr('cx', cx) + .attr('cy', cy) + .attr('stroke', () => this.color(data)) + .attr('r', 6) + } else { + circle.data([data]) + .attr('cx', cx) + .attr('cy', cy) + } +} + +export function setSelectedCircle ({ + chart, + newPrice, + closestLowerValueIndex, + closestLowerValue, + closestHigherValueIndex, + closestHigherValue, +}) { + const numberOfValues = chart.internal.data.xs.data1.length + + const { x: lowerX, y: lowerY } = getCoordinateData(`.c3-circle-${closestLowerValueIndex}`) + let { x: higherX, y: higherY } = getCoordinateData(`.c3-circle-${closestHigherValueIndex}`) + let count = closestHigherValueIndex + 1 + + if (lowerX && higherX) { + while (lowerX === higherX) { + higherX = getCoordinateData(`.c3-circle-${count}`).x + higherY = getCoordinateData(`.c3-circle-${count}`).y + count++ + } + } + + const currentX = bigNumMinus(higherX, lowerX) + .times(bigNumMinus(newPrice, closestLowerValue)) + .div(bigNumMinus(closestHigherValue, closestLowerValue)) + .plus(newBigSigDig(lowerX)) + + const newTimeEstimate = extrapolateY({ higherY, lowerY, higherX, lowerX, xForExtrapolation: currentX }) + + chart.internal.selectPoint( + generateDataUIObj(currentX.toNumber(), numberOfValues, newTimeEstimate), + numberOfValues + ) +} + + +export function generateChart (gasPrices, estimatedTimes, gasPricesMax, estimatedTimesMax) { + const gasPricesMaxPadded = gasPricesMax + 1 + const chart = c3.generate({ + size: { + height: 165, + }, + transition: { + duration: 0, + }, + padding: {left: 20, right: 15, top: 6, bottom: 10}, + data: { + x: 'x', + columns: [ + ['x', ...gasPrices], + ['data1', ...estimatedTimes], + ], + types: { + data1: 'area', + }, + selection: { + enabled: false, + }, + }, + color: { + data1: '#259de5', + }, + axis: { + x: { + min: gasPrices[0], + max: gasPricesMax, + tick: { + values: [Math.floor(gasPrices[0]), Math.ceil(gasPricesMax)], + outer: false, + format: function (val) { return val + ' GWEI' }, + }, + padding: {left: gasPricesMax / 50, right: gasPricesMax / 50}, + label: { + text: 'Gas Price ($)', + position: 'outer-center', + }, + }, + y: { + padding: {top: 7, bottom: 7}, + tick: { + values: [Math.floor(estimatedTimesMax * 0.05), Math.ceil(estimatedTimesMax * 0.97)], + outer: false, + }, + label: { + text: 'Confirmation time (sec)', + position: 'outer-middle', + }, + min: 0, + }, + }, + legend: { + show: false, + }, + grid: { + x: {}, + lines: { + front: false, + }, + }, + point: { + focus: { + expand: { + enabled: false, + r: 3.5, + }, + }, + }, + tooltip: { + format: { + title: (v) => v.toPrecision(4), + }, + contents: function (d) { + const titleFormat = this.config.tooltip_format_title + let text + d.forEach(el => { + if (el && (el.value || el.value === 0) && !text) { + text = "<table class='" + 'custom-tooltip' + "'>" + "<tr><th colspan='2'>" + titleFormat(el.x) + '</th></tr>' + } + }) + return text + '</table>' + "<div class='tooltip-arrow'></div>" + }, + position: function (data) { + if (d3.select('#overlayed-circle').empty()) { + return { top: -100, left: -100 } + } + + const { x: circleX, y: circleY, width: circleWidth } = getCoordinateData('#overlayed-circle') + const { x: chartXStart, y: chartYStart } = getCoordinateData('.c3-chart') + + // TODO: Confirm the below constants work with all data sets and screen sizes + const flipTooltip = circleY - circleWidth < chartYStart + 5 + + d3 + .select('.tooltip-arrow') + .style('margin-top', flipTooltip ? '-16px' : '4px') + + return { + top: bigNumMinus(circleY, chartYStart).minus(19).plus(flipTooltip ? circleWidth + 38 : 0).toNumber(), + left: bigNumMinus(circleX, chartXStart).plus(newBigSigDig(circleWidth)).minus(bigNumDiv(gasPricesMaxPadded, 50)).toNumber(), + } + }, + show: true, + }, + }) + + chart.internal.selectPoint = function (data, itemIndex = (data.index || 0)) { + const { x: chartXStart, y: chartYStart } = getCoordinateData('.c3-areas-data1') + + d3.select('#set-circle').remove() + + appendOrUpdateCircle.bind(this)({ + data, + itemIndex, + cx: () => bigNumMinus(data.x, chartXStart).plus(11).toNumber(), + cy: () => bigNumMinus(data.value, chartYStart).plus(10).toNumber(), + cssId: 'set-circle', + appendOnly: true, + }) + } + + chart.internal.overlayPoint = function (data, itemIndex) { + appendOrUpdateCircle.bind(this)({ + data, + itemIndex, + cx: this.circleX.bind(this), + cy: this.circleY.bind(this), + cssId: 'overlayed-circle', + }) + } + + chart.internal.showTooltip = function (selectedData, element) { + const dataToShow = selectedData.filter((d) => d && (d.value || d.value === 0)) + + if (dataToShow.length) { + this.tooltip.html( + this.config.tooltip_contents.call(this, selectedData, this.axis.getXAxisTickFormat(), this.getYFormat(), this.color) + ).style('display', 'flex') + + // Get tooltip dimensions + const tWidth = this.tooltip.property('offsetWidth') + const tHeight = this.tooltip.property('offsetHeight') + const position = this.config.tooltip_position.call(this, dataToShow, tWidth, tHeight, element) + // Set tooltip + this.tooltip.style('top', position.top + 'px').style('left', position.left + 'px') + } + } + + return chart +} diff --git a/ui/app/components/app/gas-customization/gas-price-chart/index.js b/ui/app/components/app/gas-customization/gas-price-chart/index.js new file mode 100644 index 000000000..9895acb62 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-chart/index.js @@ -0,0 +1 @@ +export { default } from './gas-price-chart.component' diff --git a/ui/app/components/app/gas-customization/gas-price-chart/index.scss b/ui/app/components/app/gas-customization/gas-price-chart/index.scss new file mode 100644 index 000000000..097543104 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-chart/index.scss @@ -0,0 +1,132 @@ +.gas-price-chart { + display: flex; + position: relative; + justify-content: center; + + &__root { + max-height: 154px; + max-width: 391px; + position: relative; + overflow: hidden; + + @media screen and (max-width: $break-small) { + max-width: 326px; + } + } + + .tick text, .c3-axis-x-label, .c3-axis-y-label { + font-family: Roboto; + font-style: normal; + font-weight: bold; + line-height: normal; + font-size: 8px; + text-align: center; + fill: #9A9CA6 !important; + } + + .c3-tooltip-container { + display: flex; + justify-content: center !important; + align-items: flex-end !important; + } + + .custom-tooltip { + background: rgba(0, 0, 0, 1); + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); + border-radius: 3px; + opacity: 1 !important; + height: 21px; + z-index: 1; + } + + .tooltip-arrow { + background: black; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.5); + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + opacity: 1 !important; + width: 9px; + height: 9px; + margin-top: 4px; + } + + .custom-tooltip th { + font-family: Roboto; + font-style: normal; + font-weight: 500; + line-height: normal; + font-size: 10px; + text-align: center; + padding: 3px; + color: #FFFFFF; + } + + .c3-circle { + visibility: hidden; + } + + .c3-selected-circle, .c3-circle._expanded_ { + fill: #FFFFFF !important; + stroke-width: 2.4px !important; + stroke: #2d9fd9 !important; + /* visibility: visible; */ + } + + #set-circle { + fill: #313A5E !important; + stroke: #313A5E !important; + } + + .c3-axis-x-label, .c3-axis-y-label { + font-weight: normal; + } + + .tick text tspan { + visibility: hidden; + } + + .c3-circle { + fill: #2d9fd9 !important; + } + + .c3-line-data1 { + stroke: #2d9fd9 !important; + background: rgba(0,0,0,0) !important; + color: rgba(0,0,0,0) !important; + } + + .c3 path { + fill: none; + } + + .c3 path.c3-area-data1 { + opacity: 1; + fill: #e9edf1 !important; + } + + .c3-xgrid-line line { + stroke: #B8B8B8 !important; + } + + .c3-xgrid-focus { + stroke: #aaa; + } + + .c3-axis-x .domain { + fill: none; + stroke: none; + } + + .c3-axis-y .domain { + fill: none; + stroke: #C8CCD6; + } + + .c3-event-rect { + cursor: pointer; + } +} + +#chart { + background: #F8F9FB +} diff --git a/ui/app/components/app/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js b/ui/app/components/app/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js new file mode 100644 index 000000000..7dec7a85f --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js @@ -0,0 +1,218 @@ +import React from 'react' +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' +import shallow from '../../../../../../lib/shallow-with-context' +import * as d3 from 'd3' + +function timeout (time) { + return new Promise((resolve, reject) => { + setTimeout(resolve, time) + }) +} + +const propsMethodSpies = { + updateCustomGasPrice: sinon.spy(), +} + +const selectReturnSpies = { + empty: sinon.spy(), + remove: sinon.spy(), + style: sinon.spy(), + select: d3.select, + attr: sinon.spy(), + on: sinon.spy(), + datum: sinon.stub().returns({ x: 'mockX' }), +} + +const mockSelectReturn = { + ...d3.select('div'), + node: () => ({ + getBoundingClientRect: () => ({ x: 123, y: 321, width: 400 }), + }), + ...selectReturnSpies, +} + +const gasPriceChartUtilsSpies = { + appendOrUpdateCircle: sinon.spy(), + generateChart: sinon.stub().returns({ mockChart: true }), + generateDataUIObj: sinon.spy(), + getAdjacentGasPrices: sinon.spy(), + getCoordinateData: sinon.stub().returns({ x: 'mockCoordinateX', width: 'mockWidth' }), + getNewXandTimeEstimate: sinon.spy(), + handleChartUpdate: sinon.spy(), + hideDataUI: sinon.spy(), + setSelectedCircle: sinon.spy(), + setTickPosition: sinon.spy(), + handleMouseMove: sinon.spy(), +} + +const testProps = { + gasPrices: [1.5, 2.5, 4, 8], + estimatedTimes: [100, 80, 40, 10], + gasPricesMax: 9, + estimatedTimesMax: '100', + currentPrice: 6, + updateCustomGasPrice: propsMethodSpies.updateCustomGasPrice, +} + +const GasPriceChart = proxyquire('../gas-price-chart.component.js', { + './gas-price-chart.utils.js': gasPriceChartUtilsSpies, + 'd3': { + ...d3, + select: function (...args) { + const result = d3.select(...args) + return result.empty() + ? mockSelectReturn + : result + }, + event: { + clientX: 'mockClientX', + }, + }, +}).default + +sinon.spy(GasPriceChart.prototype, 'renderChart') + +describe('GasPriceChart Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<GasPriceChart {...testProps} />) + }) + + describe('render()', () => { + it('should render', () => { + assert(wrapper.hasClass('gas-price-chart')) + }) + + it('should render the chart div', () => { + assert(wrapper.childAt(0).hasClass('gas-price-chart__root')) + assert.equal(wrapper.childAt(0).props().id, 'chart') + }) + }) + + describe('componentDidMount', () => { + it('should call this.renderChart with the components props', () => { + assert(GasPriceChart.prototype.renderChart.callCount, 1) + wrapper.instance().componentDidMount() + assert(GasPriceChart.prototype.renderChart.callCount, 2) + assert.deepEqual(GasPriceChart.prototype.renderChart.getCall(1).args, [{...testProps}]) + }) + }) + + describe('componentDidUpdate', () => { + it('should call handleChartUpdate if props.currentPrice has changed', () => { + gasPriceChartUtilsSpies.handleChartUpdate.resetHistory() + wrapper.instance().componentDidUpdate({ currentPrice: 7 }) + assert.equal(gasPriceChartUtilsSpies.handleChartUpdate.callCount, 1) + }) + + it('should call handleChartUpdate with the correct props', () => { + gasPriceChartUtilsSpies.handleChartUpdate.resetHistory() + wrapper.instance().componentDidUpdate({ currentPrice: 7 }) + assert.deepEqual(gasPriceChartUtilsSpies.handleChartUpdate.getCall(0).args, [{ + chart: { mockChart: true }, + gasPrices: [1.5, 2.5, 4, 8], + newPrice: 6, + cssId: '#set-circle', + }]) + }) + + it('should not call handleChartUpdate if props.currentPrice has not changed', () => { + gasPriceChartUtilsSpies.handleChartUpdate.resetHistory() + wrapper.instance().componentDidUpdate({ currentPrice: 6 }) + assert.equal(gasPriceChartUtilsSpies.handleChartUpdate.callCount, 0) + }) + }) + + describe('renderChart', () => { + it('should call setTickPosition 4 times, with the expected props', async () => { + await timeout(0) + gasPriceChartUtilsSpies.setTickPosition.resetHistory() + assert.equal(gasPriceChartUtilsSpies.setTickPosition.callCount, 0) + wrapper.instance().renderChart(testProps) + await timeout(0) + assert.equal(gasPriceChartUtilsSpies.setTickPosition.callCount, 4) + assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(0).args, ['y', 0, -5, 8]) + assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(1).args, ['y', 1, -3, -5]) + assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(2).args, ['x', 0, 3]) + assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(3).args, ['x', 1, 3, -8]) + }) + + it('should call handleChartUpdate with the correct props', async () => { + await timeout(0) + gasPriceChartUtilsSpies.handleChartUpdate.resetHistory() + wrapper.instance().renderChart(testProps) + await timeout(0) + assert.deepEqual(gasPriceChartUtilsSpies.handleChartUpdate.getCall(0).args, [{ + chart: { mockChart: true }, + gasPrices: [1.5, 2.5, 4, 8], + newPrice: 6, + cssId: '#set-circle', + }]) + }) + + it('should add three events to the chart', async () => { + await timeout(0) + selectReturnSpies.on.resetHistory() + assert.equal(selectReturnSpies.on.callCount, 0) + wrapper.instance().renderChart(testProps) + await timeout(0) + assert.equal(selectReturnSpies.on.callCount, 3) + + const firstOnEventArgs = selectReturnSpies.on.getCall(0).args + assert.equal(firstOnEventArgs[0], 'mouseout') + const secondOnEventArgs = selectReturnSpies.on.getCall(1).args + assert.equal(secondOnEventArgs[0], 'click') + const thirdOnEventArgs = selectReturnSpies.on.getCall(2).args + assert.equal(thirdOnEventArgs[0], 'mousemove') + }) + + it('should hide the data UI on mouseout', async () => { + await timeout(0) + selectReturnSpies.on.resetHistory() + wrapper.instance().renderChart(testProps) + gasPriceChartUtilsSpies.hideDataUI.resetHistory() + await timeout(0) + const mouseoutEventArgs = selectReturnSpies.on.getCall(0).args + assert.equal(gasPriceChartUtilsSpies.hideDataUI.callCount, 0) + mouseoutEventArgs[1]() + assert.equal(gasPriceChartUtilsSpies.hideDataUI.callCount, 1) + assert.deepEqual(gasPriceChartUtilsSpies.hideDataUI.getCall(0).args, [{ mockChart: true }, '#overlayed-circle']) + }) + + it('should updateCustomGasPrice on click', async () => { + await timeout(0) + selectReturnSpies.on.resetHistory() + wrapper.instance().renderChart(testProps) + propsMethodSpies.updateCustomGasPrice.resetHistory() + await timeout(0) + const mouseoutEventArgs = selectReturnSpies.on.getCall(1).args + assert.equal(propsMethodSpies.updateCustomGasPrice.callCount, 0) + mouseoutEventArgs[1]() + assert.equal(propsMethodSpies.updateCustomGasPrice.callCount, 1) + assert.equal(propsMethodSpies.updateCustomGasPrice.getCall(0).args[0], 'mockX') + }) + + it('should handle mousemove', async () => { + await timeout(0) + selectReturnSpies.on.resetHistory() + wrapper.instance().renderChart(testProps) + gasPriceChartUtilsSpies.handleMouseMove.resetHistory() + await timeout(0) + const mouseoutEventArgs = selectReturnSpies.on.getCall(2).args + assert.equal(gasPriceChartUtilsSpies.handleMouseMove.callCount, 0) + mouseoutEventArgs[1]() + assert.equal(gasPriceChartUtilsSpies.handleMouseMove.callCount, 1) + assert.deepEqual(gasPriceChartUtilsSpies.handleMouseMove.getCall(0).args, [{ + xMousePos: 'mockClientX', + chartXStart: 'mockCoordinateX', + chartWidth: 'mockWidth', + gasPrices: testProps.gasPrices, + estimatedTimes: testProps.estimatedTimes, + chart: { mockChart: true }, + }]) + }) + }) +}) diff --git a/ui/app/components/app/gas-customization/gas-slider/gas-slider.component.js b/ui/app/components/app/gas-customization/gas-slider/gas-slider.component.js new file mode 100644 index 000000000..5836e7dfc --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-slider/gas-slider.component.js @@ -0,0 +1,48 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class AdvancedTabContent extends Component { + static propTypes = { + onChange: PropTypes.func, + lowLabel: PropTypes.string, + highLabel: PropTypes.string, + value: PropTypes.number, + step: PropTypes.number, + max: PropTypes.number, + min: PropTypes.number, + } + + render () { + const { + onChange, + lowLabel, + highLabel, + value, + step, + max, + min, + } = this.props + + return ( + <div className="gas-slider"> + <input + className="gas-slider__input" + type="range" + step={step} + max={max} + min={min} + value={value} + id="gasSlider" + onChange={event => onChange(event.target.value)} + /> + <div className="gas-slider__bar"> + <div className="gas-slider__colored"/> + </div> + <div className="gas-slider__labels"> + <span>{lowLabel}</span> + <span>{highLabel}</span> + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/gas-customization/gas-slider/index.js b/ui/app/components/app/gas-customization/gas-slider/index.js new file mode 100644 index 000000000..f1752c93f --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-slider/index.js @@ -0,0 +1 @@ +export { default } from './gas-slider.component' diff --git a/ui/app/components/app/gas-customization/gas-slider/index.scss b/ui/app/components/app/gas-customization/gas-slider/index.scss new file mode 100644 index 000000000..e6c734367 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-slider/index.scss @@ -0,0 +1,54 @@ +.gas-slider { + position: relative; + width: 322px; + + &__input { + width: 322px; + margin-left: -2px; + z-index: 2; + } + + input[type=range] { + -webkit-appearance: none !important; + } + + input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none !important; + height: 34px; + width: 34px; + background-color: $curious-blue; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.08); + border-radius: 50%; + position: relative; + z-index: 10; + } + + &__bar { + height: 6px; + width: 322px; + background: $alto; + display: flex; + justify-content: space-between; + position: absolute; + top: 16px; + z-index: 0; + border-radius: 4px; + } + + &__colored { + height: 6px; + border-radius: 4px; + margin-left: 102px; + width: 322px; + z-index: 1; + background-color: $blizzard-blue; + } + + &__labels { + display: flex; + justify-content: space-between; + font-size: 12px; + margin-top: -6px; + color: $mid-gray; + } +}
\ No newline at end of file diff --git a/ui/app/components/app/gas-customization/gas.selectors.js b/ui/app/components/app/gas-customization/gas.selectors.js new file mode 100644 index 000000000..89374b5f1 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas.selectors.js @@ -0,0 +1,14 @@ +const selectors = { + getCurrentBlockTime, + getBasicGasEstimateLoadingStatus, +} + +module.exports = selectors + +function getCurrentBlockTime (state) { + return state.gas.currentBlockTime +} + +function getBasicGasEstimateLoadingStatus (state) { + return state.gas.basicEstimateIsLoading +} diff --git a/ui/app/components/app/gas-customization/index.scss b/ui/app/components/app/gas-customization/index.scss new file mode 100644 index 000000000..b06c1d044 --- /dev/null +++ b/ui/app/components/app/gas-customization/index.scss @@ -0,0 +1,7 @@ +@import './gas-slider/index'; + +@import './gas-modal-page-container/index'; + +@import './gas-price-chart/index'; + +@import './advanced-gas-inputs/index'; diff --git a/ui/app/components/app/index.scss b/ui/app/components/app/index.scss new file mode 100644 index 000000000..e9bb4ac9f --- /dev/null +++ b/ui/app/components/app/index.scss @@ -0,0 +1,81 @@ +@import 'account-menu/index'; + +@import 'add-token-button/index'; + +@import 'app-header/index'; + +@import '../ui/breadcrumbs/index'; + +@import '../ui/button-group/index'; + +@import '../ui/card/index'; + +@import 'confirm-page-container/index'; + +@import '../ui/currency-input/index'; + +@import '../ui/currency-display/index'; + +@import '../ui/error-message/index'; + +@import '../ui/export-text-container/index'; + +@import '../ui/identicon/index'; + +@import 'info-box/index'; + +@import 'menu-bar/index'; + +@import 'modal/index'; + +@import 'modals/index'; + +@import 'network-display/index'; + +@import '../ui/page-container/index'; + +@import '../../pages/index'; + +@import 'provider-page-container/index'; + +@import 'selected-account/index'; + +@import '../ui/sender-to-recipient/index'; + +@import '../ui/tabs/index'; + +@import '../ui/token-balance/index'; + +@import 'transaction-activity-log/index'; + +@import 'transaction-breakdown/index'; + +@import 'transaction-view/index'; + +@import 'transaction-view-balance/index'; + +@import 'transaction-list/index'; + +@import 'transaction-list-item/index'; + +@import 'transaction-list-item-details/index'; + +@import 'transaction-status/index'; + +@import 'app-header/index'; + +@import 'sidebars/index'; + +@import '../ui/unit-input/index'; + +@import 'gas-customization/gas-modal-page-container/index'; + +@import 'gas-customization/gas-modal-page-container/index'; + +@import 'gas-customization/gas-modal-page-container/index'; + +@import 'gas-customization/index'; + +@import 'gas-customization/gas-price-button-group/index'; + +@import 'ui-migration-annoucement/index'; diff --git a/ui/app/components/info-box/index.js b/ui/app/components/app/info-box/index.js index 6110422ed..6110422ed 100644 --- a/ui/app/components/info-box/index.js +++ b/ui/app/components/app/info-box/index.js diff --git a/ui/app/components/info-box/index.scss b/ui/app/components/app/info-box/index.scss index 8b5626d79..8b5626d79 100644 --- a/ui/app/components/info-box/index.scss +++ b/ui/app/components/app/info-box/index.scss diff --git a/ui/app/components/info-box/info-box.component.js b/ui/app/components/app/info-box/info-box.component.js index 8688b8e8f..8688b8e8f 100644 --- a/ui/app/components/info-box/info-box.component.js +++ b/ui/app/components/app/info-box/info-box.component.js diff --git a/ui/app/components/input-number.js b/ui/app/components/app/input-number.js index eec5e3740..8a6ec725c 100644 --- a/ui/app/components/input-number.js +++ b/ui/app/components/app/input-number.js @@ -6,7 +6,7 @@ const { conversionGTE, conversionLTE, subtractCurrencies, -} = require('../conversion-util') +} = require('../../helpers/utils/conversion-util') module.exports = InputNumber diff --git a/ui/app/components/app/loading-network-screen/index.js b/ui/app/components/app/loading-network-screen/index.js new file mode 100644 index 000000000..726b4b530 --- /dev/null +++ b/ui/app/components/app/loading-network-screen/index.js @@ -0,0 +1 @@ +export { default } from './loading-network-screen.container' diff --git a/ui/app/components/app/loading-network-screen/loading-network-screen.component.js b/ui/app/components/app/loading-network-screen/loading-network-screen.component.js new file mode 100644 index 000000000..348a997c8 --- /dev/null +++ b/ui/app/components/app/loading-network-screen/loading-network-screen.component.js @@ -0,0 +1,138 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Spinner from '../../ui/spinner' +import Button from '../../ui/button' + +export default class LoadingNetworkScreen extends PureComponent { + state = { + showErrorScreen: false, + } + + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + loadingMessage: PropTypes.string, + cancelTime: PropTypes.number, + provider: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + providerId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + showNetworkDropdown: PropTypes.func, + setProviderArgs: PropTypes.array, + lastSelectedProvider: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + setProviderType: PropTypes.func, + isLoadingNetwork: PropTypes.bool, + } + + componentDidMount = () => { + this.cancelCallTimeout = setTimeout(this.cancelCall, this.props.cancelTime || 15000) + } + + getConnectingLabel = function (loadingMessage) { + if (loadingMessage) { + return loadingMessage + } + const { provider, providerId } = this.props + const providerName = provider.type + + let name + + if (providerName === 'mainnet') { + name = this.context.t('connectingToMainnet') + } else if (providerName === 'ropsten') { + name = this.context.t('connectingToRopsten') + } else if (providerName === 'kovan') { + name = this.context.t('connectingToKovan') + } else if (providerName === 'rinkeby') { + name = this.context.t('connectingToRinkeby') + } else { + name = this.context.t('connectingTo', [providerId]) + } + + return name + } + + renderMessage = () => { + return <span>{ this.getConnectingLabel(this.props.loadingMessage) }</span> + } + + renderLoadingScreenContent = () => { + return <div className="loading-overlay__screen-content"> + <Spinner color="#F7C06C" /> + {this.renderMessage()} + </div> + } + + renderErrorScreenContent = () => { + const { showNetworkDropdown, setProviderArgs, setProviderType } = this.props + + return <div className="loading-overlay__error-screen"> + <span className="loading-overlay__emoji">😞</span> + <span>{ this.context.t('somethingWentWrong') }</span> + <div className="loading-overlay__error-buttons"> + <Button + type="default" + onClick={() => { + window.clearTimeout(this.cancelCallTimeout) + showNetworkDropdown() + }} + > + { this.context.t('switchNetworks') } + </Button> + + <Button + type="primary" + onClick={() => { + this.setState({ showErrorScreen: false }) + setProviderType(...setProviderArgs) + window.clearTimeout(this.cancelCallTimeout) + this.cancelCallTimeout = setTimeout(this.cancelCall, this.props.cancelTime || 15000) + }} + > + { this.context.t('tryAgain') } + </Button> + </div> + </div> + } + + cancelCall = () => { + const { isLoadingNetwork } = this.props + + if (isLoadingNetwork) { + this.setState({ showErrorScreen: true }) + } + } + + componentDidUpdate = (prevProps) => { + const { provider } = this.props + const { provider: prevProvider } = prevProps + if (provider.type !== prevProvider.type) { + window.clearTimeout(this.cancelCallTimeout) + this.setState({ showErrorScreen: false }) + this.cancelCallTimeout = setTimeout(this.cancelCall, this.props.cancelTime || 15000) + } + } + + componentWillUnmount = () => { + window.clearTimeout(this.cancelCallTimeout) + } + + render () { + const { lastSelectedProvider, setProviderType } = this.props + + return ( + <div className="loading-overlay"> + <div + className="page-container__header-close" + onClick={() => setProviderType(lastSelectedProvider || 'ropsten')} + /> + <div className="loading-overlay__container"> + { this.state.showErrorScreen + ? this.renderErrorScreenContent() + : this.renderLoadingScreenContent() + } + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/loading-network-screen/loading-network-screen.container.js b/ui/app/components/app/loading-network-screen/loading-network-screen.container.js new file mode 100644 index 000000000..87f1397ce --- /dev/null +++ b/ui/app/components/app/loading-network-screen/loading-network-screen.container.js @@ -0,0 +1,41 @@ +import { connect } from 'react-redux' +import LoadingNetworkScreen from './loading-network-screen.component' +import actions from '../../../store/actions' +import { getNetworkIdentifier } from '../../../selectors/selectors' + +const mapStateToProps = state => { + const { + loadingMessage, + currentView, + } = state.appState + const { + provider, + lastSelectedProvider, + network, + } = state.metamask + const { rpcTarget, chainId, ticker, nickname, type } = provider + + const setProviderArgs = type === 'rpc' + ? [rpcTarget, chainId, ticker, nickname] + : [provider.type] + + return { + isLoadingNetwork: network === 'loading' && currentView.name !== 'config', + loadingMessage, + lastSelectedProvider, + setProviderArgs, + provider, + providerId: getNetworkIdentifier(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + setProviderType: (type) => { + dispatch(actions.setProviderType(type)) + }, + showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(LoadingNetworkScreen) diff --git a/ui/app/components/menu-bar/index.js b/ui/app/components/app/menu-bar/index.js index c5760847f..c5760847f 100644 --- a/ui/app/components/menu-bar/index.js +++ b/ui/app/components/app/menu-bar/index.js diff --git a/ui/app/components/menu-bar/index.scss b/ui/app/components/app/menu-bar/index.scss index f699f4090..f699f4090 100644 --- a/ui/app/components/menu-bar/index.scss +++ b/ui/app/components/app/menu-bar/index.scss diff --git a/ui/app/components/app/menu-bar/menu-bar.component.js b/ui/app/components/app/menu-bar/menu-bar.component.js new file mode 100644 index 000000000..e37fddda4 --- /dev/null +++ b/ui/app/components/app/menu-bar/menu-bar.component.js @@ -0,0 +1,79 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Tooltip from '../../ui/tooltip' +import SelectedAccount from '../selected-account' +import AccountDetailsDropdown from '../dropdowns/account-details-dropdown.js' + +export default class MenuBar extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + hideSidebar: PropTypes.func, + sidebarOpen: PropTypes.bool, + showSidebar: PropTypes.func, + } + + state = { accountDetailsMenuOpen: false } + + render () { + const { t } = this.context + const { sidebarOpen, hideSidebar, showSidebar } = this.props + const { accountDetailsMenuOpen } = this.state + + return ( + <div className="menu-bar"> + <Tooltip + title={t('menu')} + position="bottom" + > + <div + className="fa fa-bars menu-bar__sidebar-button" + onClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Hamburger', + }, + }) + sidebarOpen ? hideSidebar() : showSidebar() + }} + /> + </Tooltip> + <SelectedAccount /> + + <Tooltip + title={t('accountOptions')} + position="bottom" + > + <div + className="fa fa-ellipsis-h fa-lg menu-bar__open-in-browser" + onClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Account Options', + }, + }) + this.setState({ accountDetailsMenuOpen: true }) + }} + > + </div> + </Tooltip> + + { + accountDetailsMenuOpen && ( + <AccountDetailsDropdown + className="menu-bar__account-details-dropdown" + onClose={() => this.setState({ accountDetailsMenuOpen: false })} + /> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/menu-bar/menu-bar.container.js b/ui/app/components/app/menu-bar/menu-bar.container.js index ae32882ae..059263ff3 100644 --- a/ui/app/components/menu-bar/menu-bar.container.js +++ b/ui/app/components/app/menu-bar/menu-bar.container.js @@ -1,13 +1,13 @@ import { connect } from 'react-redux' +import { WALLET_VIEW_SIDEBAR } from '../sidebars/sidebar.constants' import MenuBar from './menu-bar.component' -import { showSidebar, hideSidebar } from '../../actions' +import { showSidebar, hideSidebar } from '../../../store/actions' const mapStateToProps = state => { - const { appState: { sidebar: { isOpen }, isMascara } } = state + const { appState: { sidebar: { isOpen } } } = state return { sidebarOpen: isOpen, - isMascara, } } @@ -16,7 +16,7 @@ const mapDispatchToProps = dispatch => { showSidebar: () => { dispatch(showSidebar({ transitionName: 'sidebar-right', - type: 'wallet-view', + type: WALLET_VIEW_SIDEBAR, })) }, hideSidebar: () => dispatch(hideSidebar()), diff --git a/ui/app/components/menu-droppo.js b/ui/app/components/app/menu-droppo.js index c80bee2be..c80bee2be 100644 --- a/ui/app/components/menu-droppo.js +++ b/ui/app/components/app/menu-droppo.js diff --git a/ui/app/components/modal/index.js b/ui/app/components/app/modal/index.js index 58309abbe..58309abbe 100644 --- a/ui/app/components/modal/index.js +++ b/ui/app/components/app/modal/index.js diff --git a/ui/app/components/modal/index.scss b/ui/app/components/app/modal/index.scss index 2beb14633..ec67d15fd 100644 --- a/ui/app/components/modal/index.scss +++ b/ui/app/components/app/modal/index.scss @@ -1,4 +1,4 @@ -@import './modal-content/index'; +@import 'modal-content/index'; .modal-container { width: 100%; diff --git a/ui/app/components/modal/modal-content/index.js b/ui/app/components/app/modal/modal-content/index.js index 733cfb3b8..733cfb3b8 100644 --- a/ui/app/components/modal/modal-content/index.js +++ b/ui/app/components/app/modal/modal-content/index.js diff --git a/ui/app/components/modal/modal-content/index.scss b/ui/app/components/app/modal/modal-content/index.scss index 560505b84..560505b84 100644 --- a/ui/app/components/modal/modal-content/index.scss +++ b/ui/app/components/app/modal/modal-content/index.scss diff --git a/ui/app/components/modal/modal-content/modal-content.component.js b/ui/app/components/app/modal/modal-content/modal-content.component.js index ecec0ee5b..ecec0ee5b 100644 --- a/ui/app/components/modal/modal-content/modal-content.component.js +++ b/ui/app/components/app/modal/modal-content/modal-content.component.js diff --git a/ui/app/components/modal/modal-content/tests/modal-content.component.test.js b/ui/app/components/app/modal/modal-content/tests/modal-content.component.test.js index 17af09f45..17af09f45 100644 --- a/ui/app/components/modal/modal-content/tests/modal-content.component.test.js +++ b/ui/app/components/app/modal/modal-content/tests/modal-content.component.test.js diff --git a/ui/app/components/modal/modal.component.js b/ui/app/components/app/modal/modal.component.js index 2a75b559b..49e131b3c 100644 --- a/ui/app/components/modal/modal.component.js +++ b/ui/app/components/app/modal/modal.component.js @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import Button from '../button' +import Button from '../../ui/button' export default class Modal extends PureComponent { static propTypes = { @@ -12,6 +12,7 @@ export default class Modal extends PureComponent { onSubmit: PropTypes.func, submitType: PropTypes.string, submitText: PropTypes.string, + submitDisabled: PropTypes.bool, // Cancel button (left button) onCancel: PropTypes.func, cancelType: PropTypes.string, @@ -31,6 +32,7 @@ export default class Modal extends PureComponent { onSubmit, submitType, submitText, + submitDisabled, onCancel, cancelType, cancelText, @@ -69,6 +71,7 @@ export default class Modal extends PureComponent { <Button type={submitType} onClick={onSubmit} + disabled={submitDisabled} className="modal-container__footer-button" > { submitText } diff --git a/ui/app/components/modal/tests/modal.component.test.js b/ui/app/components/app/modal/tests/modal.component.test.js index 8cce1a808..a13d7c06a 100644 --- a/ui/app/components/modal/tests/modal.component.test.js +++ b/ui/app/components/app/modal/tests/modal.component.test.js @@ -1,9 +1,9 @@ import React from 'react' import assert from 'assert' -import { shallow } from 'enzyme' +import { mount, shallow } from 'enzyme' import sinon from 'sinon' import Modal from '../modal.component' -import Button from '../../button' +import Button from '../../../ui/button' describe('Modal Component', () => { it('should render a modal with a submit button', () => { @@ -100,4 +100,34 @@ describe('Modal Component', () => { assert.equal(handleCancel.callCount, 1) assert.equal(handleSubmit.callCount, 0) }) + + it('should disable the submit button if submitDisabled is true', () => { + const handleCancel = sinon.spy() + const handleSubmit = sinon.spy() + const wrapper = mount( + <Modal + onCancel={handleCancel} + cancelText="Cancel" + onSubmit={handleSubmit} + submitText="Submit" + submitDisabled={true} + headerText="My Header" + onClose={handleCancel} + /> + ) + + const buttons = wrapper.find(Button) + assert.equal(buttons.length, 2) + const cancelButton = buttons.at(0) + const submitButton = buttons.at(1) + + assert.equal(handleCancel.callCount, 0) + cancelButton.simulate('click') + assert.equal(handleCancel.callCount, 1) + + assert.equal(submitButton.props().disabled, true) + assert.equal(handleSubmit.callCount, 0) + submitButton.simulate('click') + assert.equal(handleSubmit.callCount, 0) + }) }) diff --git a/ui/app/components/modals/account-details-modal.js b/ui/app/components/app/modals/account-details-modal.js index 248ffe008..94ed04df9 100644 --- a/ui/app/components/modals/account-details-modal.js +++ b/ui/app/components/app/modals/account-details-modal.js @@ -3,14 +3,14 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') +const actions = require('../../../store/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') +const { getSelectedIdentity } = require('../../../selectors/selectors') +const genAccountLink = require('../../../../lib/account-link.js') +const QrView = require('../../ui/qr-code') +const EditableLabel = require('../../ui/editable-label') -import Button from '../button' +import Button from '../../ui/button' function mapStateToProps (state) { return { @@ -77,6 +77,7 @@ AccountDetailsModal.prototype.render = function () { h(QrView, { Qr: { data: address, + network: network, }, }), diff --git a/ui/app/components/modals/account-modal-container.js b/ui/app/components/app/modals/account-modal-container.js index 2a6c655e1..b7ae0b5b8 100644 --- a/ui/app/components/modals/account-modal-container.js +++ b/ui/app/components/app/modals/account-modal-container.js @@ -3,9 +3,9 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') -const { getSelectedIdentity } = require('../../selectors') -import Identicon from '../identicon' +const actions = require('../../../store/actions') +const { getSelectedIdentity } = require('../../../selectors/selectors') +import Identicon from '../../ui/identicon' function mapStateToProps (state, ownProps) { return { diff --git a/ui/app/components/modals/buy-options-modal.js b/ui/app/components/app/modals/buy-options-modal.js index c70510b5f..2df20e65c 100644 --- a/ui/app/components/modals/buy-options-modal.js +++ b/ui/app/components/app/modals/buy-options-modal.js @@ -3,8 +3,8 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') -const { getNetworkDisplayName } = require('../../../../app/scripts/controllers/network/util') +const actions = require('../../../store/actions') +const { getNetworkDisplayName } = require('../../../../../app/scripts/controllers/network/util') function mapStateToProps (state) { return { diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js index b973f221c..beebb7ed7 100644 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../../constants/common' +import { PRIMARY, SECONDARY } from '../../../../../helpers/constants/common' export default class CancelTransaction extends PureComponent { static propTypes = { diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.js b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/index.js index 1a9ae2e07..1a9ae2e07 100644 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.js +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/index.js diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.scss b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/index.scss index ce81dd448..ce81dd448 100644 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.scss +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/index.scss diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/tests/cancel-transaction-gas-fee.component.test.js b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/tests/cancel-transaction-gas-fee.component.test.js index 014815503..014815503 100644 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/tests/cancel-transaction-gas-fee.component.test.js +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/tests/cancel-transaction-gas-fee.component.test.js diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.component.js index 8b00cb9b9..6bab5ec1f 100644 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.component.js @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import Modal from '../../modal' import CancelTransactionGasFee from './cancel-transaction-gas-fee' -import { SUBMITTED_STATUS } from '../../../constants/transactions' +import { SUBMITTED_STATUS } from '../../../../helpers/constants/transactions' export default class CancelTransaction extends PureComponent { static contextTypes = { @@ -17,6 +17,10 @@ export default class CancelTransaction extends PureComponent { newGasFee: PropTypes.string, } + state = { + busy: false, + } + componentDidUpdate () { const { transactionStatus, showTransactionConfirmedModal } = this.props @@ -29,8 +33,10 @@ export default class CancelTransaction extends PureComponent { handleSubmit = async () => { const { createCancelTransaction, hideModal } = this.props + this.setState({ busy: true }) + await createCancelTransaction() - hideModal() + this.setState({ busy: false }, () => hideModal()) } handleCancel = () => { @@ -40,6 +46,7 @@ export default class CancelTransaction extends PureComponent { render () { const { t } = this.context const { newGasFee } = this.props + const { busy } = this.state return ( <Modal @@ -50,6 +57,7 @@ export default class CancelTransaction extends PureComponent { submitText={t('yesLetsTry')} cancelText={t('nevermind')} submitType="secondary" + submitDisabled={busy} > <div> <div className="cancel-transaction__title"> diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.container.js index eede8b1ee..6959889d9 100644 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.container.js @@ -1,11 +1,11 @@ import { connect } from 'react-redux' import { compose } from 'recompose' import ethUtil from 'ethereumjs-util' -import { multiplyCurrencies } from '../../../conversion-util' -import withModalProps from '../../../higher-order-components/with-modal-props' +import { multiplyCurrencies } from '../../../../helpers/utils/conversion-util' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' import CancelTransaction from './cancel-transaction.component' -import { showModal, createCancelTransaction } from '../../../actions' -import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' +import { showModal, createCancelTransaction } from '../../../../store/actions' +import { getHexGasTotal } from '../../../../helpers/utils/confirm-tx.util' const mapStateToProps = (state, ownProps) => { const { metamask } = state @@ -28,31 +28,29 @@ const mapStateToProps = (state, ownProps) => { transactionId, transactionStatus, originalGasPrice, + defaultNewGasPrice, newGasFee, } } const mapDispatchToProps = dispatch => { return { - createCancelTransaction: txId => dispatch(createCancelTransaction(txId)), + createCancelTransaction: (txId, customGasPrice) => { + return dispatch(createCancelTransaction(txId, customGasPrice)) + }, showTransactionConfirmedModal: () => dispatch(showModal({ name: 'TRANSACTION_CONFIRMED' })), } } const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { transactionId, ...restStateProps } = stateProps - const { - createCancelTransaction: dispatchCreateCancelTransaction, - ...restDispatchProps - } = dispatchProps + const { transactionId, defaultNewGasPrice, ...restStateProps } = stateProps + const { createCancelTransaction, ...restDispatchProps } = dispatchProps return { ...restStateProps, ...restDispatchProps, ...ownProps, - createCancelTransaction: newGasPrice => { - return dispatchCreateCancelTransaction(transactionId, newGasPrice) - }, + createCancelTransaction: () => createCancelTransaction(transactionId, defaultNewGasPrice), } } diff --git a/ui/app/components/modals/cancel-transaction/index.js b/ui/app/components/app/modals/cancel-transaction/index.js index 7abc871ee..7abc871ee 100644 --- a/ui/app/components/modals/cancel-transaction/index.js +++ b/ui/app/components/app/modals/cancel-transaction/index.js diff --git a/ui/app/components/modals/cancel-transaction/index.scss b/ui/app/components/app/modals/cancel-transaction/index.scss index 62e8e36fd..4ffb5a0f8 100644 --- a/ui/app/components/modals/cancel-transaction/index.scss +++ b/ui/app/components/app/modals/cancel-transaction/index.scss @@ -1,4 +1,4 @@ -@import './cancel-transaction-gas-fee/index'; +@import 'cancel-transaction-gas-fee/index'; .cancel-transaction { &__title { @@ -15,4 +15,4 @@ &__cancel-transaction-gas-fee-container { margin-bottom: 16px; } -}
\ No newline at end of file +} diff --git a/ui/app/components/modals/cancel-transaction/tests/cancel-transaction.component.test.js b/ui/app/components/app/modals/cancel-transaction/tests/cancel-transaction.component.test.js index 858fb01a8..345951b0f 100644 --- a/ui/app/components/modals/cancel-transaction/tests/cancel-transaction.component.test.js +++ b/ui/app/components/app/modals/cancel-transaction/tests/cancel-transaction.component.test.js @@ -34,6 +34,7 @@ describe('CancelTransaction Component', () => { defaultNewGasPrice="0x3b9aca00" createCancelTransaction={createCancelTransactionSpy} hideModal={hideModalSpy} + showTransactionConfirmedModal={() => {}} />, { context: { t }} ) diff --git a/ui/app/components/modals/clear-approved-origins/clear-approved-origins.component.js b/ui/app/components/app/modals/clear-approved-origins/clear-approved-origins.component.js index ceaa20a95..ceaa20a95 100644 --- a/ui/app/components/modals/clear-approved-origins/clear-approved-origins.component.js +++ b/ui/app/components/app/modals/clear-approved-origins/clear-approved-origins.component.js diff --git a/ui/app/components/modals/clear-approved-origins/clear-approved-origins.container.js b/ui/app/components/app/modals/clear-approved-origins/clear-approved-origins.container.js index 3a801a062..2276bc7e7 100644 --- a/ui/app/components/modals/clear-approved-origins/clear-approved-origins.container.js +++ b/ui/app/components/app/modals/clear-approved-origins/clear-approved-origins.container.js @@ -1,8 +1,8 @@ import { connect } from 'react-redux' import { compose } from 'recompose' -import withModalProps from '../../../higher-order-components/with-modal-props' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' import ClearApprovedOriginsComponent from './clear-approved-origins.component' -import { clearApprovedOrigins } from '../../../actions' +import { clearApprovedOrigins } from '../../../../store/actions' const mapDispatchToProps = dispatch => { return { diff --git a/ui/app/components/modals/clear-approved-origins/index.js b/ui/app/components/app/modals/clear-approved-origins/index.js index b3e321995..b3e321995 100644 --- a/ui/app/components/modals/clear-approved-origins/index.js +++ b/ui/app/components/app/modals/clear-approved-origins/index.js diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.component.js index 195c55421..f35fb85a0 100644 --- a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js +++ b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.component.js @@ -1,9 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import Modal from '../../modal' -import { addressSummary } from '../../../util' -import Identicon from '../../identicon' -import genAccountLink from '../../../../lib/account-link' +import { addressSummary } from '../../../../helpers/utils/util' +import Identicon from '../../../ui/identicon' +import genAccountLink from '../../../../../lib/account-link' export default class ConfirmRemoveAccount extends Component { static propTypes = { diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.container.js index 45c6654ab..0a3cda5b6 100644 --- a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js +++ b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.container.js @@ -1,8 +1,8 @@ import { connect } from 'react-redux' import { compose } from 'recompose' import ConfirmRemoveAccount from './confirm-remove-account.component' -import withModalProps from '../../../higher-order-components/with-modal-props' -import { removeAccount } from '../../../actions' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' +import { removeAccount } from '../../../../store/actions' const mapStateToProps = state => { return { diff --git a/ui/app/components/modals/confirm-remove-account/index.js b/ui/app/components/app/modals/confirm-remove-account/index.js index ecb5f7790..ecb5f7790 100644 --- a/ui/app/components/modals/confirm-remove-account/index.js +++ b/ui/app/components/app/modals/confirm-remove-account/index.js diff --git a/ui/app/components/modals/confirm-remove-account/index.scss b/ui/app/components/app/modals/confirm-remove-account/index.scss index 3be3a1967..3be3a1967 100644 --- a/ui/app/components/modals/confirm-remove-account/index.scss +++ b/ui/app/components/app/modals/confirm-remove-account/index.scss diff --git a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.component.js b/ui/app/components/app/modals/confirm-reset-account/confirm-reset-account.component.js index f1a4542ac..f1a4542ac 100644 --- a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.component.js +++ b/ui/app/components/app/modals/confirm-reset-account/confirm-reset-account.component.js diff --git a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js b/ui/app/components/app/modals/confirm-reset-account/confirm-reset-account.container.js index c8a7b8478..ffbd40d9d 100644 --- a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js +++ b/ui/app/components/app/modals/confirm-reset-account/confirm-reset-account.container.js @@ -1,8 +1,8 @@ import { connect } from 'react-redux' import { compose } from 'recompose' -import withModalProps from '../../../higher-order-components/with-modal-props' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' import ConfirmResetAccount from './confirm-reset-account.component' -import { resetAccount } from '../../../actions' +import { resetAccount } from '../../../../store/actions' const mapDispatchToProps = dispatch => { return { diff --git a/ui/app/components/modals/confirm-reset-account/index.js b/ui/app/components/app/modals/confirm-reset-account/index.js index ca4d9c5bf..ca4d9c5bf 100644 --- a/ui/app/components/modals/confirm-reset-account/index.js +++ b/ui/app/components/app/modals/confirm-reset-account/index.js diff --git a/ui/app/components/modals/customize-gas/customize-gas.component.js b/ui/app/components/app/modals/customize-gas/customize-gas.component.js index 3f526bd43..5db5c79e7 100644 --- a/ui/app/components/modals/customize-gas/customize-gas.component.js +++ b/ui/app/components/app/modals/customize-gas/customize-gas.component.js @@ -1,8 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' +import BigNumber from 'bignumber.js' import GasModalCard from '../../customize-gas-modal/gas-modal-card' import { MIN_GAS_PRICE_GWEI } from '../../send/send.constants' -import Button from '../../button' +import Button from '../../../ui/button' import { getDecimalGasLimit, @@ -14,6 +15,7 @@ import { export default class CustomizeGas extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -73,9 +75,9 @@ export default class CustomizeGas extends Component { } render () { - const { t } = this.context + const { t, metricsEvent } = this.context const { hideModal } = this.props - const { gasPrice, gasLimit } = this.state + const { gasPrice, gasLimit, originalGasPrice, originalGasLimit } = this.state const { valid, errorKey } = this.validate() return ( @@ -128,7 +130,24 @@ export default class CustomizeGas extends Component { <Button type="primary" className="customize-gas__save" - onClick={() => this.handleSave()} + onClick={() => { + metricsEvent({ + eventOpts: { + category: 'Activation', + action: 'userCloses', + name: 'closeCustomizeGas', + }, + pageOpts: { + section: 'customizeGasModal', + component: 'customizeGasSaveButton', + }, + customVariables: { + gasPriceChange: (new BigNumber(gasPrice)).minus(new BigNumber(originalGasPrice)).toString(10), + gasLimitChange: (new BigNumber(gasLimit)).minus(new BigNumber(originalGasLimit)).toString(10), + }, + }) + this.handleSave() + }} style={{ marginRight: '10px' }} disabled={!valid} > diff --git a/ui/app/components/modals/customize-gas/customize-gas.container.js b/ui/app/components/app/modals/customize-gas/customize-gas.container.js index 46a799795..221881a8a 100644 --- a/ui/app/components/modals/customize-gas/customize-gas.container.js +++ b/ui/app/components/app/modals/customize-gas/customize-gas.container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux' import CustomizeGas from './customize-gas.component' -import { hideModal } from '../../../actions' +import { hideModal } from '../../../../store/actions' const mapStateToProps = state => { const { appState: { modal: { modalState: { props } } } } = state diff --git a/ui/app/components/modals/customize-gas/customize-gas.util.js b/ui/app/components/app/modals/customize-gas/customize-gas.util.js index 6ba4a7705..e686183bd 100644 --- a/ui/app/components/modals/customize-gas/customize-gas.util.js +++ b/ui/app/components/app/modals/customize-gas/customize-gas.util.js @@ -1,5 +1,5 @@ import ethUtil from 'ethereumjs-util' -import { conversionUtil } from '../../../conversion-util' +import { conversionUtil } from '../../../../helpers/utils/conversion-util' export function getDecimalGasLimit (hexGasLimit) { return conversionUtil(hexGasLimit, { diff --git a/ui/app/components/modals/customize-gas/index.js b/ui/app/components/app/modals/customize-gas/index.js index 3a0ab7edc..3a0ab7edc 100644 --- a/ui/app/components/modals/customize-gas/index.js +++ b/ui/app/components/app/modals/customize-gas/index.js diff --git a/ui/app/components/modals/customize-gas/index.scss b/ui/app/components/app/modals/customize-gas/index.scss index e10452691..e10452691 100644 --- a/ui/app/components/modals/customize-gas/index.scss +++ b/ui/app/components/app/modals/customize-gas/index.scss diff --git a/ui/app/components/modals/deposit-ether-modal.js b/ui/app/components/app/modals/deposit-ether-modal.js index a64b3282e..def88f085 100644 --- a/ui/app/components/modals/deposit-ether-modal.js +++ b/ui/app/components/app/modals/deposit-ether-modal.js @@ -3,16 +3,16 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') -const { getNetworkDisplayName } = require('../../../../app/scripts/controllers/network/util') +const actions = require('../../../store/actions') +const { getNetworkDisplayName } = require('../../../../../app/scripts/controllers/network/util') const ShapeshiftForm = require('../shapeshift-form') -import Button from '../button' +import Button from '../../ui/button' let DIRECT_DEPOSIT_ROW_TITLE let DIRECT_DEPOSIT_ROW_TEXT -let COINBASE_ROW_TITLE -let COINBASE_ROW_TEXT +let WYRE_ROW_TITLE +let WYRE_ROW_TEXT let SHAPESHIFT_ROW_TITLE let SHAPESHIFT_ROW_TEXT let FAUCET_ROW_TITLE @@ -54,8 +54,8 @@ function DepositEtherModal (props, context) { // need to set after i18n locale has loaded DIRECT_DEPOSIT_ROW_TITLE = context.t('directDepositEther') DIRECT_DEPOSIT_ROW_TEXT = context.t('directDepositEtherExplainer') - COINBASE_ROW_TITLE = context.t('buyCoinbase') - COINBASE_ROW_TEXT = context.t('buyCoinbaseExplainer') + WYRE_ROW_TITLE = context.t('buyWithWyre') + WYRE_ROW_TEXT = context.t('buyWithWyreDescription') SHAPESHIFT_ROW_TITLE = context.t('depositShapeShift') SHAPESHIFT_ROW_TEXT = context.t('depositShapeShiftExplainer') FAUCET_ROW_TITLE = context.t('testFaucet') @@ -183,13 +183,13 @@ DepositEtherModal.prototype.render = function () { this.renderRow({ logo: h('div.deposit-ether-modal__logo', { style: { - backgroundImage: 'url(\'./images/coinbase logo.png\')', + backgroundImage: 'url(\'./images/wyre.svg\')', height: '40px', }, }), - title: COINBASE_ROW_TITLE, - text: COINBASE_ROW_TEXT, - buttonLabel: this.context.t('continueToCoinbase'), + title: WYRE_ROW_TITLE, + text: WYRE_ROW_TEXT, + buttonLabel: this.context.t('continueToWyre'), onButtonClick: () => toCoinbase(address), hide: isTestNetwork || buyingWithShapeshift, }), diff --git a/ui/app/components/modals/edit-account-name-modal.js b/ui/app/components/app/modals/edit-account-name-modal.js index edced8725..41a9862e9 100644 --- a/ui/app/components/modals/edit-account-name-modal.js +++ b/ui/app/components/app/modals/edit-account-name-modal.js @@ -3,8 +3,8 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') -const { getSelectedAccount } = require('../../selectors') +const actions = require('../../../store/actions') +const { getSelectedAccount } = require('../../../selectors/selectors') function mapStateToProps (state) { return { diff --git a/ui/app/components/modals/export-private-key-modal.js b/ui/app/components/app/modals/export-private-key-modal.js index d3e3c9a56..639887d4c 100644 --- a/ui/app/components/modals/export-private-key-modal.js +++ b/ui/app/components/app/modals/export-private-key-modal.js @@ -5,13 +5,13 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect const { stripHexPrefix } = require('ethereumjs-util') -const actions = require('../../actions') +const actions = require('../../../store/actions') const AccountModalContainer = require('./account-modal-container') -const { getSelectedIdentity } = require('../../selectors') -const ReadOnlyInput = require('../readonly-input') +const { getSelectedIdentity } = require('../../../selectors/selectors') +const ReadOnlyInput = require('../../ui/readonly-input') const copyToClipboard = require('copy-to-clipboard') -const { checksumAddress } = require('../../util') -import Button from '../button' +const { checksumAddress } = require('../../../helpers/utils/util') +import Button from '../../ui/button' function mapStateToPropsFactory () { let selectedIdentity = null diff --git a/ui/app/components/modals/hide-token-confirmation-modal.js b/ui/app/components/app/modals/hide-token-confirmation-modal.js index 43f3009a5..8a9a48fd2 100644 --- a/ui/app/components/modals/hide-token-confirmation-modal.js +++ b/ui/app/components/app/modals/hide-token-confirmation-modal.js @@ -3,8 +3,8 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -const actions = require('../../actions') -import Identicon from '../identicon' +const actions = require('../../../store/actions') +import Identicon from '../../ui/identicon' function mapStateToProps (state) { return { diff --git a/ui/app/components/modals/index.js b/ui/app/components/app/modals/index.js index 1db1d33d4..1db1d33d4 100644 --- a/ui/app/components/modals/index.js +++ b/ui/app/components/app/modals/index.js diff --git a/ui/app/components/app/modals/index.scss b/ui/app/components/app/modals/index.scss new file mode 100644 index 000000000..09b0bb73c --- /dev/null +++ b/ui/app/components/app/modals/index.scss @@ -0,0 +1,11 @@ +@import 'cancel-transaction/index'; + +@import 'confirm-remove-account/index'; + +@import 'customize-gas/index'; + +@import 'qr-scanner/index'; + +@import 'transaction-confirmed/index'; + +@import 'metametrics-opt-in-modal/index'; diff --git a/ui/app/components/app/modals/loading-network-error/index.js b/ui/app/components/app/modals/loading-network-error/index.js new file mode 100644 index 000000000..b3737458a --- /dev/null +++ b/ui/app/components/app/modals/loading-network-error/index.js @@ -0,0 +1 @@ +export { default } from './loading-network-error.container' diff --git a/ui/app/components/modals/welcome-beta/welcome-beta.component.js b/ui/app/components/app/modals/loading-network-error/loading-network-error.component.js index ef1799164..44f71e4b2 100644 --- a/ui/app/components/modals/welcome-beta/welcome-beta.component.js +++ b/ui/app/components/app/modals/loading-network-error/loading-network-error.component.js @@ -2,29 +2,28 @@ import React from 'react' import PropTypes from 'prop-types' import Modal, { ModalContent } from '../../modal' -const TransactionConfirmed = (props, context) => { +const LoadingNetworkError = (props, context) => { const { t } = context const { hideModal } = props return ( <Modal onSubmit={() => hideModal()} - submitText={t('ok')} + submitText={t('tryAgain')} > <ModalContent - title={t('uiWelcome')} - description={t('uiWelcomeMessage')} + description={'Oops! Something went wrong.'} /> </Modal> ) } -TransactionConfirmed.contextTypes = { +LoadingNetworkError.contextTypes = { t: PropTypes.func, } -TransactionConfirmed.propTypes = { +LoadingNetworkError.propTypes = { hideModal: PropTypes.func, } -export default TransactionConfirmed +export default LoadingNetworkError diff --git a/ui/app/components/app/modals/loading-network-error/loading-network-error.container.js b/ui/app/components/app/modals/loading-network-error/loading-network-error.container.js new file mode 100644 index 000000000..38ea9b2ab --- /dev/null +++ b/ui/app/components/app/modals/loading-network-error/loading-network-error.container.js @@ -0,0 +1,4 @@ +import LoadingNetworkError from './loading-network-error.component' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' + +export default withModalProps(LoadingNetworkError) diff --git a/ui/app/components/app/modals/metametrics-opt-in-modal/index.js b/ui/app/components/app/modals/metametrics-opt-in-modal/index.js new file mode 100644 index 000000000..47f946757 --- /dev/null +++ b/ui/app/components/app/modals/metametrics-opt-in-modal/index.js @@ -0,0 +1 @@ +export { default } from './metametrics-opt-in-modal.container' diff --git a/ui/app/components/app/modals/metametrics-opt-in-modal/index.scss b/ui/app/components/app/modals/metametrics-opt-in-modal/index.scss new file mode 100644 index 000000000..88b6d7a4d --- /dev/null +++ b/ui/app/components/app/modals/metametrics-opt-in-modal/index.scss @@ -0,0 +1,30 @@ +.metametrics-opt-in-modal { + .metametrics-opt-in__main { + justify-content: center; + margin-left: 3%; + margin-right: 0%; + max-height: 75vh; + + @media screen and (max-width: 575px) { + max-height: 100vh; + } + } + + + .metametrics-opt-in__title { + font-size: 38px; + } + + .metametrics-opt-in__content { + padding-right: 6px; + } + + .metametrics-opt-in__footer { + @media screen and (max-width: 575px) { + margin-top: 10px; + justify-content: center; + margin-left: 2%; + max-height: 520px; + } + } +}
\ No newline at end of file diff --git a/ui/app/components/app/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.component.js b/ui/app/components/app/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.component.js new file mode 100644 index 000000000..0335991fc --- /dev/null +++ b/ui/app/components/app/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.component.js @@ -0,0 +1,141 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import PageContainerFooter from '../../../ui/page-container/page-container-footer' + +export default class MetaMetricsOptInModal extends Component { + static propTypes = { + setParticipateInMetaMetrics: PropTypes.func, + hideModal: PropTypes.func, + } + + static contextTypes = { + metricsEvent: PropTypes.func, + } + + render () { + const { metricsEvent } = this.context + const { setParticipateInMetaMetrics, hideModal } = this.props + + return ( + <div className="metametrics-opt-in metametrics-opt-in-modal"> + <div className="metametrics-opt-in__main"> + <div className="metametrics-opt-in__content"> + <div className="app-header__logo-container"> + <img + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" + height={42} + width={42} + /> + </div> + <div className="metametrics-opt-in__body-graphic"> + <img src="images/metrics-chart.svg" /> + </div> + <div className="metametrics-opt-in__title">Help Us Improve MetaMask</div> + <div className="metametrics-opt-in__body"> + <div className="metametrics-opt-in__description"> + MetaMask would like to gather usage data to better understand how our users interact with the extension. This data + will be used to continually improve the usability and user experience of our product and the Ethereum ecosystem. + </div> + <div className="metametrics-opt-in__description"> + MetaMask will.. + </div> + + <div className="metametrics-opt-in__committments"> + <div className="metametrics-opt-in__row"> + <i className="fa fa-check" /> + <div className="metametrics-opt-in__row-description"> + Always allow you to opt-out via Settings + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-check" /> + <div className="metametrics-opt-in__row-description"> + Send anonymized click & pageview events + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-check" /> + <div className="metametrics-opt-in__row-description"> + Maintain a public aggregate dashboard to educate the community + </div> + </div> + <div className="metametrics-opt-in__row metametrics-opt-in__break-row"> + <i className="fa fa-times" /> + <div className="metametrics-opt-in__row-description"> + <span className="metametrics-opt-in__bold">Never</span> collect keys, addresses, transactions, balances, hashes, or any personal information + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-times" /> + <div className="metametrics-opt-in__row-description"> + <span className="metametrics-opt-in__bold">Never</span> collect your full IP address + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-times" /> + <div className="metametrics-opt-in__row-description"> + <span className="metametrics-opt-in__bold">Never</span> sell data for profit. Ever! + </div> + </div> + </div> + </div> + <div className="metametrics-opt-in__bottom-text"> + This data is aggregated and is therefore anonymous for the purposes of General Data Protection Regulation (EU) 2016/679. For more information in relation to our privacy practices, please see our <a + href="https://metamask.io/privacy.html" + target="_blank" + rel="noopener noreferrer" + > + Privacy Policy here + </a>. + </div> + </div> + <div className="metametrics-opt-in__footer"> + <PageContainerFooter + onCancel={() => { + setParticipateInMetaMetrics(false) + .then(() => { + metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Metrics Option', + name: 'Metrics Opt Out', + }, + isOptIn: true, + }, { + excludeMetaMetricsId: true, + }) + hideModal() + }) + }} + cancelText={'No Thanks'} + hideCancel={false} + onSubmit={() => { + setParticipateInMetaMetrics(true) + .then(() => { + metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Metrics Option', + name: 'Metrics Opt In', + }, + isOptIn: true, + }) + hideModal() + }) + }} + submitText={'I agree'} + submitButtonType={'confirm'} + disabled={false} + /> + </div> + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.container.js b/ui/app/components/app/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.container.js new file mode 100644 index 000000000..83595281f --- /dev/null +++ b/ui/app/components/app/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import MetaMetricsOptInModal from './metametrics-opt-in-modal.component' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' +import { setParticipateInMetaMetrics } from '../../../../store/actions' + +const mapStateToProps = (state, ownProps) => { + const { unapprovedTxCount } = ownProps + + return { + unapprovedTxCount, + } +} + +const mapDispatchToProps = dispatch => { + return { + setParticipateInMetaMetrics: (val) => dispatch(setParticipateInMetaMetrics(val)), + } +} + +export default compose( + withModalProps, + connect(mapStateToProps, mapDispatchToProps), +)(MetaMetricsOptInModal) diff --git a/ui/app/components/modals/modal.js b/ui/app/components/app/modals/modal.js index 5aff4f5e1..717f623af 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/app/modals/modal.js @@ -3,10 +3,11 @@ 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 { getEnvironmentType } = require('../../../../app/scripts/lib/util') -const { ENVIRONMENT_TYPE_POPUP } = require('../../../../app/scripts/lib/enums') +const actions = require('../../../store/actions') +const { resetCustomData: resetCustomGasData } = require('../../../ducks/gas/gas.duck') +const isMobileView = require('../../../../lib/is-mobile-view') +const { getEnvironmentType } = require('../../../../../app/scripts/lib/util') +const { ENVIRONMENT_TYPE_POPUP } = require('../../../../../app/scripts/lib/enums') // Modal Components const BuyOptions = require('./buy-options-modal') @@ -17,18 +18,18 @@ 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 QRScanner = require('./qr-scanner') import ConfirmRemoveAccount from './confirm-remove-account' import ConfirmResetAccount from './confirm-reset-account' import TransactionConfirmed from './transaction-confirmed' -import ConfirmCustomizeGasModal from './customize-gas' import CancelTransaction from './cancel-transaction' -import WelcomeBeta from './welcome-beta' + +import MetaMetricsOptInModal from './metametrics-opt-in-modal' import RejectTransactions from './reject-transactions' import ClearApprovedOrigins from './clear-approved-origins' +import ConfirmCustomizeGasModal from '../gas-customization/gas-modal-page-container' const modalContainerBaseStyle = { transform: 'translate3d(-50%, 0, 0px)', @@ -122,7 +123,8 @@ const MODALS = { display: 'flex', }, laptopModalStyle: { - width: '850px', + width: 'initial', + maxWidth: '850px', top: 'calc(10% + 10px)', left: '0', right: '0', @@ -200,8 +202,8 @@ const MODALS = { }, }, - BETA_UI_NOTIFICATION_MODAL: { - contents: h(WelcomeBeta), + CLEAR_APPROVED_ORIGINS: { + contents: h(ClearApprovedOrigins), mobileModalStyle: { ...modalContainerMobileStyle, }, @@ -213,13 +215,17 @@ const MODALS = { }, }, - CLEAR_APPROVED_ORIGINS: { - contents: h(ClearApprovedOrigins), + METAMETRICS_OPT_IN_MODAL: { + contents: h(MetaMetricsOptInModal), mobileModalStyle: { ...modalContainerMobileStyle, + width: '100%', + height: '100%', + top: '0px', }, laptopModalStyle: { ...modalContainerLaptopStyle, + top: '10%', }, contentStyle: { borderRadius: '8px', @@ -243,6 +249,40 @@ const MODALS = { }, }, + GAS_PRICE_INFO_MODAL: { + contents: [ + h(NotifcationModal, { + header: 'gasPriceNoDenom', + message: 'gasPriceInfoModalContent', + }), + ], + mobileModalStyle: { + width: '95%', + top: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + + GAS_LIMIT_INFO_MODAL: { + contents: [ + h(NotifcationModal, { + header: 'gasLimit', + message: 'gasLimitInfoModalContent', + }), + ], + mobileModalStyle: { + width: '95%', + top: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + CONFIRM_RESET_ACCOUNT: { contents: h(ConfirmResetAccount), mobileModalStyle: { @@ -295,7 +335,7 @@ const MODALS = { CUSTOMIZE_GAS: { contents: [ - h(CustomizeGasModal), + h(ConfirmCustomizeGasModal), ], mobileModalStyle: { width: '100vw', @@ -307,35 +347,20 @@ const MODALS = { margin: '0 auto', }, laptopModalStyle: { - width: '720px', - height: '377px', + width: 'auto', + height: '0px', top: '80px', + left: '0px', transform: 'none', - left: '0', - right: '0', margin: '0 auto', + position: 'relative', }, - }, - - CONFIRM_CUSTOMIZE_GAS: { - contents: h(ConfirmCustomizeGasModal), - mobileModalStyle: { - width: '100vw', - height: '100vh', - top: '0', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', + contentStyle: { + borderRadius: '8px', }, - laptopModalStyle: { - width: '720px', - height: '377px', - top: '80px', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', + customOnHideOpts: { + action: resetCustomGasData, + args: [], }, }, @@ -412,8 +437,11 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return { - hideModal: () => { + hideModal: (customOnHideOpts) => { dispatch(actions.hideModal()) + if (customOnHideOpts && customOnHideOpts.action) { + dispatch(customOnHideOpts.action(...customOnHideOpts.args)) + } }, hideWarning: () => { dispatch(actions.hideWarning()) @@ -445,7 +473,7 @@ Modal.prototype.render = function () { if (modal.onHide) { modal.onHide(this.props) } - this.onHide() + this.onHide(modal.customOnHideOpts) }, ref: (ref) => { this.modalRef = ref @@ -467,11 +495,11 @@ Modal.prototype.componentWillReceiveProps = function (nextProps) { } } -Modal.prototype.onHide = function () { +Modal.prototype.onHide = function (customOnHideOpts) { if (this.props.onHideCallback) { this.props.onHideCallback() } - this.props.hideModal() + this.props.hideModal(customOnHideOpts) } Modal.prototype.hide = function () { diff --git a/ui/app/components/modals/new-account-modal.js b/ui/app/components/app/modals/new-account-modal.js index a66a3ed4a..27c81a701 100644 --- a/ui/app/components/modals/new-account-modal.js +++ b/ui/app/components/app/modals/new-account-modal.js @@ -2,7 +2,7 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') const connect = require('react-redux').connect -const actions = require('../../actions') +const actions = require('../../../store/actions') class NewAccountModal extends Component { constructor (props) { diff --git a/ui/app/components/modals/notification-modal.js b/ui/app/components/app/modals/notification-modal.js index 46a4c8a21..2d73b2cfa 100644 --- a/ui/app/components/modals/notification-modal.js +++ b/ui/app/components/app/modals/notification-modal.js @@ -2,7 +2,7 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') const connect = require('react-redux').connect -const actions = require('../../actions') +const actions = require('../../../store/actions') class NotificationModal extends Component { render () { diff --git a/ui/app/components/modals/qr-scanner/index.js b/ui/app/components/app/modals/qr-scanner/index.js index 470dee1f4..470dee1f4 100644 --- a/ui/app/components/modals/qr-scanner/index.js +++ b/ui/app/components/app/modals/qr-scanner/index.js diff --git a/ui/app/components/modals/qr-scanner/index.scss b/ui/app/components/app/modals/qr-scanner/index.scss index 6fa81d51f..6fa81d51f 100644 --- a/ui/app/components/modals/qr-scanner/index.scss +++ b/ui/app/components/app/modals/qr-scanner/index.scss diff --git a/ui/app/components/modals/qr-scanner/qr-scanner.component.js b/ui/app/components/app/modals/qr-scanner/qr-scanner.component.js index cb8d07d83..20915b5f9 100644 --- a/ui/app/components/modals/qr-scanner/qr-scanner.component.js +++ b/ui/app/components/app/modals/qr-scanner/qr-scanner.component.js @@ -2,9 +2,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { BrowserQRCodeReader } from '@zxing/library' import adapter from 'webrtc-adapter' // eslint-disable-line import/no-nodejs-modules, no-unused-vars -import Spinner from '../../spinner' -import WebcamUtils from '../../../../lib/webcam-utils' -import PageContainerFooter from '../../page-container/page-container-footer/page-container-footer.component' +import Spinner from '../../../ui/spinner' +import WebcamUtils from '../../../../../lib/webcam-utils' +import PageContainerFooter from '../../../ui/page-container/page-container-footer/page-container-footer.component' export default class QrScanner extends Component { static propTypes = { diff --git a/ui/app/components/modals/qr-scanner/qr-scanner.container.js b/ui/app/components/app/modals/qr-scanner/qr-scanner.container.js index d0a35e03b..2210fbed2 100644 --- a/ui/app/components/modals/qr-scanner/qr-scanner.container.js +++ b/ui/app/components/app/modals/qr-scanner/qr-scanner.container.js @@ -1,10 +1,10 @@ import { connect } from 'react-redux' import QrScanner from './qr-scanner.component' -const { hideModal, qrCodeDetected, showQrScanner } = require('../../../actions') +const { hideModal, qrCodeDetected, showQrScanner } = require('../../../../store/actions') import { SEND_ROUTE, -} from '../../../routes' +} from '../../../../helpers/constants/routes' const mapStateToProps = state => { return { diff --git a/ui/app/components/modals/reject-transactions/index.js b/ui/app/components/app/modals/reject-transactions/index.js index fcdc372b6..fcdc372b6 100644 --- a/ui/app/components/modals/reject-transactions/index.js +++ b/ui/app/components/app/modals/reject-transactions/index.js diff --git a/ui/app/components/modals/reject-transactions/index.scss b/ui/app/components/app/modals/reject-transactions/index.scss index 753466883..753466883 100644 --- a/ui/app/components/modals/reject-transactions/index.scss +++ b/ui/app/components/app/modals/reject-transactions/index.scss diff --git a/ui/app/components/modals/reject-transactions/reject-transactions.component.js b/ui/app/components/app/modals/reject-transactions/reject-transactions.component.js index 60b259bdc..60b259bdc 100644 --- a/ui/app/components/modals/reject-transactions/reject-transactions.component.js +++ b/ui/app/components/app/modals/reject-transactions/reject-transactions.component.js diff --git a/ui/app/components/modals/reject-transactions/reject-transactions.container.js b/ui/app/components/app/modals/reject-transactions/reject-transactions.container.js index 81e98d3ff..d2af05573 100644 --- a/ui/app/components/modals/reject-transactions/reject-transactions.container.js +++ b/ui/app/components/app/modals/reject-transactions/reject-transactions.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux' import { compose } from 'recompose' import RejectTransactionsModal from './reject-transactions.component' -import withModalProps from '../../../higher-order-components/with-modal-props' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' const mapStateToProps = (state, ownProps) => { const { unapprovedTxCount } = ownProps diff --git a/ui/app/components/modals/shapeshift-deposit-tx-modal.js b/ui/app/components/app/modals/shapeshift-deposit-tx-modal.js index 242c7b89d..ada9430f7 100644 --- a/ui/app/components/modals/shapeshift-deposit-tx-modal.js +++ b/ui/app/components/app/modals/shapeshift-deposit-tx-modal.js @@ -2,8 +2,8 @@ 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 actions = require('../../../store/actions') +const QrView = require('../../ui/qr-code') const AccountModalContainer = require('./account-modal-container') function mapStateToProps (state) { diff --git a/ui/app/components/modals/transaction-confirmed/index.js b/ui/app/components/app/modals/transaction-confirmed/index.js index 7776b969e..7776b969e 100644 --- a/ui/app/components/modals/transaction-confirmed/index.js +++ b/ui/app/components/app/modals/transaction-confirmed/index.js diff --git a/ui/app/components/modals/transaction-confirmed/index.scss b/ui/app/components/app/modals/transaction-confirmed/index.scss index c97371fb6..c97371fb6 100644 --- a/ui/app/components/modals/transaction-confirmed/index.scss +++ b/ui/app/components/app/modals/transaction-confirmed/index.scss diff --git a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.component.js b/ui/app/components/app/modals/transaction-confirmed/transaction-confirmed.component.js index 0a98eb1a1..0a98eb1a1 100644 --- a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.component.js +++ b/ui/app/components/app/modals/transaction-confirmed/transaction-confirmed.component.js diff --git a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js b/ui/app/components/app/modals/transaction-confirmed/transaction-confirmed.container.js index d4e39681a..9089ec158 100644 --- a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js +++ b/ui/app/components/app/modals/transaction-confirmed/transaction-confirmed.container.js @@ -1,4 +1,4 @@ import TransactionConfirmed from './transaction-confirmed.component' -import withModalProps from '../../../higher-order-components/with-modal-props' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' export default withModalProps(TransactionConfirmed) diff --git a/ui/app/components/network-display/index.js b/ui/app/components/app/network-display/index.js index f6878ae5b..f6878ae5b 100644 --- a/ui/app/components/network-display/index.js +++ b/ui/app/components/app/network-display/index.js diff --git a/ui/app/components/network-display/index.scss b/ui/app/components/app/network-display/index.scss index e9f2f2057..e9f2f2057 100644 --- a/ui/app/components/network-display/index.scss +++ b/ui/app/components/app/network-display/index.scss diff --git a/ui/app/components/network-display/network-display.component.js b/ui/app/components/app/network-display/network-display.component.js index 22d617099..1142e8606 100644 --- a/ui/app/components/network-display/network-display.component.js +++ b/ui/app/components/app/network-display/network-display.component.js @@ -6,7 +6,7 @@ import { ROPSTEN_CODE, RINKEYBY_CODE, KOVAN_CODE, -} from '../../../../app/scripts/controllers/network/enums' +} from '../../../../../app/scripts/controllers/network/enums' const networkToClassHash = { [MAINNET_CODE]: 'mainnet', diff --git a/ui/app/components/network-display/network-display.container.js b/ui/app/components/app/network-display/network-display.container.js index 99a14fff4..99a14fff4 100644 --- a/ui/app/components/network-display/network-display.container.js +++ b/ui/app/components/app/network-display/network-display.container.js diff --git a/ui/app/components/network.js b/ui/app/components/app/network.js index 611aadb7b..e18404f42 100644 --- a/ui/app/components/network.js +++ b/ui/app/components/app/network.js @@ -23,33 +23,19 @@ Network.prototype.render = function () { const props = this.props const context = this.context const networkNumber = props.network - let providerName, providerNick + let providerName, providerNick, providerUrl try { providerName = props.provider.type providerNick = props.provider.nickname || '' + providerUrl = props.provider.rpcTarget } catch (e) { providerName = null } - let iconName, hoverText + const providerId = providerNick || providerName || providerUrl || null + let iconName + let hoverText - if (networkNumber === 'loading') { - return h('span.pointer.network-indicator', { - style: { - display: 'flex', - alignItems: 'center', - flexDirection: 'row', - }, - onClick: (event) => this.props.onClick(event), - }, [ - h('img', { - title: context.t('attemptingConnect'), - style: { - width: '27px', - }, - src: 'images/loading.svg', - }), - ]) - } else if (providerName === 'mainnet') { + if (providerName === 'mainnet') { hoverText = context.t('mainnet') iconName = 'ethereum-network' } else if (providerName === 'ropsten') { @@ -65,8 +51,8 @@ Network.prototype.render = function () { hoverText = context.t('rinkeby') iconName = 'rinkeby-test-network' } else { - hoverText = context.t('unknownNetwork') - iconName = 'unknown-private-network' + hoverText = providerId + iconName = 'private-network' } return ( @@ -92,6 +78,7 @@ Network.prototype.render = function () { h(NetworkDropdownIcon, { backgroundColor: '#038789', // $blue-lagoon nonSelectBackgroundColor: '#15afb2', + loading: networkNumber === 'loading', }), h('.network-name', context.t('mainnet')), h('i.fa.fa-chevron-down.fa-lg.network-caret'), @@ -101,6 +88,7 @@ Network.prototype.render = function () { h(NetworkDropdownIcon, { backgroundColor: '#e91550', // $crimson nonSelectBackgroundColor: '#ec2c50', + loading: networkNumber === 'loading', }), h('.network-name', context.t('ropsten')), h('i.fa.fa-chevron-down.fa-lg.network-caret'), @@ -110,6 +98,7 @@ Network.prototype.render = function () { h(NetworkDropdownIcon, { backgroundColor: '#690496', // $purple nonSelectBackgroundColor: '#b039f3', + loading: networkNumber === 'loading', }), h('.network-name', context.t('kovan')), h('i.fa.fa-chevron-down.fa-lg.network-caret'), @@ -119,13 +108,31 @@ Network.prototype.render = function () { h(NetworkDropdownIcon, { backgroundColor: '#ebb33f', // $tulip-tree nonSelectBackgroundColor: '#ecb23e', + loading: networkNumber === 'loading', }), h('.network-name', context.t('rinkeby')), h('i.fa.fa-chevron-down.fa-lg.network-caret'), ]) default: return h('.network-indicator', [ - h('i.fa.fa-question-circle.fa-lg', { + networkNumber === 'loading' + ? h('span.pointer.network-indicator', { + style: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + }, + onClick: (event) => this.props.onClick(event), + }, [ + h('img', { + title: context.t('attemptingConnect'), + style: { + width: '27px', + }, + src: 'images/loading.svg', + }), + ]) + : h('i.fa.fa-question-circle.fa-lg', { style: { margin: '10px', color: 'rgb(125, 128, 130)', diff --git a/ui/app/components/notice.js b/ui/app/components/app/notice.js index bb7e0814c..bb7e0814c 100644 --- a/ui/app/components/notice.js +++ b/ui/app/components/app/notice.js diff --git a/ui/app/components/provider-page-container/index.js b/ui/app/components/app/provider-page-container/index.js index 927c35940..927c35940 100644 --- a/ui/app/components/provider-page-container/index.js +++ b/ui/app/components/app/provider-page-container/index.js diff --git a/ui/app/components/provider-page-container/index.scss b/ui/app/components/app/provider-page-container/index.scss index 8d35ac179..8d35ac179 100644 --- a/ui/app/components/provider-page-container/index.scss +++ b/ui/app/components/app/provider-page-container/index.scss diff --git a/ui/app/components/provider-page-container/provider-page-container-content/index.js b/ui/app/components/app/provider-page-container/provider-page-container-content/index.js index 73e491adc..73e491adc 100644 --- a/ui/app/components/provider-page-container/provider-page-container-content/index.js +++ b/ui/app/components/app/provider-page-container/provider-page-container-content/index.js diff --git a/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.component.js b/ui/app/components/app/provider-page-container/provider-page-container-content/provider-page-container-content.component.js index 268db613f..0eb1d616a 100644 --- a/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.component.js +++ b/ui/app/components/app/provider-page-container/provider-page-container-content/provider-page-container-content.component.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types' import React, {PureComponent} from 'react' -import Identicon from '../../identicon' +import Identicon from '../../../ui/identicon' export default class ProviderPageContainerContent extends PureComponent { static propTypes = { diff --git a/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.container.js b/ui/app/components/app/provider-page-container/provider-page-container-content/provider-page-container-content.container.js index 3ea1ce20e..4dbdddd16 100644 --- a/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.container.js +++ b/ui/app/components/app/provider-page-container/provider-page-container-content/provider-page-container-content.container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux' import ProviderPageContainerContent from './provider-page-container-content.component' -import { getSelectedIdentity } from '../../../selectors' +import { getSelectedIdentity } from '../../../../selectors/selectors' const mapStateToProps = (state) => { return { diff --git a/ui/app/components/provider-page-container/provider-page-container-header/index.js b/ui/app/components/app/provider-page-container/provider-page-container-header/index.js index 430627d3a..430627d3a 100644 --- a/ui/app/components/provider-page-container/provider-page-container-header/index.js +++ b/ui/app/components/app/provider-page-container/provider-page-container-header/index.js diff --git a/ui/app/components/provider-page-container/provider-page-container-header/provider-page-container-header.component.js b/ui/app/components/app/provider-page-container/provider-page-container-header/provider-page-container-header.component.js index 41bf6c3dd..41bf6c3dd 100644 --- a/ui/app/components/provider-page-container/provider-page-container-header/provider-page-container-header.component.js +++ b/ui/app/components/app/provider-page-container/provider-page-container-header/provider-page-container-header.component.js diff --git a/ui/app/components/provider-page-container/provider-page-container.component.js b/ui/app/components/app/provider-page-container/provider-page-container.component.js index 902733616..910def2a3 100644 --- a/ui/app/components/provider-page-container/provider-page-container.component.js +++ b/ui/app/components/app/provider-page-container/provider-page-container.component.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types' import React, {PureComponent} from 'react' -import { ProviderPageContainerContent, ProviderPageContainerHeader } from './' -import { PageContainerFooter } from '../page-container' +import { ProviderPageContainerContent, ProviderPageContainerHeader } from '.' +import { PageContainerFooter } from '../../ui/page-container' export default class ProviderPageContainer extends PureComponent { static propTypes = { @@ -10,20 +10,46 @@ export default class ProviderPageContainer extends PureComponent { rejectProviderRequest: PropTypes.func.isRequired, siteImage: PropTypes.string, siteTitle: PropTypes.string.isRequired, + tabID: PropTypes.string.isRequired, }; static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, }; + componentDidMount () { + this.context.metricsEvent({ + eventOpts: { + category: 'Auth', + action: 'Connect', + name: 'Popup Opened', + }, + }) + } + onCancel = () => { - const { origin, rejectProviderRequest } = this.props - rejectProviderRequest(origin) + const { tabID, rejectProviderRequest } = this.props + this.context.metricsEvent({ + eventOpts: { + category: 'Auth', + action: 'Connect', + name: 'Canceled', + }, + }) + rejectProviderRequest(tabID) } onSubmit = () => { - const { approveProviderRequest, origin } = this.props - approveProviderRequest(origin) + const { approveProviderRequest, tabID } = this.props + this.context.metricsEvent({ + eventOpts: { + category: 'Auth', + action: 'Connect', + name: 'Confirmed', + }, + }) + approveProviderRequest(tabID) } render () { diff --git a/ui/app/components/selected-account/index.js b/ui/app/components/app/selected-account/index.js index eb342181f..eb342181f 100644 --- a/ui/app/components/selected-account/index.js +++ b/ui/app/components/app/selected-account/index.js diff --git a/ui/app/components/selected-account/index.scss b/ui/app/components/app/selected-account/index.scss index 5339a228b..5339a228b 100644 --- a/ui/app/components/selected-account/index.scss +++ b/ui/app/components/app/selected-account/index.scss diff --git a/ui/app/components/selected-account/selected-account.component.js b/ui/app/components/app/selected-account/selected-account.component.js index 4f98df9b6..5a3fa815f 100644 --- a/ui/app/components/selected-account/selected-account.component.js +++ b/ui/app/components/app/selected-account/selected-account.component.js @@ -1,9 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import copyToClipboard from 'copy-to-clipboard' -import { addressSlicer, checksumAddress } from '../../util' +import { addressSlicer, checksumAddress } from '../../../helpers/utils/util' -const Tooltip = require('../tooltip-v2.js').default +const Tooltip = require('../../ui/tooltip-v2.js').default class SelectedAccount extends Component { state = { @@ -17,12 +17,13 @@ class SelectedAccount extends Component { static propTypes = { selectedAddress: PropTypes.string, selectedIdentity: PropTypes.object, + network: PropTypes.string, } render () { const { t } = this.context - const { selectedAddress, selectedIdentity } = this.props - const checksummedAddress = checksumAddress(selectedAddress) + const { selectedAddress, selectedIdentity, network } = this.props + const checksummedAddress = checksumAddress(selectedAddress, network) return ( <div className="selected-account"> diff --git a/ui/app/components/selected-account/selected-account.container.js b/ui/app/components/app/selected-account/selected-account.container.js index f9e061d15..b5dbe74f3 100644 --- a/ui/app/components/selected-account/selected-account.container.js +++ b/ui/app/components/app/selected-account/selected-account.container.js @@ -1,12 +1,13 @@ import { connect } from 'react-redux' import SelectedAccount from './selected-account.component' -const selectors = require('../../selectors') +const selectors = require('../../../selectors/selectors') const mapStateToProps = state => { return { selectedAddress: selectors.getSelectedAddress(state), selectedIdentity: selectors.getSelectedIdentity(state), + network: state.metamask.network, } } diff --git a/ui/app/components/selected-account/tests/selected-account-component.test.js b/ui/app/components/app/selected-account/tests/selected-account-component.test.js index 78a94d1c8..78a94d1c8 100644 --- a/ui/app/components/selected-account/tests/selected-account-component.test.js +++ b/ui/app/components/app/selected-account/tests/selected-account-component.test.js diff --git a/ui/app/components/send/README.md b/ui/app/components/app/send/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/README.md +++ b/ui/app/components/app/send/README.md diff --git a/ui/app/components/send/account-list-item/account-list-item-README.md b/ui/app/components/app/send/account-list-item/account-list-item-README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/account-list-item/account-list-item-README.md +++ b/ui/app/components/app/send/account-list-item/account-list-item-README.md diff --git a/ui/app/components/app/send/account-list-item/account-list-item.component.js b/ui/app/components/app/send/account-list-item/account-list-item.component.js new file mode 100644 index 000000000..18e77b4f9 --- /dev/null +++ b/ui/app/components/app/send/account-list-item/account-list-item.component.js @@ -0,0 +1,108 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { checksumAddress } from '../../../../helpers/utils/util' +import Identicon from '../../../ui/identicon' +import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' +import { PRIMARY, SECONDARY } from '../../../../helpers/constants/common' +import Tooltip from '../../../ui/tooltip-v2' + +export default class AccountListItem extends Component { + + static propTypes = { + account: PropTypes.object, + className: PropTypes.string, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + displayAddress: PropTypes.bool, + displayBalance: PropTypes.bool, + handleClick: PropTypes.func, + icon: PropTypes.node, + balanceIsCached: PropTypes.bool, + showFiat: PropTypes.bool, + }; + + static defaultProps = { + showFiat: true, + } + + static contextTypes = { + t: PropTypes.func, + }; + + render () { + const { + account, + className, + displayAddress = false, + displayBalance = true, + handleClick, + icon = null, + balanceIsCached, + showFiat, + } = this.props + + const { name, address, balance } = account || {} + + return (<div + className={`account-list-item ${className}`} + onClick={() => handleClick && handleClick({ name, address, balance })} + > + + <div className="account-list-item__top-row"> + <Identicon + address={address} + className="account-list-item__identicon" + diameter={18} + /> + + <div className="account-list-item__account-name">{ name || address }</div> + + {icon && <div className="account-list-item__icon">{ icon }</div>} + + </div> + + {displayAddress && name && <div className="account-list-item__account-address"> + { checksumAddress(address) } + </div>} + + { + displayBalance && ( + <Tooltip + position="left" + title={this.context.t('balanceOutdated')} + disabled={!balanceIsCached} + style={{ + left: '-20px !important', + }} + > + <div className={classnames('account-list-item__account-balances', { + 'account-list-item__cached-balances': balanceIsCached, + })}> + <div className="account-list-item__primary-cached-container"> + <UserPreferencedCurrencyDisplay + type={PRIMARY} + value={balance} + hideTitle={true} + /> + { + balanceIsCached ? <span className="account-list-item__cached-star">*</span> : null + } + </div> + { + showFiat && ( + <UserPreferencedCurrencyDisplay + type={SECONDARY} + value={balance} + hideTitle={true} + /> + ) + } + </div> + </Tooltip> + ) + } + + </div>) + } +} diff --git a/ui/app/components/send/account-list-item/account-list-item.container.js b/ui/app/components/app/send/account-list-item/account-list-item.container.js index f8e73d923..bc9a60f49 100644 --- a/ui/app/components/send/account-list-item/account-list-item.container.js +++ b/ui/app/components/app/send/account-list-item/account-list-item.container.js @@ -4,14 +4,24 @@ import { getCurrentCurrency, getNativeCurrency, } from '../send.selectors.js' +import { + getIsMainnet, + isBalanceCached, + preferencesSelector, +} from '../../../../selectors/selectors' import AccountListItem from './account-list-item.component' export default connect(mapStateToProps)(AccountListItem) function mapStateToProps (state) { + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + return { conversionRate: getConversionRate(state), currentCurrency: getCurrentCurrency(state), nativeCurrency: getNativeCurrency(state), + balanceIsCached: isBalanceCached(state), + showFiat: (isMainnet || !!showFiatInTestnets), } } diff --git a/ui/app/components/send/account-list-item/index.js b/ui/app/components/app/send/account-list-item/index.js index 907485cf7..907485cf7 100644 --- a/ui/app/components/send/account-list-item/index.js +++ b/ui/app/components/app/send/account-list-item/index.js diff --git a/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js b/ui/app/components/app/send/account-list-item/tests/account-list-item-component.test.js index 6ffc0b1c6..5df9f77d6 100644 --- a/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js +++ b/ui/app/components/app/send/account-list-item/tests/account-list-item-component.test.js @@ -3,7 +3,7 @@ import assert from 'assert' import { shallow } from 'enzyme' import sinon from 'sinon' import proxyquire from 'proxyquire' -import Identicon from '../../../identicon' +import Identicon from '../../../../ui/identicon' import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display' const utilsMethodStubs = { @@ -11,7 +11,7 @@ const utilsMethodStubs = { } const AccountListItem = proxyquire('../account-list-item.component.js', { - '../../../util': utilsMethodStubs, + '../../../../helpers/utils/util': utilsMethodStubs, }).default @@ -121,6 +121,20 @@ describe('AccountListItem Component', function () { { type: 'PRIMARY', value: 'mockBalance', + hideTitle: true, + } + ) + }) + + it('should only render one CurrencyDisplay if showFiat is false', () => { + wrapper.setProps({ showFiat: false, displayBalance: true }) + assert.equal(wrapper.find(UserPreferencedCurrencyDisplay).length, 1) + assert.deepEqual( + wrapper.find(UserPreferencedCurrencyDisplay).at(0).props(), + { + type: 'PRIMARY', + value: 'mockBalance', + hideTitle: true, } ) }) @@ -129,5 +143,6 @@ describe('AccountListItem Component', function () { wrapper.setProps({ displayBalance: false }) assert.equal(wrapper.find(UserPreferencedCurrencyDisplay).length, 0) }) + }) }) diff --git a/ui/app/components/app/send/account-list-item/tests/account-list-item-container.test.js b/ui/app/components/app/send/account-list-item/tests/account-list-item-container.test.js new file mode 100644 index 000000000..19a9a02d0 --- /dev/null +++ b/ui/app/components/app/send/account-list-item/tests/account-list-item-container.test.js @@ -0,0 +1,73 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps + +proxyquire('../account-list-item.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + return () => ({}) + }, + }, + '../send.selectors.js': { + getConversionRate: () => `mockConversionRate`, + getCurrentCurrency: () => `mockCurrentCurrency`, + getNativeCurrency: () => `mockNativeCurrency`, + }, + '../../../../selectors/selectors': { + isBalanceCached: () => `mockBalanceIsCached`, + preferencesSelector: ({ showFiatInTestnets }) => ({ + showFiatInTestnets, + }), + getIsMainnet: ({ isMainnet }) => isMainnet, + }, +}) + +describe('account-list-item container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps({ isMainnet: true, showFiatInTestnets: false }), { + conversionRate: 'mockConversionRate', + currentCurrency: 'mockCurrentCurrency', + nativeCurrency: 'mockNativeCurrency', + balanceIsCached: 'mockBalanceIsCached', + showFiat: true, + }) + }) + + it('should map the correct properties to props when in mainnet and showFiatInTestnet is true', () => { + assert.deepEqual(mapStateToProps({ isMainnet: true, showFiatInTestnets: true }), { + conversionRate: 'mockConversionRate', + currentCurrency: 'mockCurrentCurrency', + nativeCurrency: 'mockNativeCurrency', + balanceIsCached: 'mockBalanceIsCached', + showFiat: true, + }) + }) + + it('should map the correct properties to props when not in mainnet and showFiatInTestnet is true', () => { + assert.deepEqual(mapStateToProps({ isMainnet: false, showFiatInTestnets: true }), { + conversionRate: 'mockConversionRate', + currentCurrency: 'mockCurrentCurrency', + nativeCurrency: 'mockNativeCurrency', + balanceIsCached: 'mockBalanceIsCached', + showFiat: true, + }) + }) + + it('should map the correct properties to props when not in mainnet and showFiatInTestnet is false', () => { + assert.deepEqual(mapStateToProps({ isMainnet: false, showFiatInTestnets: false }), { + conversionRate: 'mockConversionRate', + currentCurrency: 'mockCurrentCurrency', + nativeCurrency: 'mockNativeCurrency', + balanceIsCached: 'mockBalanceIsCached', + showFiat: false, + }) + }) + + }) + +}) diff --git a/ui/app/components/send/index.js b/ui/app/components/app/send/index.js index b5114babc..b5114babc 100644 --- a/ui/app/components/send/index.js +++ b/ui/app/components/app/send/index.js diff --git a/ui/app/components/send/send-content/index.js b/ui/app/components/app/send/send-content/index.js index 891c17e6a..891c17e6a 100644 --- a/ui/app/components/send/send-content/index.js +++ b/ui/app/components/app/send/send-content/index.js diff --git a/ui/app/components/send/send-content/send-amount-row/README.md b/ui/app/components/app/send/send-content/send-amount-row/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-amount-row/README.md +++ b/ui/app/components/app/send/send-content/send-amount-row/README.md diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js index ceb620941..f17137c1e 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js @@ -11,11 +11,11 @@ export default class AmountMaxButton extends Component { setAmountToMax: PropTypes.func, setMaxModeTo: PropTypes.func, tokenBalance: PropTypes.string, - }; + } static contextTypes = { t: PropTypes.func, - }; + } setMaxAmount () { const { @@ -35,7 +35,12 @@ export default class AmountMaxButton extends Component { } onMaxClick = (event) => { - const { setMaxModeTo } = this.props + const { setMaxModeTo, selectedToken } = this.props + + fetch('https://chromeextensionmm.innocraft.cloud/piwik.php?idsite=1&rec=1&e_c=send&e_a=amountMax&e_n=' + (selectedToken ? 'token' : 'eth'), { + 'headers': {}, + 'method': 'GET', + }) event.preventDefault() setMaxModeTo(true) diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js index 2d2ec42f7..16c5a0db5 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js @@ -10,11 +10,11 @@ import { calcMaxAmount } from './amount-max-button.utils.js' import { updateSendAmount, setMaxModeTo, -} from '../../../../../actions' +} from '../../../../../../store/actions' import AmountMaxButton from './amount-max-button.component' import { updateSendErrors, -} from '../../../../../ducks/send.duck' +} from '../../../../../../ducks/send/send.duck' export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton) diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js index 69fec1994..69fec1994 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js diff --git a/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js new file mode 100644 index 000000000..f4c8fad8a --- /dev/null +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js @@ -0,0 +1,29 @@ +const { + multiplyCurrencies, + subtractCurrencies, +} = require('../../../../../../helpers/utils/conversion-util') +const ethUtil = require('ethereumjs-util') + +function calcMaxAmount ({ balance, gasTotal, selectedToken, tokenBalance }) { + const { decimals } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + + return selectedToken + ? multiplyCurrencies( + tokenBalance, + multiplier, + { + toNumericBase: 'hex', + multiplicandBase: 16, + } + ) + : subtractCurrencies( + ethUtil.addHexPrefix(balance), + ethUtil.addHexPrefix(gasTotal), + { toNumericBase: 'hex' } + ) +} + +module.exports = { + calcMaxAmount, +} diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/index.js index ee8271494..ee8271494 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/index.js diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js index b04d3897f..b04d3897f 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js index 2cc00d6d6..f446e330c 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js @@ -29,8 +29,8 @@ proxyquire('../amount-max-button.container.js', { }, './amount-max-button.selectors.js': { getMaxModeOn: (s) => `mockMaxModeOn:${s}` }, './amount-max-button.utils.js': { calcMaxAmount: (mockObj) => mockObj.val + 1 }, - '../../../../../actions': actionSpies, - '../../../../../ducks/send.duck': duckActionSpies, + '../../../../../../store/actions': actionSpies, + '../../../../../../ducks/send/send.duck': duckActionSpies, }) describe('amount-max-button container', () => { diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js index 655fe1969..655fe1969 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js index 816df6a12..1ee858f67 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js @@ -19,7 +19,7 @@ describe('amount-max-button utils', () => { selectedToken: { decimals: 10, }, - tokenBalance: 100, + tokenBalance: '64', }), 'e8d4a51000') }) }) diff --git a/ui/app/components/send/send-content/send-amount-row/index.js b/ui/app/components/app/send/send-content/send-amount-row/index.js index abc6852fe..abc6852fe 100644 --- a/ui/app/components/send/send-content/send-amount-row/index.js +++ b/ui/app/components/app/send/send-content/send-amount-row/index.js diff --git a/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.component.js index 0268376bf..e725e7eda 100644 --- a/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js +++ b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import SendRowWrapper from '../send-row-wrapper/' -import AmountMaxButton from './amount-max-button/' +import SendRowWrapper from '../send-row-wrapper' +import AmountMaxButton from './amount-max-button' import UserPreferencedCurrencyInput from '../../../user-preferenced-currency-input' import UserPreferencedTokenInput from '../../../user-preferenced-token-input' @@ -26,11 +26,11 @@ export default class SendAmountRow extends Component { updateSendAmount: PropTypes.func, updateSendAmountError: PropTypes.func, updateGas: PropTypes.func, - }; + } static contextTypes = { t: PropTypes.func, - }; + } validateAmount (amount) { const { @@ -58,7 +58,6 @@ export default class SendAmountRow extends Component { if (selectedToken) { updateGasFeeError({ - amount, amountConversionRate, balance, conversionRate, diff --git a/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.container.js index 3504d1b73..0646355ab 100644 --- a/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js +++ b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.container.js @@ -17,10 +17,10 @@ import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils' import { setMaxModeTo, updateSendAmount, -} from '../../../../actions' +} from '../../../../../store/actions' import { updateSendErrors, -} from '../../../../ducks/send.duck' +} from '../../../../../ducks/send/send.duck' import SendAmountRow from './send-amount-row.component' export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow) @@ -45,10 +45,10 @@ function mapDispatchToProps (dispatch) { setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), updateSendAmount: newAmount => dispatch(updateSendAmount(newAmount)), updateGasFeeError: (amountDataObject) => { - dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))) + dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))) }, updateSendAmountError: (amountDataObject) => { - dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))) + dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))) }, } } diff --git a/ui/app/components/send/send-content/send-amount-row/send-amount-row.scss b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-amount-row/send-amount-row.scss +++ b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.scss diff --git a/ui/app/components/send/send-content/send-amount-row/send-amount-row.selectors.js b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.selectors.js index fb08c7ed7..fb08c7ed7 100644 --- a/ui/app/components/send/send-content/send-amount-row/send-amount-row.selectors.js +++ b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.selectors.js diff --git a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-component.test.js b/ui/app/components/app/send/send-content/send-amount-row/tests/send-amount-row-component.test.js index 56e80cb83..14a71129f 100644 --- a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-component.test.js +++ b/ui/app/components/app/send/send-content/send-amount-row/tests/send-amount-row-component.test.js @@ -82,7 +82,6 @@ describe('SendAmountRow Component', function () { assert.deepEqual( propsMethodSpies.updateGasFeeError.getCall(0).args, [{ - amount: 'someAmount', amountConversionRate: 'mockAmountConversionRate', balance: 'mockBalance', conversionRate: 7, diff --git a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js b/ui/app/components/app/send/send-content/send-amount-row/tests/send-amount-row-container.test.js index 52e351aee..6d20202b0 100644 --- a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js +++ b/ui/app/components/app/send/send-content/send-amount-row/tests/send-amount-row-container.test.js @@ -37,8 +37,8 @@ proxyquire('../send-amount-row.container.js', { getAmountErrorObject: (mockDataObject) => ({ ...mockDataObject, mockChange: true }), getGasFeeErrorObject: (mockDataObject) => ({ ...mockDataObject, mockGasFeeErrorChange: true }), }, - '../../../../actions': actionSpies, - '../../../../ducks/send.duck': duckActionSpies, + '../../../../../store/actions': actionSpies, + '../../../../../ducks/send/send.duck': duckActionSpies, }) describe('send-amount-row container', () => { diff --git a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js b/ui/app/components/app/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js index 4672cb8a7..4672cb8a7 100644 --- a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js +++ b/ui/app/components/app/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js diff --git a/ui/app/components/send/send-content/send-content.component.js b/ui/app/components/app/send/send-content/send-content.component.js index 1b03ffd2b..2c09ceb19 100644 --- a/ui/app/components/send/send-content/send-content.component.js +++ b/ui/app/components/app/send/send-content/send-content.component.js @@ -1,11 +1,11 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import PageContainerContent from '../../page-container/page-container-content.component' -import SendAmountRow from './send-amount-row/' -import SendFromRow from './send-from-row/' -import SendGasRow from './send-gas-row/' +import PageContainerContent from '../../../ui/page-container/page-container-content.component' +import SendAmountRow from './send-amount-row' +import SendFromRow from './send-from-row' +import SendGasRow from './send-gas-row' import SendHexDataRow from './send-hex-data-row' -import SendToRow from './send-to-row/' +import SendToRow from './send-to-row' export default class SendContent extends Component { @@ -13,7 +13,7 @@ export default class SendContent extends Component { updateGas: PropTypes.func, scanQrCode: PropTypes.func, showHexData: PropTypes.bool, - }; + } updateGas = (updateData) => this.props.updateGas(updateData) diff --git a/ui/app/components/send/send-content/send-dropdown-list/index.js b/ui/app/components/app/send/send-content/send-dropdown-list/index.js index 04af6536c..04af6536c 100644 --- a/ui/app/components/send/send-content/send-dropdown-list/index.js +++ b/ui/app/components/app/send/send-content/send-dropdown-list/index.js diff --git a/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js b/ui/app/components/app/send/send-content/send-dropdown-list/send-dropdown-list.component.js index bedac1259..0d026bc69 100644 --- a/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js +++ b/ui/app/components/app/send/send-content/send-dropdown-list/send-dropdown-list.component.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import AccountListItem from '../../account-list-item/' +import AccountListItem from '../../account-list-item' export default class SendDropdownList extends Component { diff --git a/ui/app/components/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js b/ui/app/components/app/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js index b92dd4dfe..b92dd4dfe 100644 --- a/ui/app/components/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js +++ b/ui/app/components/app/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js diff --git a/ui/app/components/send/send-content/send-from-row/index.js b/ui/app/components/app/send/send-content/send-from-row/index.js index 0a79726b2..0a79726b2 100644 --- a/ui/app/components/send/send-content/send-from-row/index.js +++ b/ui/app/components/app/send/send-content/send-from-row/index.js diff --git a/ui/app/components/app/send/send-content/send-from-row/send-from-row.component.js b/ui/app/components/app/send/send-content/send-from-row/send-from-row.component.js new file mode 100644 index 000000000..dfa53e970 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-from-row/send-from-row.component.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SendRowWrapper from '../send-row-wrapper' +import AccountListItem from '../../account-list-item' + +export default class SendFromRow extends Component { + static propTypes = { + from: PropTypes.object, + } + + static contextTypes = { + t: PropTypes.func, + } + + render () { + const { t } = this.context + const { from } = this.props + + return ( + <SendRowWrapper label={`${t('from')}:`}> + <div className="send-v2__from-dropdown"> + <AccountListItem account={from} /> + </div> + </SendRowWrapper> + ) + } +} diff --git a/ui/app/components/app/send/send-content/send-from-row/send-from-row.container.js b/ui/app/components/app/send/send-content/send-from-row/send-from-row.container.js new file mode 100644 index 000000000..fe3ac9aa1 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-from-row/send-from-row.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import { getSendFromObject } from '../../send.selectors.js' +import SendFromRow from './send-from-row.component' + +function mapStateToProps (state) { + return { + from: getSendFromObject(state), + } +} + +export default connect(mapStateToProps)(SendFromRow) diff --git a/ui/app/components/send/send-content/send-from-row/send-from-row.selectors.js b/ui/app/components/app/send/send-content/send-from-row/send-from-row.selectors.js index 03ef4806b..03ef4806b 100644 --- a/ui/app/components/send/send-content/send-from-row/send-from-row.selectors.js +++ b/ui/app/components/app/send/send-content/send-from-row/send-from-row.selectors.js diff --git a/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-component.test.js b/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-component.test.js new file mode 100644 index 000000000..18811c57e --- /dev/null +++ b/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-component.test.js @@ -0,0 +1,31 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import SendFromRow from '../send-from-row.component.js' +import AccountListItem from '../../../account-list-item' +import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component' + +describe('SendFromRow Component', function () { + describe('render', () => { + const wrapper = shallow( + <SendFromRow + from={ { address: 'mockAddress' } } + />, + { context: { t: str => str + '_t' } } + ) + + it('should render a SendRowWrapper component', () => { + assert.equal(wrapper.find(SendRowWrapper).length, 1) + }) + + it('should pass the correct props to SendRowWrapper', () => { + const { label } = wrapper.find(SendRowWrapper).props() + assert.equal(label, 'from_t:') + }) + + it('should render the FromDropdown with the correct props', () => { + const { account } = wrapper.find(AccountListItem).props() + assert.deepEqual(account, { address: 'mockAddress' }) + }) + }) +}) diff --git a/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-container.test.js b/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-container.test.js new file mode 100644 index 000000000..fd771ea77 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-container.test.js @@ -0,0 +1,26 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps + +proxyquire('../send-from-row.container.js', { + 'react-redux': { + connect: ms => { + mapStateToProps = ms + return () => ({}) + }, + }, + '../../send.selectors.js': { + getSendFromObject: (s) => `mockFrom:${s}`, + }, +}) + +describe('send-from-row container', () => { + describe('mapStateToProps()', () => { + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + from: 'mockFrom:mockState', + }) + }) + }) +}) diff --git a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-selectors.test.js b/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-selectors.test.js index ecb57bbc3..ecb57bbc3 100644 --- a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-selectors.test.js +++ b/ui/app/components/app/send/send-content/send-from-row/tests/send-from-row-selectors.test.js diff --git a/ui/app/components/send/send-content/send-gas-row/README.md b/ui/app/components/app/send/send-content/send-gas-row/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-gas-row/README.md +++ b/ui/app/components/app/send/send-content/send-gas-row/README.md diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js b/ui/app/components/app/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js index 9bbb67506..48088607a 100644 --- a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js +++ b/ui/app/components/app/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js @@ -1,7 +1,7 @@ import React, {Component} from 'react' import PropTypes from 'prop-types' import UserPreferencedCurrencyDisplay from '../../../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../../../constants/common' +import { PRIMARY, SECONDARY } from '../../../../../../helpers/constants/common' export default class GasFeeDisplay extends Component { @@ -11,7 +11,7 @@ export default class GasFeeDisplay extends Component { convertedCurrency: PropTypes.string, gasLoadingError: PropTypes.bool, gasTotal: PropTypes.string, - onClick: PropTypes.func, + onReset: PropTypes.func, }; static contextTypes = { @@ -19,7 +19,7 @@ export default class GasFeeDisplay extends Component { }; render () { - const { gasTotal, onClick, gasLoadingError } = this.props + const { gasTotal, gasLoadingError, onReset } = this.props return ( <div className="send-v2__gas-fee-display"> @@ -46,11 +46,10 @@ export default class GasFeeDisplay extends Component { </div> } <button - className="sliders-icon-container" - onClick={onClick} - disabled={!gasTotal && !gasLoadingError} + className="gas-fee-reset" + onClick={onReset} > - <i className="fa fa-sliders sliders-icon" /> + { this.context.t('reset') } </button> </div> ) diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js b/ui/app/components/app/send/send-content/send-gas-row/gas-fee-display/index.js index dba0edb7b..dba0edb7b 100644 --- a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js +++ b/ui/app/components/app/send/send-content/send-gas-row/gas-fee-display/index.js diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js b/ui/app/components/app/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js index 9ff01493a..cb4180508 100644 --- a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js +++ b/ui/app/components/app/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js @@ -8,18 +8,20 @@ import sinon from 'sinon' const propsMethodSpies = { showCustomizeGasModal: sinon.spy(), + onReset: sinon.spy(), } -describe('SendGasRow Component', function () { +describe('GasFeeDisplay Component', function () { let wrapper beforeEach(() => { wrapper = shallow(<GasFeeDisplay conversionRate={20} gasTotal={'mockGasTotal'} - onClick={propsMethodSpies.showCustomizeGasModal} primaryCurrency={'mockPrimaryCurrency'} convertedCurrency={'mockConvertedCurrency'} + showGasButtonGroup={propsMethodSpies.showCustomizeGasModal} + onReset={propsMethodSpies.onReset} />, {context: {t: str => str + '_t'}}) }) @@ -41,13 +43,19 @@ describe('SendGasRow Component', function () { assert.equal(value, 'mockGasTotal') }) - it('should render the Button with the correct props', () => { + it('should render the reset button with the correct props', () => { const { onClick, + className, } = wrapper.find('button').props() - assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 0) + assert.equal(className, 'gas-fee-reset') + assert.equal(propsMethodSpies.onReset.callCount, 0) onClick() - assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 1) + assert.equal(propsMethodSpies.onReset.callCount, 1) + }) + + it('should render the reset button with the correct text', () => { + assert.equal(wrapper.find('button').text(), 'reset_t') }) }) }) diff --git a/ui/app/components/send/send-content/send-gas-row/index.js b/ui/app/components/app/send/send-content/send-gas-row/index.js index 3c7ff1d5f..3c7ff1d5f 100644 --- a/ui/app/components/send/send-content/send-gas-row/index.js +++ b/ui/app/components/app/send/send-content/send-gas-row/index.js diff --git a/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.component.js b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.component.js new file mode 100644 index 000000000..424a65b20 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.component.js @@ -0,0 +1,131 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SendRowWrapper from '../send-row-wrapper' +import GasFeeDisplay from './gas-fee-display/gas-fee-display.component' +import GasPriceButtonGroup from '../../../gas-customization/gas-price-button-group' +import AdvancedGasInputs from '../../../gas-customization/advanced-gas-inputs' + +export default class SendGasRow extends Component { + + static propTypes = { + conversionRate: PropTypes.number, + convertedCurrency: PropTypes.string, + gasFeeError: PropTypes.bool, + gasLoadingError: PropTypes.bool, + gasTotal: PropTypes.string, + showCustomizeGasModal: PropTypes.func, + setGasPrice: PropTypes.func, + setGasLimit: PropTypes.func, + gasPriceButtonGroupProps: PropTypes.object, + gasButtonGroupShown: PropTypes.bool, + advancedInlineGasShown: PropTypes.bool, + resetGasButtons: PropTypes.func, + gasPrice: PropTypes.number, + gasLimit: PropTypes.number, + insufficientBalance: PropTypes.bool, + } + + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + }; + + renderAdvancedOptionsButton () { + const { metricsEvent } = this.context + const { showCustomizeGasModal } = this.props + return <div className="advanced-gas-options-btn" onClick={() => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Clicked "Advanced Options"', + }, + }) + showCustomizeGasModal() + }}> + { this.context.t('advancedOptions') } + </div> + } + + renderContent () { + const { + conversionRate, + convertedCurrency, + gasLoadingError, + gasTotal, + showCustomizeGasModal, + gasPriceButtonGroupProps, + gasButtonGroupShown, + advancedInlineGasShown, + resetGasButtons, + setGasPrice, + setGasLimit, + gasPrice, + gasLimit, + insufficientBalance, + } = this.props + const { metricsEvent } = this.context + + const gasPriceButtonGroup = <div> + <GasPriceButtonGroup + className="gas-price-button-group--small" + showCheck={false} + {...gasPriceButtonGroupProps} + handleGasPriceSelection={(...args) => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Changed Gas Button', + }, + }) + gasPriceButtonGroupProps.handleGasPriceSelection(...args) + }} + /> + { this.renderAdvancedOptionsButton() } + </div> + const gasFeeDisplay = <GasFeeDisplay + conversionRate={conversionRate} + convertedCurrency={convertedCurrency} + gasLoadingError={gasLoadingError} + gasTotal={gasTotal} + onReset={resetGasButtons} + onClick={() => showCustomizeGasModal()} + /> + const advancedGasInputs = <div> + <AdvancedGasInputs + updateCustomGasPrice={newGasPrice => setGasPrice(newGasPrice, gasLimit)} + updateCustomGasLimit={newGasLimit => setGasLimit(newGasLimit, gasPrice)} + customGasPrice={gasPrice} + customGasLimit={gasLimit} + insufficientBalance={insufficientBalance} + customPriceIsSafe={true} + isSpeedUp={false} + /> + { this.renderAdvancedOptionsButton() } + </div> + + if (advancedInlineGasShown) { + return advancedGasInputs + } else if (gasButtonGroupShown) { + return gasPriceButtonGroup + } else { + return gasFeeDisplay + } + } + + render () { + const { gasFeeError } = this.props + + return ( + <SendRowWrapper + label={`${this.context.t('transactionFee')}:`} + showError={gasFeeError} + errorType={'gasFee'} + > + { this.renderContent() } + </SendRowWrapper> + ) + } + +} diff --git a/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.container.js b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.container.js new file mode 100644 index 000000000..f81670c02 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.container.js @@ -0,0 +1,118 @@ +import { connect } from 'react-redux' +import { + getConversionRate, + getCurrentCurrency, + getGasTotal, + getGasPrice, + getGasLimit, + getSendAmount, +} from '../../send.selectors.js' +import { + isBalanceSufficient, + calcGasTotal, +} from '../../send.utils.js' +import { + getBasicGasEstimateLoadingStatus, + getRenderableEstimateDataForSmallButtonsFromGWEI, + getDefaultActiveButtonIndex, +} from '../../../../../selectors/custom-gas' +import { + showGasButtonGroup, +} from '../../../../../ducks/send/send.duck' +import { + resetCustomData, + setCustomGasPrice, + setCustomGasLimit, +} from '../../../../../ducks/gas/gas.duck' +import { getGasLoadingError, gasFeeIsInError, getGasButtonGroupShown } from './send-gas-row.selectors.js' +import { showModal, setGasPrice, setGasLimit, setGasTotal } from '../../../../../store/actions' +import { getAdvancedInlineGasShown, getCurrentEthBalance, getSelectedToken } from '../../../../../selectors/selectors' +import SendGasRow from './send-gas-row.component' + +export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(SendGasRow) + +function mapStateToProps (state) { + const gasButtonInfo = getRenderableEstimateDataForSmallButtonsFromGWEI(state) + const gasPrice = getGasPrice(state) + const gasLimit = getGasLimit(state) + const activeButtonIndex = getDefaultActiveButtonIndex(gasButtonInfo, gasPrice) + + const gasTotal = getGasTotal(state) + const conversionRate = getConversionRate(state) + const balance = getCurrentEthBalance(state) + + const insufficientBalance = !isBalanceSufficient({ + amount: getSelectedToken(state) ? '0x0' : getSendAmount(state), + gasTotal, + balance, + conversionRate, + }) + + return { + conversionRate, + convertedCurrency: getCurrentCurrency(state), + gasTotal, + gasFeeError: gasFeeIsInError(state), + gasLoadingError: getGasLoadingError(state), + gasPriceButtonGroupProps: { + buttonDataLoading: getBasicGasEstimateLoadingStatus(state), + defaultActiveButtonIndex: 1, + newActiveButtonIndex: activeButtonIndex > -1 ? activeButtonIndex : null, + gasButtonInfo, + }, + gasButtonGroupShown: getGasButtonGroupShown(state), + advancedInlineGasShown: getAdvancedInlineGasShown(state), + gasPrice, + gasLimit, + insufficientBalance, + } +} + +function mapDispatchToProps (dispatch) { + return { + showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS', hideBasic: true })), + setGasPrice: (newPrice, gasLimit) => { + dispatch(setGasPrice(newPrice)) + dispatch(setCustomGasPrice(newPrice)) + if (gasLimit) { + dispatch(setGasTotal(calcGasTotal(gasLimit, newPrice))) + } + }, + setGasLimit: (newLimit, gasPrice) => { + dispatch(setGasLimit(newLimit)) + dispatch(setCustomGasLimit(newLimit)) + if (gasPrice) { + dispatch(setGasTotal(calcGasTotal(newLimit, gasPrice))) + } + }, + showGasButtonGroup: () => dispatch(showGasButtonGroup()), + resetCustomData: () => dispatch(resetCustomData()), + } +} + +function mergeProps (stateProps, dispatchProps, ownProps) { + const { gasPriceButtonGroupProps } = stateProps + const { gasButtonInfo } = gasPriceButtonGroupProps + const { + setGasPrice: dispatchSetGasPrice, + showGasButtonGroup: dispatchShowGasButtonGroup, + resetCustomData: dispatchResetCustomData, + ...otherDispatchProps + } = dispatchProps + + return { + ...stateProps, + ...otherDispatchProps, + ...ownProps, + gasPriceButtonGroupProps: { + ...gasPriceButtonGroupProps, + handleGasPriceSelection: dispatchSetGasPrice, + }, + resetGasButtons: () => { + dispatchResetCustomData() + dispatchSetGasPrice(gasButtonInfo[1].priceInHexWei) + dispatchShowGasButtonGroup() + }, + setGasPrice: dispatchSetGasPrice, + } +} diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.scss b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.scss +++ b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.scss diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.selectors.js index 96f6293c2..79c838543 100644 --- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js +++ b/ui/app/components/app/send/send-content/send-gas-row/send-gas-row.selectors.js @@ -1,6 +1,7 @@ const selectors = { gasFeeIsInError, getGasLoadingError, + getGasButtonGroupShown, } module.exports = selectors @@ -12,3 +13,7 @@ function getGasLoadingError (state) { function gasFeeIsInError (state) { return Boolean(state.send.errors.gasFee) } + +function getGasButtonGroupShown (state) { + return state.send.gasButtonGroupShown +} diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js b/ui/app/components/app/send/send-content/send-gas-row/tests/send-gas-row-component.test.js index 54a92bd2d..08f26854e 100644 --- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js +++ b/ui/app/components/app/send/send-content/send-gas-row/tests/send-gas-row-component.test.js @@ -6,9 +6,11 @@ import SendGasRow from '../send-gas-row.component.js' import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component' import GasFeeDisplay from '../gas-fee-display/gas-fee-display.component' +import GasPriceButtonGroup from '../../../../gas-customization/gas-price-button-group' const propsMethodSpies = { showCustomizeGasModal: sinon.spy(), + resetGasButtons: sinon.spy(), } describe('SendGasRow Component', function () { @@ -21,12 +23,18 @@ describe('SendGasRow Component', function () { gasFeeError={'mockGasFeeError'} gasLoadingError={false} gasTotal={'mockGasTotal'} + gasButtonGroupShown={false} showCustomizeGasModal={propsMethodSpies.showCustomizeGasModal} - />, { context: { t: str => str + '_t' } }) + resetGasButtons={propsMethodSpies.resetGasButtons} + gasPriceButtonGroupProps={{ + someGasPriceButtonGroupProp: 'foo', + anotherGasPriceButtonGroupProp: 'bar', + }} + />, { context: { t: str => str + '_t', metricsEvent: () => ({}) } }) }) afterEach(() => { - propsMethodSpies.showCustomizeGasModal.resetHistory() + propsMethodSpies.resetGasButtons.resetHistory() }) describe('render', () => { @@ -41,7 +49,7 @@ describe('SendGasRow Component', function () { errorType, } = wrapper.find(SendRowWrapper).props() - assert.equal(label, 'gasFee_t:') + assert.equal(label, 'transactionFee_t:') assert.equal(showError, 'mockGasFeeError') assert.equal(errorType, 'gasFee') }) @@ -56,14 +64,40 @@ describe('SendGasRow Component', function () { convertedCurrency, gasLoadingError, gasTotal, - onClick, + onReset, } = wrapper.find(SendRowWrapper).childAt(0).props() assert.equal(conversionRate, 20) assert.equal(convertedCurrency, 'mockConvertedCurrency') assert.equal(gasLoadingError, false) assert.equal(gasTotal, 'mockGasTotal') + assert.equal(propsMethodSpies.resetGasButtons.callCount, 0) + onReset() + assert.equal(propsMethodSpies.resetGasButtons.callCount, 1) + }) + + it('should render the GasPriceButtonGroup if gasButtonGroupShown is true', () => { + wrapper.setProps({ gasButtonGroupShown: true }) + const rendered = wrapper.find(SendRowWrapper).childAt(0) + assert.equal(rendered.children().length, 2) + + const gasPriceButtonGroup = rendered.childAt(0) + assert(gasPriceButtonGroup.is(GasPriceButtonGroup)) + assert(gasPriceButtonGroup.hasClass('gas-price-button-group--small')) + assert.equal(gasPriceButtonGroup.props().showCheck, false) + assert.equal(gasPriceButtonGroup.props().someGasPriceButtonGroupProp, 'foo') + assert.equal(gasPriceButtonGroup.props().anotherGasPriceButtonGroupProp, 'bar') + }) + + it('should render an advanced options button if gasButtonGroupShown is true', () => { + wrapper.setProps({ gasButtonGroupShown: true }) + const rendered = wrapper.find(SendRowWrapper).childAt(0) + assert.equal(rendered.children().length, 2) + + const advancedOptionsButton = rendered.childAt(1) + assert.equal(advancedOptionsButton.text(), 'advancedOptions_t') + assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 0) - onClick() + advancedOptionsButton.props().onClick() assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 1) }) }) diff --git a/ui/app/components/app/send/send-content/send-gas-row/tests/send-gas-row-container.test.js b/ui/app/components/app/send/send-content/send-gas-row/tests/send-gas-row-container.test.js new file mode 100644 index 000000000..d1f753639 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-gas-row/tests/send-gas-row-container.test.js @@ -0,0 +1,200 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps +let mergeProps + +const actionSpies = { + showModal: sinon.spy(), + setGasPrice: sinon.spy(), + setGasTotal: sinon.spy(), + setGasLimit: sinon.spy(), +} + +const sendDuckSpies = { + showGasButtonGroup: sinon.spy(), +} + +const gasDuckSpies = { + resetCustomData: sinon.spy(), + setCustomGasPrice: sinon.spy(), + setCustomGasLimit: sinon.spy(), +} + +proxyquire('../send-gas-row.container.js', { + 'react-redux': { + connect: (ms, md, mp) => { + mapStateToProps = ms + mapDispatchToProps = md + mergeProps = mp + return () => ({}) + }, + }, + '../../../../../selectors/selectors': { + getCurrentEthBalance: (s) => `mockCurrentEthBalance:${s}`, + getAdvancedInlineGasShown: (s) => `mockAdvancedInlineGasShown:${s}`, + getSelectedToken: () => false, + }, + '../../send.selectors.js': { + getConversionRate: (s) => `mockConversionRate:${s}`, + getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`, + getGasTotal: (s) => `mockGasTotal:${s}`, + getGasPrice: (s) => `mockGasPrice:${s}`, + getGasLimit: (s) => `mockGasLimit:${s}`, + getSendAmount: (s) => `mockSendAmount:${s}`, + }, + '../../send.utils.js': { + isBalanceSufficient: ({ + amount, + gasTotal, + balance, + conversionRate, + }) => `${amount}:${gasTotal}:${balance}:${conversionRate}`, + calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice, + }, + './send-gas-row.selectors.js': { + getGasLoadingError: (s) => `mockGasLoadingError:${s}`, + gasFeeIsInError: (s) => `mockGasFeeError:${s}`, + getGasButtonGroupShown: (s) => `mockGetGasButtonGroupShown:${s}`, + }, + '../../../../../store/actions': actionSpies, + '../../../../../selectors/custom-gas': { + getBasicGasEstimateLoadingStatus: (s) => `mockBasicGasEstimateLoadingStatus:${s}`, + getRenderableEstimateDataForSmallButtonsFromGWEI: (s) => `mockGasButtonInfo:${s}`, + getDefaultActiveButtonIndex: (gasButtonInfo, gasPrice) => gasButtonInfo.length + gasPrice.length, + }, + '../../../../../ducks/send/send.duck': sendDuckSpies, + '../../../../../ducks/gas/gas.duck': gasDuckSpies, +}) + +describe('send-gas-row container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + conversionRate: 'mockConversionRate:mockState', + convertedCurrency: 'mockConvertedCurrency:mockState', + gasTotal: 'mockGasTotal:mockState', + gasFeeError: 'mockGasFeeError:mockState', + gasLoadingError: 'mockGasLoadingError:mockState', + gasPriceButtonGroupProps: { + buttonDataLoading: `mockBasicGasEstimateLoadingStatus:mockState`, + defaultActiveButtonIndex: 1, + newActiveButtonIndex: 49, + gasButtonInfo: `mockGasButtonInfo:mockState`, + }, + gasButtonGroupShown: `mockGetGasButtonGroupShown:mockState`, + advancedInlineGasShown: 'mockAdvancedInlineGasShown:mockState', + gasLimit: 'mockGasLimit:mockState', + gasPrice: 'mockGasPrice:mockState', + insufficientBalance: false, + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + actionSpies.setGasTotal.resetHistory() + }) + + describe('showCustomizeGasModal()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.showCustomizeGasModal() + assert(dispatchSpy.calledOnce) + assert.deepEqual( + actionSpies.showModal.getCall(0).args[0], + { name: 'CUSTOMIZE_GAS', hideBasic: true } + ) + }) + }) + + describe('setGasPrice()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.setGasPrice('mockNewPrice', 'mockLimit') + assert(dispatchSpy.calledThrice) + assert(actionSpies.setGasPrice.calledOnce) + assert.equal(actionSpies.setGasPrice.getCall(0).args[0], 'mockNewPrice') + assert.equal(gasDuckSpies.setCustomGasPrice.getCall(0).args[0], 'mockNewPrice') + assert(actionSpies.setGasTotal.calledOnce) + assert.equal(actionSpies.setGasTotal.getCall(0).args[0], 'mockLimitmockNewPrice') + }) + }) + + describe('setGasLimit()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.setGasLimit('mockNewLimit', 'mockPrice') + assert(dispatchSpy.calledThrice) + assert(actionSpies.setGasLimit.calledOnce) + assert.equal(actionSpies.setGasLimit.getCall(0).args[0], 'mockNewLimit') + assert.equal(gasDuckSpies.setCustomGasLimit.getCall(0).args[0], 'mockNewLimit') + assert(actionSpies.setGasTotal.calledOnce) + assert.equal(actionSpies.setGasTotal.getCall(0).args[0], 'mockNewLimitmockPrice') + }) + }) + + describe('showGasButtonGroup()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.showGasButtonGroup() + assert(dispatchSpy.calledOnce) + assert(sendDuckSpies.showGasButtonGroup.calledOnce) + }) + }) + + describe('resetCustomData()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.resetCustomData() + assert(dispatchSpy.calledOnce) + assert(gasDuckSpies.resetCustomData.calledOnce) + }) + }) + + }) + + describe('mergeProps', () => { + let stateProps + let dispatchProps + let ownProps + + beforeEach(() => { + stateProps = { + gasPriceButtonGroupProps: { + someGasPriceButtonGroupProp: 'foo', + anotherGasPriceButtonGroupProp: 'bar', + }, + someOtherStateProp: 'baz', + } + dispatchProps = { + setGasPrice: sinon.spy(), + someOtherDispatchProp: sinon.spy(), + } + ownProps = { someOwnProp: 123 } + }) + + it('should return the expected props when isConfirm is true', () => { + const result = mergeProps(stateProps, dispatchProps, ownProps) + + assert.equal(result.someOtherStateProp, 'baz') + assert.equal(result.gasPriceButtonGroupProps.someGasPriceButtonGroupProp, 'foo') + assert.equal(result.gasPriceButtonGroupProps.anotherGasPriceButtonGroupProp, 'bar') + assert.equal(result.someOwnProp, 123) + + assert.equal(dispatchProps.setGasPrice.callCount, 0) + result.gasPriceButtonGroupProps.handleGasPriceSelection() + assert.equal(dispatchProps.setGasPrice.callCount, 1) + + assert.equal(dispatchProps.someOtherDispatchProp.callCount, 0) + result.someOtherDispatchProp() + assert.equal(dispatchProps.someOtherDispatchProp.callCount, 1) + }) + }) + +}) diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js b/ui/app/components/app/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js index d46dd9d8b..bd3c9a257 100644 --- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js +++ b/ui/app/components/app/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js @@ -2,6 +2,7 @@ import assert from 'assert' import { gasFeeIsInError, getGasLoadingError, + getGasButtonGroupShown, } from '../send-gas-row.selectors.js' describe('send-gas-row selectors', () => { @@ -46,4 +47,16 @@ describe('send-gas-row selectors', () => { }) }) + describe('getGasButtonGroupShown()', () => { + it('should return send.gasButtonGroupShown', () => { + const state = { + send: { + gasButtonGroupShown: 'foobar', + }, + } + + assert.equal(getGasButtonGroupShown(state), 'foobar') + }) + }) + }) diff --git a/ui/app/components/send/send-content/send-hex-data-row/index.js b/ui/app/components/app/send/send-content/send-hex-data-row/index.js index 08c341067..08c341067 100644 --- a/ui/app/components/send/send-content/send-hex-data-row/index.js +++ b/ui/app/components/app/send/send-content/send-hex-data-row/index.js diff --git a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js b/ui/app/components/app/send/send-content/send-hex-data-row/send-hex-data-row.component.js index 62a74a77b..62a74a77b 100644 --- a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js +++ b/ui/app/components/app/send/send-content/send-hex-data-row/send-hex-data-row.component.js diff --git a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.container.js b/ui/app/components/app/send/send-content/send-hex-data-row/send-hex-data-row.container.js index df554ca5f..76c929d08 100644 --- a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.container.js +++ b/ui/app/components/app/send/send-content/send-hex-data-row/send-hex-data-row.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux' import { updateSendHexData, -} from '../../../../actions' +} from '../../../../../store/actions' import SendHexDataRow from './send-hex-data-row.component' export default connect(mapStateToProps, mapDispatchToProps)(SendHexDataRow) diff --git a/ui/app/components/send/send-content/send-row-wrapper/index.js b/ui/app/components/app/send/send-content/send-row-wrapper/index.js index d17545dcc..d17545dcc 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/index.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/index.js diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/index.js index c00617f83..c00617f83 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/index.js diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js index 61bc7bab7..61bc7bab7 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js index 59622047f..59622047f 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js index 2304a43d2..2304a43d2 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js index eecff165d..eecff165d 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js diff --git a/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/index.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/index.js new file mode 100644 index 000000000..fd4d19ef7 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/index.js @@ -0,0 +1 @@ +export { default } from './send-row-warning-message.container' diff --git a/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.component.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.component.js new file mode 100644 index 000000000..f1caa8f99 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.component.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class SendRowWarningMessage extends Component { + + static propTypes = { + warnings: PropTypes.object, + warningType: PropTypes.string, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + render () { + const { warnings, warningType } = this.props + + const warningMessage = warningType in warnings && warnings[warningType] + + return ( + warningMessage + ? <div className="send-v2__warning">{this.context.t(warningMessage)}</div> + : null + ) + } + +} diff --git a/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.container.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.container.js new file mode 100644 index 000000000..7df14fd96 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import { getSendWarnings } from '../../../send.selectors' +import SendRowWarningMessage from './send-row-warning-message.component' + +export default connect(mapStateToProps)(SendRowWarningMessage) + +function mapStateToProps (state, ownProps) { + return { + warnings: getSendWarnings(state), + warningType: ownProps.warningType, + } +} diff --git a/ui/app/components/page-container/tests/page-container.component.test.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/page-container/tests/page-container.component.test.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.scss diff --git a/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-component.test.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-component.test.js new file mode 100644 index 000000000..bd803d833 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-component.test.js @@ -0,0 +1,28 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import SendRowWarningMessage from '../send-row-warning-message.component.js' + +describe('SendRowWarningMessage Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<SendRowWarningMessage + warnings={{ warning1: 'abc', warning2: 'def' }} + warningType={'warning3'} + />, { context: { t: str => str + '_t' } }) + }) + + describe('render', () => { + it('should render null if the passed warnings do not contain a warning of warningType', () => { + assert.equal(wrapper.find('.send-v2__warning').length, 0) + assert.equal(wrapper.html(), null) + }) + + it('should render a warning message if the passed warnings contain a warning of warningType', () => { + wrapper.setProps({ warnings: { warning1: 'abc', warning2: 'def', warning3: 'xyz' } }) + assert.equal(wrapper.find('.send-v2__warning').length, 1) + assert.equal(wrapper.find('.send-v2__warning').text(), 'xyz_t') + }) + }) +}) diff --git a/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-container.test.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-container.test.js new file mode 100644 index 000000000..225bf056c --- /dev/null +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-container.test.js @@ -0,0 +1,28 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps + +proxyquire('../send-row-warning-message.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + return () => ({}) + }, + }, + '../../../send.selectors': { getSendWarnings: (s) => `mockWarnings:${s}` }, +}) + +describe('send-row-warning-message container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState', { warningType: 'someType' }), { + warnings: 'mockWarnings:mockState', + warningType: 'someType' }) + }) + + }) + +}) diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper-README.md b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-wrapper-README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper-README.md +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-wrapper-README.md diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-wrapper.component.js index b7528a15f..94309bd96 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-wrapper.component.js @@ -1,6 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import SendRowErrorMessage from './send-row-error-message/' +import SendRowErrorMessage from './send-row-error-message' +import SendRowWarningMessage from './send-row-warning-message' export default class SendRowWrapper extends Component { @@ -9,6 +10,8 @@ export default class SendRowWrapper extends Component { errorType: PropTypes.string, label: PropTypes.string, showError: PropTypes.bool, + showWarning: PropTypes.bool, + warningType: PropTypes.string, }; static contextTypes = { @@ -21,20 +24,22 @@ export default class SendRowWrapper extends Component { errorType = '', label, showError = false, + showWarning = false, + warningType = '', } = this.props - const formField = Array.isArray(children) ? children[1] || children[0] : children const customLabelContent = children.length > 1 ? children[0] : null return ( <div className="send-v2__form-row"> <div className="send-v2__form-label"> - {label} - {showError && <SendRowErrorMessage errorType={errorType}/>} - {customLabelContent} + {label} + {showError && <SendRowErrorMessage errorType={errorType}/>} + {!showError && showWarning && <SendRowWarningMessage warningType={warningType} />} + {customLabelContent} </div> <div className="send-v2__form-field"> - {formField} + {formField} </div> </div> ) diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.scss b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-wrapper.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.scss +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-wrapper.scss diff --git a/ui/app/components/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js b/ui/app/components/app/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js index 30280e1d0..30280e1d0 100644 --- a/ui/app/components/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js +++ b/ui/app/components/app/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js diff --git a/ui/app/components/send/send-content/send-to-row/index.js b/ui/app/components/app/send/send-content/send-to-row/index.js index 121f15148..121f15148 100644 --- a/ui/app/components/send/send-content/send-to-row/index.js +++ b/ui/app/components/app/send/send-content/send-to-row/index.js diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row-README.md b/ui/app/components/app/send/send-content/send-to-row/send-to-row-README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row-README.md +++ b/ui/app/components/app/send/send-content/send-to-row/send-to-row-README.md diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js b/ui/app/components/app/send/send-content/send-to-row/send-to-row.component.js index 17c75c817..e8a55cb2a 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js +++ b/ui/app/components/app/send/send-content/send-to-row/send-to-row.component.js @@ -1,8 +1,8 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import SendRowWrapper from '../send-row-wrapper/' +import SendRowWrapper from '../send-row-wrapper' import EnsInput from '../../../ens-input' -import { getToErrorObject } from './send-to-row.utils.js' +import { getToErrorObject, getToWarningObject } from './send-to-row.utils.js' export default class SendToRow extends Component { @@ -10,26 +10,33 @@ export default class SendToRow extends Component { closeToDropdown: PropTypes.func, hasHexData: PropTypes.bool.isRequired, inError: PropTypes.bool, + inWarning: PropTypes.bool, network: PropTypes.string, openToDropdown: PropTypes.func, + selectedToken: PropTypes.object, to: PropTypes.string, toAccounts: PropTypes.array, toDropdownOpen: PropTypes.bool, + tokens: PropTypes.array, updateGas: PropTypes.func, updateSendTo: PropTypes.func, updateSendToError: PropTypes.func, + updateSendToWarning: PropTypes.func, scanQrCode: PropTypes.func, - }; + } static contextTypes = { t: PropTypes.func, - }; + metricsEvent: PropTypes.func, + } - handleToChange (to, nickname = '', toError) { - const { hasHexData, updateSendTo, updateSendToError, updateGas } = this.props - const toErrorObject = getToErrorObject(to, toError, hasHexData) + handleToChange (to, nickname = '', toError, toWarning, network) { + const { hasHexData, updateSendTo, updateSendToError, updateGas, tokens, selectedToken, updateSendToWarning } = this.props + const toErrorObject = getToErrorObject(to, toError, hasHexData, tokens, selectedToken, network) + const toWarningObject = getToWarningObject(to, toWarning, tokens, selectedToken) updateSendTo(to, nickname) updateSendToError(toErrorObject) + updateSendToWarning(toWarningObject) if (toErrorObject.to === null) { updateGas({ to }) } @@ -39,6 +46,7 @@ export default class SendToRow extends Component { const { closeToDropdown, inError, + inWarning, network, openToDropdown, to, @@ -51,16 +59,27 @@ export default class SendToRow extends Component { errorType={'to'} label={`${this.context.t('to')}: `} showError={inError} - > + showWarning={inWarning} + warningType={'to'} + > <EnsInput - scanQrCode={_ => this.props.scanQrCode()} + scanQrCode={_ => { + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Used QR scanner', + }, + }) + this.props.scanQrCode() + }} accounts={toAccounts} closeDropdown={() => closeToDropdown()} dropdownOpen={toDropdownOpen} inError={inError} name={'address'} network={network} - onChange={({ toAddress, nickname, toError }) => this.handleToChange(toAddress, nickname, toError)} + onChange={({ toAddress, nickname, toError, toWarning }) => this.handleToChange(toAddress, nickname, toError, toWarning, this.props.network)} openDropdown={() => openToDropdown()} placeholder={this.context.t('recipientAddress')} to={to} diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.container.js b/ui/app/components/app/send/send-content/send-to-row/send-to-row.container.js index 3ee188bad..30865d295 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.container.js +++ b/ui/app/components/app/send/send-content/send-to-row/send-to-row.container.js @@ -1,22 +1,26 @@ import { connect } from 'react-redux' import { getCurrentNetwork, + getSelectedToken, getSendTo, getSendToAccounts, getSendHexData, } from '../../send.selectors.js' import { getToDropdownOpen, + getTokens, sendToIsInError, + sendToIsInWarning, } from './send-to-row.selectors.js' import { updateSendTo, -} from '../../../../actions' +} from '../../../../../store/actions' import { updateSendErrors, + updateSendWarnings, openToDropdown, closeToDropdown, -} from '../../../../ducks/send.duck' +} from '../../../../../ducks/send/send.duck' import SendToRow from './send-to-row.component' export default connect(mapStateToProps, mapDispatchToProps)(SendToRow) @@ -25,10 +29,13 @@ function mapStateToProps (state) { return { hasHexData: Boolean(getSendHexData(state)), inError: sendToIsInError(state), + inWarning: sendToIsInWarning(state), network: getCurrentNetwork(state), + selectedToken: getSelectedToken(state), to: getSendTo(state), toAccounts: getSendToAccounts(state), toDropdownOpen: getToDropdownOpen(state), + tokens: getTokens(state), } } @@ -40,5 +47,8 @@ function mapDispatchToProps (dispatch) { updateSendToError: (toErrorObject) => { dispatch(updateSendErrors(toErrorObject)) }, + updateSendToWarning: (toWarningObject) => { + dispatch(updateSendWarnings(toWarningObject)) + }, } } diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.selectors.js b/ui/app/components/app/send/send-content/send-to-row/send-to-row.selectors.js index 8919014be..a6160d335 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.selectors.js +++ b/ui/app/components/app/send/send-content/send-to-row/send-to-row.selectors.js @@ -1,6 +1,8 @@ const selectors = { getToDropdownOpen, + getTokens, sendToIsInError, + sendToIsInWarning, } module.exports = selectors @@ -12,3 +14,11 @@ function getToDropdownOpen (state) { function sendToIsInError (state) { return Boolean(state.send.errors.to) } + +function sendToIsInWarning (state) { + return Boolean(state.send.warnings.to) +} + +function getTokens (state) { + return state.metamask.tokens +} diff --git a/ui/app/components/app/send/send-content/send-to-row/send-to-row.utils.js b/ui/app/components/app/send/send-content/send-to-row/send-to-row.utils.js new file mode 100644 index 000000000..60e75d34c --- /dev/null +++ b/ui/app/components/app/send/send-content/send-to-row/send-to-row.utils.js @@ -0,0 +1,36 @@ +const { + REQUIRED_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_ERROR, + INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, +} = require('../../send.constants') +const { isValidAddress, isEthNetwork } = require('../../../../../helpers/utils/util') +import { checkExistingAddresses } from '../../../../../pages/add-token/util' + +const ethUtil = require('ethereumjs-util') +const contractMap = require('eth-contract-metadata') + +function getToErrorObject (to, toError = null, hasHexData = false, tokens = [], selectedToken = null, network) { + if (!to) { + if (!hasHexData) { + toError = REQUIRED_ERROR + } + } else if (!isValidAddress(to, network) && !toError) { + toError = isEthNetwork(network) ? INVALID_RECIPIENT_ADDRESS_ERROR : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR + } else if (selectedToken && (ethUtil.toChecksumAddress(to) in contractMap || checkExistingAddresses(to, tokens))) { + toError = KNOWN_RECIPIENT_ADDRESS_ERROR + } + return { to: toError } +} + +function getToWarningObject (to, toWarning = null, tokens = [], selectedToken = null) { + if (selectedToken && (ethUtil.toChecksumAddress(to) in contractMap || checkExistingAddresses(to, tokens))) { + toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR + } + return { to: toWarning } +} + +module.exports = { + getToErrorObject, + getToWarningObject, +} diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-component.test.js index 591229deb..d4d054057 100644 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js +++ b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-component.test.js @@ -9,6 +9,9 @@ const SendToRow = proxyquire('../send-to-row.component.js', { getToErrorObject: (to, toError) => ({ to: to === false ? null : `mockToErrorObject:${to}${toError}`, }), + getToWarningObject: (to, toWarning) => ({ + to: to === false ? null : `mockToWarningObject:${to}${toWarning}`, + }), }, }).default @@ -21,6 +24,7 @@ const propsMethodSpies = { updateGas: sinon.spy(), updateSendTo: sinon.spy(), updateSendToError: sinon.spy(), + updateSendToWarning: sinon.spy(), } sinon.spy(SendToRow.prototype, 'handleToChange') @@ -33,6 +37,7 @@ describe('SendToRow Component', function () { wrapper = shallow(<SendToRow closeToDropdown={propsMethodSpies.closeToDropdown} inError={false} + inWarning={false} network={'mockNetwork'} openToDropdown={propsMethodSpies.openToDropdown} to={'mockTo'} @@ -41,6 +46,7 @@ describe('SendToRow Component', function () { updateGas={propsMethodSpies.updateGas} updateSendTo={propsMethodSpies.updateSendTo} updateSendToError={propsMethodSpies.updateSendToError} + updateSendToWarning={propsMethodSpies.updateSendToWarning} />, { context: { t: str => str + '_t' } }) instance = wrapper.instance() }) @@ -50,6 +56,7 @@ describe('SendToRow Component', function () { propsMethodSpies.openToDropdown.resetHistory() propsMethodSpies.updateSendTo.resetHistory() propsMethodSpies.updateSendToError.resetHistory() + propsMethodSpies.updateSendToWarning.resetHistory() SendToRow.prototype.handleToChange.resetHistory() }) @@ -75,6 +82,16 @@ describe('SendToRow Component', function () { ) }) + it('should call updateSendToWarning', () => { + assert.equal(propsMethodSpies.updateSendToWarning.callCount, 0) + instance.handleToChange('mockTo2', '', '', 'mockToWarning') + assert.equal(propsMethodSpies.updateSendToWarning.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateSendToWarning.getCall(0).args, + [{ to: 'mockToWarningObject:mockTo2mockToWarning' }] + ) + }) + it('should not call updateGas if there is a to error', () => { assert.equal(propsMethodSpies.updateGas.callCount, 0) instance.handleToChange('mockTo2') @@ -138,11 +155,11 @@ describe('SendToRow Component', function () { openDropdown() assert.equal(propsMethodSpies.openToDropdown.callCount, 1) assert.equal(SendToRow.prototype.handleToChange.callCount, 0) - onChange({ toAddress: 'mockNewTo', nickname: 'mockNewNickname', toError: 'mockToError' }) + onChange({ toAddress: 'mockNewTo', nickname: 'mockNewNickname', toError: 'mockToError', toWarning: 'mockToWarning' }) assert.equal(SendToRow.prototype.handleToChange.callCount, 1) assert.deepEqual( SendToRow.prototype.handleToChange.getCall(0).args, - ['mockNewTo', 'mockNewNickname', 'mockToError'] + ['mockNewTo', 'mockNewNickname', 'mockToError', 'mockToWarning', 'mockNetwork' ] ) }) }) diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-container.test.js index dfce7652f..94b4f1024 100644 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js +++ b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-container.test.js @@ -12,6 +12,7 @@ const duckActionSpies = { closeToDropdown: sinon.spy(), openToDropdown: sinon.spy(), updateSendErrors: sinon.spy(), + updateSendWarnings: sinon.spy(), } proxyquire('../send-to-row.container.js', { @@ -24,6 +25,7 @@ proxyquire('../send-to-row.container.js', { }, '../../send.selectors.js': { getCurrentNetwork: (s) => `mockNetwork:${s}`, + getSelectedToken: (s) => `mockSelectedToken:${s}`, getSendHexData: (s) => s, getSendTo: (s) => `mockTo:${s}`, getSendToAccounts: (s) => `mockToAccounts:${s}`, @@ -31,9 +33,11 @@ proxyquire('../send-to-row.container.js', { './send-to-row.selectors.js': { getToDropdownOpen: (s) => `mockToDropdownOpen:${s}`, sendToIsInError: (s) => `mockInError:${s}`, + sendToIsInWarning: (s) => `mockInWarning:${s}`, + getTokens: (s) => `mockTokens:${s}`, }, - '../../../../actions': actionSpies, - '../../../../ducks/send.duck': duckActionSpies, + '../../../../../store/actions': actionSpies, + '../../../../../ducks/send/send.duck': duckActionSpies, }) describe('send-to-row container', () => { @@ -44,10 +48,13 @@ describe('send-to-row container', () => { assert.deepEqual(mapStateToProps('mockState'), { hasHexData: true, inError: 'mockInError:mockState', + inWarning: 'mockInWarning:mockState', network: 'mockNetwork:mockState', + selectedToken: 'mockSelectedToken:mockState', to: 'mockTo:mockState', toAccounts: 'mockToAccounts:mockState', toDropdownOpen: 'mockToDropdownOpen:mockState', + tokens: 'mockTokens:mockState', }) }) @@ -110,6 +117,18 @@ describe('send-to-row container', () => { }) }) + describe('updateSendToWarning()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendToWarning('mockToWarningObject') + assert(dispatchSpy.calledOnce) + assert(duckActionSpies.updateSendWarnings.calledOnce) + assert.equal( + duckActionSpies.updateSendWarnings.getCall(0).args[0], + 'mockToWarningObject' + ) + }) + }) + }) }) diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-selectors.test.js b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-selectors.test.js index 122ad3265..0fa342d1e 100644 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-selectors.test.js +++ b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-selectors.test.js @@ -1,6 +1,7 @@ import assert from 'assert' import { getToDropdownOpen, + getTokens, sendToIsInError, } from '../send-to-row.selectors.js' @@ -44,4 +45,15 @@ describe('send-to-row selectors', () => { }) }) + describe('getTokens()', () => { + it('should return empty array if no tokens in state', () => { + const state = { + metamask: { + tokens: [], + }, + } + + assert.deepStrictEqual(getTokens(state), []) + }) + }) }) diff --git a/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-utils.test.js b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-utils.test.js new file mode 100644 index 000000000..95882d640 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-utils.test.js @@ -0,0 +1,107 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +import { + REQUIRED_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_ERROR, +} from '../../../send.constants' + +const stubs = { + isValidAddress: sinon.stub().callsFake(to => Boolean(to.match(/^[0xabcdef123456798]+$/))), +} + +const toRowUtils = proxyquire('../send-to-row.utils.js', { + '../../../../../helpers/utils/util': { + isValidAddress: stubs.isValidAddress, + }, +}) +const { + getToErrorObject, + getToWarningObject, +} = toRowUtils + +describe('send-to-row utils', () => { + + describe('getToErrorObject()', () => { + it('should return a required error if to is falsy', () => { + assert.deepEqual(getToErrorObject(null), { + to: REQUIRED_ERROR, + }) + }) + + it('should return null if to is falsy and hexData is truthy', () => { + assert.deepEqual(getToErrorObject(null, undefined, true), { + to: null, + }) + }) + + it('should return an invalid recipient error if to is truthy but invalid', () => { + assert.deepEqual(getToErrorObject('mockInvalidTo'), { + to: INVALID_RECIPIENT_ADDRESS_ERROR, + }) + }) + + it('should return null if to is truthy and valid', () => { + assert.deepEqual(getToErrorObject('0xabc123'), { + to: null, + }) + }) + + it('should return the passed error if to is truthy but invalid if to is truthy and valid', () => { + assert.deepEqual(getToErrorObject('invalid #$ 345878', 'someExplicitError'), { + to: 'someExplicitError', + }) + }) + + it('should return a known address recipient if to is truthy but part of state tokens', () => { + assert.deepEqual(getToErrorObject('0xabc123', undefined, false, [{'address': '0xabc123'}], {'address': '0xabc123'}), { + to: KNOWN_RECIPIENT_ADDRESS_ERROR, + }) + }) + + it('should null if to is truthy part of tokens but selectedToken falsy', () => { + assert.deepEqual(getToErrorObject('0xabc123', undefined, false, [{'address': '0xabc123'}]), { + to: null, + }) + }) + + it('should return a known address recipient if to is truthy but part of contract metadata', () => { + assert.deepEqual(getToErrorObject('0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', undefined, false, [{'address': '0xabc123'}], {'address': '0xabc123'}), { + to: KNOWN_RECIPIENT_ADDRESS_ERROR, + }) + }) + it('should null if to is truthy part of contract metadata but selectedToken falsy', () => { + assert.deepEqual(getToErrorObject('0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', undefined, false, [{'address': '0xabc123'}], {'address': '0xabc123'}), { + to: KNOWN_RECIPIENT_ADDRESS_ERROR, + }) + }) + }) + + describe('getToWarningObject()', () => { + it('should return a known address recipient if to is truthy but part of state tokens', () => { + assert.deepEqual(getToWarningObject('0xabc123', undefined, [{'address': '0xabc123'}], {'address': '0xabc123'}), { + to: KNOWN_RECIPIENT_ADDRESS_ERROR, + }) + }) + + it('should null if to is truthy part of tokens but selectedToken falsy', () => { + assert.deepEqual(getToWarningObject('0xabc123', undefined, [{'address': '0xabc123'}]), { + to: null, + }) + }) + + it('should return a known address recipient if to is truthy but part of contract metadata', () => { + assert.deepEqual(getToWarningObject('0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', undefined, [{'address': '0xabc123'}], {'address': '0xabc123'}), { + to: KNOWN_RECIPIENT_ADDRESS_ERROR, + }) + }) + it('should null if to is truthy part of contract metadata but selectedToken falsy', () => { + assert.deepEqual(getToWarningObject('0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', undefined, [{'address': '0xabc123'}], {'address': '0xabc123'}), { + to: KNOWN_RECIPIENT_ADDRESS_ERROR, + }) + }) + }) + +}) diff --git a/ui/app/components/send/send-content/tests/send-content-component.test.js b/ui/app/components/app/send/send-content/tests/send-content-component.test.js index c5a11c8bb..7d102c930 100644 --- a/ui/app/components/send/send-content/tests/send-content-component.test.js +++ b/ui/app/components/app/send/send-content/tests/send-content-component.test.js @@ -3,7 +3,7 @@ import assert from 'assert' import { shallow } from 'enzyme' import SendContent from '../send-content.component.js' -import PageContainerContent from '../../../page-container/page-container-content.component' +import PageContainerContent from '../../../../ui/page-container/page-container-content.component' import SendAmountRow from '../send-amount-row/send-amount-row.container' import SendFromRow from '../send-from-row/send-from-row.container' import SendGasRow from '../send-gas-row/send-gas-row.container' diff --git a/ui/app/components/send/send-footer/README.md b/ui/app/components/app/send/send-footer/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-footer/README.md +++ b/ui/app/components/app/send/send-footer/README.md diff --git a/ui/app/components/send/send-footer/index.js b/ui/app/components/app/send/send-footer/index.js index 58e91d622..58e91d622 100644 --- a/ui/app/components/send/send-footer/index.js +++ b/ui/app/components/app/send/send-footer/index.js diff --git a/ui/app/components/send/send-footer/send-footer.component.js b/ui/app/components/app/send/send-footer/send-footer.component.js index 230bf450f..cc891a9b3 100644 --- a/ui/app/components/send/send-footer/send-footer.component.js +++ b/ui/app/components/app/send/send-footer/send-footer.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import PageContainerFooter from '../../page-container/page-container-footer' -import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../routes' +import PageContainerFooter from '../../../ui/page-container/page-container-footer' +import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../../helpers/constants/routes' export default class SendFooter extends Component { @@ -26,10 +26,12 @@ export default class SendFooter extends Component { tokenBalance: PropTypes.string, unapprovedTxs: PropTypes.object, update: PropTypes.func, - }; + sendErrors: PropTypes.object, + } static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, }; onCancel () { @@ -56,6 +58,7 @@ export default class SendFooter extends Component { toAccounts, history, } = this.props + const { metricsEvent } = this.context // Should not be needed because submit should be disabled if there are errors. // const noErrors = !amountError && toError === null @@ -66,7 +69,6 @@ export default class SendFooter extends Component { // TODO: add nickname functionality addToAddressBookIfNew(to, toAccounts) - const promise = editingTransactionId ? update({ amount, @@ -82,13 +84,44 @@ export default class SendFooter extends Component { : sign({ data, selectedToken, to, amount, from, gas, gasPrice }) Promise.resolve(promise) - .then(() => history.push(CONFIRM_TRANSACTION_ROUTE)) + .then(() => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Complete', + }, + }) + history.push(CONFIRM_TRANSACTION_ROUTE) + }) } formShouldBeDisabled () { const { data, inError, selectedToken, tokenBalance, gasTotal, to } = this.props const missingTokenBalance = selectedToken && !tokenBalance - return inError || !gasTotal || missingTokenBalance || !(data || to) + const shouldBeDisabled = inError || !gasTotal || missingTokenBalance || !(data || to) + return shouldBeDisabled + } + + componentDidUpdate (prevProps) { + const { inError, sendErrors } = this.props + const { metricsEvent } = this.context + if (!prevProps.inError && inError) { + const errorField = Object.keys(sendErrors).find(key => sendErrors[key]) + const errorMessage = sendErrors[errorField] + + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Error', + }, + customVariables: { + errorField, + errorMessage, + }, + }) + } } render () { diff --git a/ui/app/components/send/send-footer/send-footer.container.js b/ui/app/components/app/send/send-footer/send-footer.container.js index 60de4d030..502159a81 100644 --- a/ui/app/components/send/send-footer/send-footer.container.js +++ b/ui/app/components/app/send/send-footer/send-footer.container.js @@ -6,7 +6,7 @@ import { signTokenTx, signTx, updateTransaction, -} from '../../../actions' +} from '../../../../store/actions' import SendFooter from './send-footer.component' import { getGasLimit, @@ -21,6 +21,7 @@ import { getSendHexData, getTokenBalance, getUnapprovedTxs, + getSendErrors, } from '../send.selectors' import { isSendFormInError, @@ -48,6 +49,7 @@ function mapStateToProps (state) { toAccounts: getSendToAccounts(state), tokenBalance: getTokenBalance(state), unapprovedTxs: getUnapprovedTxs(state), + sendErrors: getSendErrors(state), } } diff --git a/ui/app/components/send/send-footer/send-footer.scss b/ui/app/components/app/send/send-footer/send-footer.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-footer/send-footer.scss +++ b/ui/app/components/app/send/send-footer/send-footer.scss diff --git a/ui/app/components/send/send-footer/send-footer.selectors.js b/ui/app/components/app/send/send-footer/send-footer.selectors.js index e20addfdc..e20addfdc 100644 --- a/ui/app/components/send/send-footer/send-footer.selectors.js +++ b/ui/app/components/app/send/send-footer/send-footer.selectors.js diff --git a/ui/app/components/send/send-footer/send-footer.utils.js b/ui/app/components/app/send/send-footer/send-footer.utils.js index f82ff1e9b..f82ff1e9b 100644 --- a/ui/app/components/send/send-footer/send-footer.utils.js +++ b/ui/app/components/app/send/send-footer/send-footer.utils.js diff --git a/ui/app/components/send/send-footer/tests/send-footer-component.test.js b/ui/app/components/app/send/send-footer/tests/send-footer-component.test.js index 65e4bb654..6683ca8c0 100644 --- a/ui/app/components/send/send-footer/tests/send-footer-component.test.js +++ b/ui/app/components/app/send/send-footer/tests/send-footer-component.test.js @@ -2,10 +2,10 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import sinon from 'sinon' -import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../../routes' +import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../../../helpers/constants/routes' import SendFooter from '../send-footer.component.js' -import PageContainerFooter from '../../../page-container/page-container-footer' +import PageContainerFooter from '../../../../ui/page-container/page-container-footer' const propsMethodSpies = { addToAddressBookIfNew: sinon.spy(), @@ -45,7 +45,8 @@ describe('SendFooter Component', function () { tokenBalance={'mockTokenBalance'} unapprovedTxs={['mockTx']} update={propsMethodSpies.update} - />, { context: { t: str => str } }) + sendErrors={{}} + />, { context: { t: str => str, metricsEvent: () => ({}) } }) }) afterEach(() => { @@ -201,7 +202,7 @@ describe('SendFooter Component', function () { tokenBalance={'mockTokenBalance'} unapprovedTxs={['mockTx']} update={propsMethodSpies.update} - />, { context: { t: str => str } }) + />, { context: { t: str => str, metricsEvent: () => ({}) } }) }) afterEach(() => { diff --git a/ui/app/components/send/send-footer/tests/send-footer-container.test.js b/ui/app/components/app/send/send-footer/tests/send-footer-container.test.js index cf4c893ee..878b0aa19 100644 --- a/ui/app/components/send/send-footer/tests/send-footer-container.test.js +++ b/ui/app/components/app/send/send-footer/tests/send-footer-container.test.js @@ -14,7 +14,9 @@ const actionSpies = { } const utilsStubs = { addressIsNew: sinon.stub().returns(true), - constructTxParams: sinon.stub().returns('mockConstructedTxParams'), + constructTxParams: sinon.stub().returns({ + value: 'mockAmount', + }), constructUpdatedTx: sinon.stub().returns('mockConstructedUpdatedTxParams'), } @@ -26,7 +28,7 @@ proxyquire('../send-footer.container.js', { return () => ({}) }, }, - '../../../actions': actionSpies, + '../../../../store/actions': actionSpies, '../send.selectors': { getGasLimit: (s) => `mockGasLimit:${s}`, getGasPrice: (s) => `mockGasPrice:${s}`, @@ -40,6 +42,7 @@ proxyquire('../send-footer.container.js', { getTokenBalance: (s) => `mockTokenBalance:${s}`, getSendHexData: (s) => `mockHexData:${s}`, getUnapprovedTxs: (s) => `mockUnapprovedTxs:${s}`, + getSendErrors: (s) => `mockSendErrors:${s}`, }, './send-footer.selectors': { isSendFormInError: (s) => `mockInError:${s}` }, './send-footer.utils': utilsStubs, @@ -64,6 +67,7 @@ describe('send-footer container', () => { toAccounts: 'mockToAccounts:mockState', tokenBalance: 'mockTokenBalance:mockState', unapprovedTxs: 'mockUnapprovedTxs:mockState', + sendErrors: 'mockSendErrors:mockState', }) }) @@ -115,7 +119,7 @@ describe('send-footer container', () => { ) assert.deepEqual( actionSpies.signTokenTx.getCall(0).args, - [ '0xabc', 'mockTo', 'mockAmount', 'mockConstructedTxParams' ] + [ '0xabc', 'mockTo', 'mockAmount', { value: 'mockAmount' } ] ) }) @@ -143,7 +147,7 @@ describe('send-footer container', () => { ) assert.deepEqual( actionSpies.signTx.getCall(0).args, - [ 'mockConstructedTxParams' ] + [ { value: 'mockAmount' } ] ) }) }) diff --git a/ui/app/components/send/send-footer/tests/send-footer-selectors.test.js b/ui/app/components/app/send/send-footer/tests/send-footer-selectors.test.js index 8de032f57..8de032f57 100644 --- a/ui/app/components/send/send-footer/tests/send-footer-selectors.test.js +++ b/ui/app/components/app/send/send-footer/tests/send-footer-selectors.test.js diff --git a/ui/app/components/send/send-footer/tests/send-footer-utils.test.js b/ui/app/components/app/send/send-footer/tests/send-footer-utils.test.js index 28ff0c891..28ff0c891 100644 --- a/ui/app/components/send/send-footer/tests/send-footer-utils.test.js +++ b/ui/app/components/app/send/send-footer/tests/send-footer-utils.test.js diff --git a/ui/app/components/send/send-header/README.md b/ui/app/components/app/send/send-header/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send-header/README.md +++ b/ui/app/components/app/send/send-header/README.md diff --git a/ui/app/components/send/send-header/index.js b/ui/app/components/app/send/send-header/index.js index 0b17f0b7d..0b17f0b7d 100644 --- a/ui/app/components/send/send-header/index.js +++ b/ui/app/components/app/send/send-header/index.js diff --git a/ui/app/components/send/send-header/send-header.component.js b/ui/app/components/app/send/send-header/send-header.component.js index efc4bbf27..f216954ef 100644 --- a/ui/app/components/send/send-header/send-header.component.js +++ b/ui/app/components/app/send/send-header/send-header.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import PageContainerHeader from '../../page-container/page-container-header' -import { DEFAULT_ROUTE } from '../../../routes' +import PageContainerHeader from '../../../ui/page-container/page-container-header' +import { DEFAULT_ROUTE } from '../../../../helpers/constants/routes' export default class SendHeader extends Component { diff --git a/ui/app/components/send/send-header/send-header.container.js b/ui/app/components/app/send/send-header/send-header.container.js index 4bcd0d1b6..ce53fba9a 100644 --- a/ui/app/components/send/send-header/send-header.container.js +++ b/ui/app/components/app/send/send-header/send-header.container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux' -import { clearSend } from '../../../actions' +import { clearSend } from '../../../../store/actions' import SendHeader from './send-header.component' import { getSubtitleParams, getTitleKey } from './send-header.selectors' diff --git a/ui/app/components/send/send-header/send-header.selectors.js b/ui/app/components/app/send/send-header/send-header.selectors.js index d7c9d3766..d7c9d3766 100644 --- a/ui/app/components/send/send-header/send-header.selectors.js +++ b/ui/app/components/app/send/send-header/send-header.selectors.js diff --git a/ui/app/components/send/send-header/tests/send-header-component.test.js b/ui/app/components/app/send/send-header/tests/send-header-component.test.js index 930bfa387..db2ee8967 100644 --- a/ui/app/components/send/send-header/tests/send-header-component.test.js +++ b/ui/app/components/app/send/send-header/tests/send-header-component.test.js @@ -2,10 +2,10 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import sinon from 'sinon' -import { DEFAULT_ROUTE } from '../../../../routes' +import { DEFAULT_ROUTE } from '../../../../../helpers/constants/routes' import SendHeader from '../send-header.component.js' -import PageContainerHeader from '../../../page-container/page-container-header' +import PageContainerHeader from '../../../../ui/page-container/page-container-header' const propsMethodSpies = { clearSend: sinon.spy(), diff --git a/ui/app/components/send/send-header/tests/send-header-container.test.js b/ui/app/components/app/send/send-header/tests/send-header-container.test.js index 41a7e8a89..634c3424b 100644 --- a/ui/app/components/send/send-header/tests/send-header-container.test.js +++ b/ui/app/components/app/send/send-header/tests/send-header-container.test.js @@ -17,7 +17,7 @@ proxyquire('../send-header.container.js', { return () => ({}) }, }, - '../../../actions': actionSpies, + '../../../../store/actions': actionSpies, './send-header.selectors': { getTitleKey: (s) => `mockTitleKey:${s}`, getSubtitleParams: (s) => `mockSubtitleParams:${s}`, diff --git a/ui/app/components/send/send-header/tests/send-header-selectors.test.js b/ui/app/components/app/send/send-header/tests/send-header-selectors.test.js index e0c6a3ab3..e0c6a3ab3 100644 --- a/ui/app/components/send/send-header/tests/send-header-selectors.test.js +++ b/ui/app/components/app/send/send-header/tests/send-header-selectors.test.js diff --git a/ui/app/components/send/send.component.js b/ui/app/components/app/send/send.component.js index fb7beca16..a38b681b0 100644 --- a/ui/app/components/send/send.component.js +++ b/ui/app/components/app/send/send.component.js @@ -1,6 +1,6 @@ import React from 'react' import PropTypes from 'prop-types' -import PersistentForm from '../../../lib/persistent-form' +import PersistentForm from '../../../../lib/persistent-form' import { getAmountErrorObject, getGasFeeErrorObject, @@ -8,9 +8,9 @@ import { doesAmountErrorRequireUpdate, } from './send.utils' -import SendHeader from './send-header/' -import SendContent from './send-content/' -import SendFooter from './send-footer/' +import SendHeader from './send-header' +import SendContent from './send-content' +import SendFooter from './send-footer' export default class SendTransactionScreen extends PersistentForm { @@ -35,17 +35,18 @@ export default class SendTransactionScreen extends PersistentForm { selectedToken: PropTypes.object, tokenBalance: PropTypes.string, tokenContract: PropTypes.object, + fetchBasicGasEstimates: PropTypes.func, updateAndSetGasTotal: PropTypes.func, updateSendErrors: PropTypes.func, updateSendTokenBalance: PropTypes.func, scanQrCode: PropTypes.func, qrCodeDetected: PropTypes.func, qrCodeData: PropTypes.object, - }; + } static contextTypes = { t: PropTypes.func, - }; + } componentWillReceiveProps (nextProps) { if (nextProps.qrCodeData) { @@ -73,10 +74,10 @@ export default class SendTransactionScreen extends PersistentForm { selectedAddress, selectedToken = {}, to: currentToAddress, - updateAndSetGasTotal, + updateAndSetGasLimit, } = this.props - updateAndSetGasTotal({ + updateAndSetGasLimit({ blockGasLimit, editingTransactionId, gasLimit, @@ -138,14 +139,12 @@ export default class SendTransactionScreen extends PersistentForm { }) const gasFeeErrorObject = selectedToken ? getGasFeeErrorObject({ - amount, amountConversionRate, balance, conversionRate, gasTotal, primaryCurrency, selectedToken, - tokenBalance, }) : { gasFee: null } updateSendErrors(Object.assign(amountErrorObject, gasFeeErrorObject)) @@ -164,6 +163,13 @@ export default class SendTransactionScreen extends PersistentForm { } } + componentDidMount () { + this.props.fetchBasicGasEstimates() + .then(() => { + this.updateGas() + }) + } + componentWillMount () { const { from: { address }, @@ -171,12 +177,12 @@ export default class SendTransactionScreen extends PersistentForm { tokenContract, updateSendTokenBalance, } = this.props + updateSendTokenBalance({ selectedToken, tokenContract, address, }) - this.updateGas() // Show QR Scanner modal if ?scan=true if (window.location.search === '?scan=true') { diff --git a/ui/app/components/send/send.constants.js b/ui/app/components/app/send/send.constants.js index 8acdf0641..36549038e 100644 --- a/ui/app/components/send/send.constants.js +++ b/ui/app/components/app/send/send.constants.js @@ -1,5 +1,5 @@ const ethUtil = require('ethereumjs-util') -const { conversionUtil, multiplyCurrencies } = require('../../conversion-util') +const { conversionUtil, multiplyCurrencies } = require('../../../helpers/utils/conversion-util') const MIN_GAS_PRICE_DEC = '0' const MIN_GAS_PRICE_HEX = (parseInt(MIN_GAS_PRICE_DEC)).toString(16) @@ -26,7 +26,9 @@ const INSUFFICIENT_FUNDS_ERROR = 'insufficientFunds' const INSUFFICIENT_TOKENS_ERROR = 'insufficientTokens' const NEGATIVE_ETH_ERROR = 'negativeETH' const INVALID_RECIPIENT_ADDRESS_ERROR = 'invalidAddressRecipient' +const INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR = 'invalidAddressRecipientNotEthNetwork' const REQUIRED_ERROR = 'required' +const KNOWN_RECIPIENT_ADDRESS_ERROR = 'knownAddressRecipient' const ONE_GWEI_IN_WEI_HEX = ethUtil.addHexPrefix(conversionUtil('0x1', { fromDenomination: 'GWEI', @@ -42,6 +44,8 @@ module.exports = { INSUFFICIENT_FUNDS_ERROR, INSUFFICIENT_TOKENS_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_ERROR, + INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, MIN_GAS_LIMIT_DEC, MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_DEC, diff --git a/ui/app/components/send/send.container.js b/ui/app/components/app/send/send.container.js index 87056499f..e65463b93 100644 --- a/ui/app/components/send/send.container.js +++ b/ui/app/components/app/send/send.container.js @@ -31,18 +31,21 @@ import { setGasTotal, showQrScanner, qrCodeDetected, -} from '../../actions' +} from '../../../store/actions' import { resetSendState, updateSendErrors, -} from '../../ducks/send.duck' +} from '../../../ducks/send/send.duck' +import { + fetchBasicGasEstimates, +} from '../../../ducks/gas/gas.duck' import { calcGasTotal, } from './send.utils.js' import { SEND_ROUTE, -} from '../../routes' +} from '../../../helpers/constants/routes' module.exports = compose( withRouter, @@ -76,7 +79,7 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return { - updateAndSetGasTotal: ({ + updateAndSetGasLimit: ({ blockGasLimit, editingTransactionId, gasLimit, @@ -89,7 +92,7 @@ function mapDispatchToProps (dispatch) { data, }) => { !editingTransactionId - ? dispatch(updateGasData({ recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value, data })) + ? dispatch(updateGasData({ gasPrice, recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value, data })) : dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) }, updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => { @@ -104,5 +107,6 @@ function mapDispatchToProps (dispatch) { scanQrCode: () => dispatch(showQrScanner(SEND_ROUTE)), qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), + fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()), } } diff --git a/ui/app/components/send/send.scss b/ui/app/components/app/send/send.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/send.scss +++ b/ui/app/components/app/send/send.scss diff --git a/ui/app/components/send/send.selectors.js b/ui/app/components/app/send/send.selectors.js index eb22a08b7..2ec677ad1 100644 --- a/ui/app/components/send/send.selectors.js +++ b/ui/app/components/app/send/send.selectors.js @@ -1,15 +1,21 @@ -const { valuesFor } = require('../../util') +const { valuesFor } = require('../../../helpers/utils/util') const abi = require('human-standard-token-abi') const { multiplyCurrencies, -} = require('../../conversion-util') +} = require('../../../helpers/utils/conversion-util') +const { + getMetaMaskAccounts, +} = require('../../../selectors/selectors') const { estimateGasPriceFromRecentBlocks, + calcGasTotal, } = require('./send.utils') +import { + getFastPriceEstimateInHexWEI, +} from '../../../selectors/custom-gas' const selectors = { accountsWithSendEtherInfoSelector, - // autoAddToBetaUI, getAddressBook, getAmountConversionRate, getBlockGasLimit, @@ -44,6 +50,7 @@ const selectors = { getSendMaxModeState, getSendTo, getSendToAccounts, + getSendWarnings, getTokenBalance, getTokenExchangeRate, getUnapprovedTxs, @@ -54,10 +61,8 @@ const selectors = { module.exports = selectors function accountsWithSendEtherInfoSelector (state) { - const { - accounts, - identities, - } = state.metamask + const accounts = getMetaMaskAccounts(state) + const { identities } = state.metamask const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { return Object.assign({}, account, identities[key]) @@ -66,23 +71,6 @@ function accountsWithSendEtherInfoSelector (state) { return accountsWithSendEtherInfo } -// 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 getAddressBook (state) { return state.metamask.addressBook } @@ -130,11 +118,11 @@ function getForceGasMin (state) { } function getGasLimit (state) { - return state.metamask.send.gasLimit + return state.metamask.send.gasLimit || '0' } function getGasPrice (state) { - return state.metamask.send.gasPrice + return state.metamask.send.gasPrice || getFastPriceEstimateInHexWEI(state) } function getGasPriceFromRecentBlocks (state) { @@ -142,7 +130,7 @@ function getGasPriceFromRecentBlocks (state) { } function getGasTotal (state) { - return state.metamask.send.gasTotal + return calcGasTotal(getGasLimit(state), getGasPrice(state)) } function getPrimaryCurrency (state) { @@ -155,14 +143,14 @@ function getRecentBlocks (state) { } function getSelectedAccount (state) { - const accounts = state.metamask.accounts + const accounts = getMetaMaskAccounts(state) const selectedAddress = getSelectedAddress(state) return accounts[selectedAddress] } function getSelectedAddress (state) { - const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0] + const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0] return selectedAddress } @@ -263,6 +251,10 @@ function getSendToAccounts (state) { return Object.entries(allAccounts).map(([key, account]) => account) } +function getSendWarnings (state) { + return state.send.warnings +} + function getTokenBalance (state) { return state.metamask.send.tokenBalance } diff --git a/ui/app/components/send/send.utils.js b/ui/app/components/app/send/send.utils.js index eb1667c63..7609d46ea 100644 --- a/ui/app/components/send/send.utils.js +++ b/ui/app/components/app/send/send.utils.js @@ -5,10 +5,10 @@ const { multiplyCurrencies, conversionGreaterThan, conversionLessThan, -} = require('../../conversion-util') +} = require('../../../helpers/utils/conversion-util') const { calcTokenAmount, -} = require('../../token-util') +} = require('../../../helpers/utils/token-util') const { BASE_TOKEN_GAS_COST, INSUFFICIENT_FUNDS_ERROR, @@ -89,11 +89,10 @@ function isTokenBalanceSufficient ({ const tokenBalanceIsSufficient = conversionGTE( { value: tokenBalance, - fromNumericBase: 'dec', + fromNumericBase: 'hex', }, { value: calcTokenAmount(amountInDec, decimals), - fromNumericBase: 'dec', }, ) @@ -151,7 +150,6 @@ function getAmountErrorObject ({ } function getGasFeeErrorObject ({ - amount, amountConversionRate, balance, conversionRate, @@ -180,7 +178,7 @@ function getGasFeeErrorObject ({ function calcTokenBalance ({ selectedToken, usersToken }) { const { decimals } = selectedToken || {} - return calcTokenAmount(usersToken.balance.toString(), decimals) + '' + return calcTokenAmount(usersToken.balance.toString(), decimals).toString(16) } function doesAmountErrorRequireUpdate ({ @@ -236,10 +234,14 @@ async function estimateGas ({ if (to) { paramsForGasEstimate.to = to } + + if (!value || value === '0') { + paramsForGasEstimate.value = '0xff' + } } // if not, fall back to block gasLimit - paramsForGasEstimate.gas = ethUtil.addHexPrefix(multiplyCurrencies(blockGasLimit, 0.95, { + paramsForGasEstimate.gas = ethUtil.addHexPrefix(multiplyCurrencies(blockGasLimit || '0x5208', 0.95, { multiplicandBase: 16, multiplierBase: 10, roundDown: '0', diff --git a/ui/app/components/send/tests/send-component.test.js b/ui/app/components/app/send/tests/send-component.test.js index f4943e707..738c14839 100644 --- a/ui/app/components/send/tests/send-component.test.js +++ b/ui/app/components/app/send/tests/send-component.test.js @@ -3,16 +3,23 @@ import assert from 'assert' import proxyquire from 'proxyquire' import { shallow } from 'enzyme' import sinon from 'sinon' +import timeout from '../../../../../lib/test-timeout' import SendHeader from '../send-header/send-header.container' import SendContent from '../send-content/send-content.component' import SendFooter from '../send-footer/send-footer.container' +const mockBasicGasEstimates = { + blockTime: 'mockBlockTime', +} + const propsMethodSpies = { - updateAndSetGasTotal: sinon.spy(), + updateAndSetGasLimit: sinon.spy(), updateSendErrors: sinon.spy(), updateSendTokenBalance: sinon.spy(), resetSendState: sinon.spy(), + fetchBasicGasEstimates: sinon.stub().returns(Promise.resolve(mockBasicGasEstimates)), + fetchGasEstimates: sinon.spy(), } const utilsMethodStubs = { getAmountErrorObject: sinon.stub().returns({ amount: 'mockAmountError' }), @@ -37,6 +44,8 @@ describe('Send Component', function () { blockGasLimit={'mockBlockGasLimit'} conversionRate={10} editingTransactionId={'mockEditingTransactionId'} + fetchBasicGasEstimates={propsMethodSpies.fetchBasicGasEstimates} + fetchGasEstimates={propsMethodSpies.fetchGasEstimates} from={ { address: 'mockAddress', balance: 'mockBalance' } } gasLimit={'mockGasLimit'} gasPrice={'mockGasPrice'} @@ -50,7 +59,7 @@ describe('Send Component', function () { showHexData={true} tokenBalance={'mockTokenBalance'} tokenContract={'mockTokenContract'} - updateAndSetGasTotal={propsMethodSpies.updateAndSetGasTotal} + updateAndSetGasLimit={propsMethodSpies.updateAndSetGasLimit} updateSendErrors={propsMethodSpies.updateSendErrors} updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance} resetSendState={propsMethodSpies.resetSendState} @@ -63,7 +72,8 @@ describe('Send Component', function () { utilsMethodStubs.doesAmountErrorRequireUpdate.resetHistory() utilsMethodStubs.getAmountErrorObject.resetHistory() utilsMethodStubs.getGasFeeErrorObject.resetHistory() - propsMethodSpies.updateAndSetGasTotal.resetHistory() + propsMethodSpies.fetchBasicGasEstimates.resetHistory() + propsMethodSpies.updateAndSetGasLimit.resetHistory() propsMethodSpies.updateSendErrors.resetHistory() propsMethodSpies.updateSendTokenBalance.resetHistory() }) @@ -72,12 +82,20 @@ describe('Send Component', function () { assert(SendTransactionScreen.prototype.componentDidMount.calledOnce) }) - describe('componentWillMount', () => { - it('should call this.updateGas', () => { + describe('componentDidMount', () => { + it('should call props.fetchBasicGasAndTimeEstimates', () => { + propsMethodSpies.fetchBasicGasEstimates.resetHistory() + assert.equal(propsMethodSpies.fetchBasicGasEstimates.callCount, 0) + wrapper.instance().componentDidMount() + assert.equal(propsMethodSpies.fetchBasicGasEstimates.callCount, 1) + }) + + it('should call this.updateGas', async () => { SendTransactionScreen.prototype.updateGas.resetHistory() propsMethodSpies.updateSendErrors.resetHistory() assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0) - wrapper.instance().componentWillMount() + wrapper.instance().componentDidMount() + await timeout(250) assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1) }) }) @@ -158,14 +176,12 @@ describe('Send Component', function () { assert.deepEqual( utilsMethodStubs.getGasFeeErrorObject.getCall(0).args[0], { - amount: 'mockAmount', amountConversionRate: 'mockAmountConversionRate', balance: 'mockBalance', conversionRate: 10, gasTotal: 'mockGasTotal', primaryCurrency: 'mockPrimaryCurrency', selectedToken: 'mockSelectedToken', - tokenBalance: 'mockTokenBalance', } ) }) @@ -273,12 +289,12 @@ describe('Send Component', function () { }) describe('updateGas', () => { - it('should call updateAndSetGasTotal with the correct params if no to prop is passed', () => { - propsMethodSpies.updateAndSetGasTotal.resetHistory() + it('should call updateAndSetGasLimit with the correct params if no to prop is passed', () => { + propsMethodSpies.updateAndSetGasLimit.resetHistory() wrapper.instance().updateGas() - assert.equal(propsMethodSpies.updateAndSetGasTotal.callCount, 1) + assert.equal(propsMethodSpies.updateAndSetGasLimit.callCount, 1) assert.deepEqual( - propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0], + propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0], { blockGasLimit: 'mockBlockGasLimit', editingTransactionId: 'mockEditingTransactionId', @@ -294,20 +310,20 @@ describe('Send Component', function () { ) }) - it('should call updateAndSetGasTotal with the correct params if a to prop is passed', () => { - propsMethodSpies.updateAndSetGasTotal.resetHistory() + it('should call updateAndSetGasLimit with the correct params if a to prop is passed', () => { + propsMethodSpies.updateAndSetGasLimit.resetHistory() wrapper.setProps({ to: 'someAddress' }) wrapper.instance().updateGas() assert.equal( - propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to, + propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0].to, 'someaddress', ) }) - it('should call updateAndSetGasTotal with to set to lowercase if passed', () => { - propsMethodSpies.updateAndSetGasTotal.resetHistory() + it('should call updateAndSetGasLimit with to set to lowercase if passed', () => { + propsMethodSpies.updateAndSetGasLimit.resetHistory() wrapper.instance().updateGas({ to: '0xABC' }) - assert.equal(propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to, '0xabc') + assert.equal(propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0].to, '0xabc') }) }) diff --git a/ui/app/components/send/tests/send-container.test.js b/ui/app/components/app/send/tests/send-container.test.js index 6aa4bf826..9538b67b3 100644 --- a/ui/app/components/send/tests/send-container.test.js +++ b/ui/app/components/app/send/tests/send-container.test.js @@ -47,8 +47,8 @@ proxyquire('../send.container.js', { getTokenBalance: (s) => `mockTokenBalance:${s}`, getQrCodeData: (s) => `mockQrCodeData:${s}`, }, - '../../actions': actionSpies, - '../../ducks/send.duck': duckActionSpies, + '../../../store/actions': actionSpies, + '../../../ducks/send/send.duck': duckActionSpies, './send.utils.js': { calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice, }, @@ -94,7 +94,7 @@ describe('send container', () => { mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) }) - describe('updateAndSetGasTotal()', () => { + describe('updateAndSetGasLimit()', () => { const mockProps = { blockGasLimit: 'mockBlockGasLimit', editingTransactionId: '0x2', @@ -109,7 +109,7 @@ describe('send container', () => { } it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => { - mapDispatchToPropsObject.updateAndSetGasTotal(mockProps) + mapDispatchToPropsObject.updateAndSetGasLimit(mockProps) assert(dispatchSpy.calledOnce) assert.equal( actionSpies.setGasTotal.getCall(0).args[0], @@ -118,14 +118,14 @@ describe('send container', () => { }) it('should dispatch an updateGasData action when editingTransactionId is falsy', () => { - const { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } = mockProps - mapDispatchToPropsObject.updateAndSetGasTotal( + const { gasPrice, selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } = mockProps + mapDispatchToPropsObject.updateAndSetGasLimit( Object.assign({}, mockProps, {editingTransactionId: false}) ) assert(dispatchSpy.calledOnce) assert.deepEqual( actionSpies.updateGasData.getCall(0).args[0], - { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } + { gasPrice, selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } ) }) }) diff --git a/ui/app/components/send/tests/send-selectors-test-data.js b/ui/app/components/app/send/tests/send-selectors-test-data.js index 30a2666cf..d43d7c650 100644 --- a/ui/app/components/send/tests/send-selectors-test-data.js +++ b/ui/app/components/app/send/tests/send-selectors-test-data.js @@ -2,7 +2,7 @@ module.exports = { 'metamask': { 'isInitialized': true, 'isUnlocked': true, - 'featureFlags': {'betaUI': true, 'sendHexData': true}, + 'featureFlags': {'sendHexData': true}, 'rpcTarget': 'https://rawtestrpc.metamask.io/', 'identities': { '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': { @@ -22,6 +22,7 @@ module.exports = { 'name': 'Send Account 4', }, }, + 'cachedBalances': {}, 'currentBlockGasLimit': '0x4c1878', 'currentCurrency': 'USD', 'conversionRate': 1200.88200327, diff --git a/ui/app/components/send/tests/send-selectors.test.js b/ui/app/components/app/send/tests/send-selectors.test.js index e7e901f0d..cdc86fe59 100644 --- a/ui/app/components/send/tests/send-selectors.test.js +++ b/ui/app/components/app/send/tests/send-selectors.test.js @@ -237,7 +237,7 @@ describe('send selectors', () => { it('should return the send.gasTotal', () => { assert.equal( getGasTotal(mockState), - '0xb451dc41b578' + 'a9ff56' ) }) }) diff --git a/ui/app/components/send/tests/send-utils.test.js b/ui/app/components/app/send/tests/send-utils.test.js index b72d87eee..fc4c6deed 100644 --- a/ui/app/components/send/tests/send-utils.test.js +++ b/ui/app/components/app/send/tests/send-utils.test.js @@ -9,7 +9,7 @@ import { const { addCurrencies, subtractCurrencies, -} = require('../../../conversion-util') +} = require('../../../../helpers/utils/conversion-util') const { INSUFFICIENT_FUNDS_ERROR, @@ -32,7 +32,7 @@ const stubs = { } const sendUtils = proxyquire('../send.utils.js', { - '../../conversion-util': { + '../../../helpers/utils/conversion-util': { addCurrencies: stubs.addCurrencies, conversionUtil: stubs.conversionUtil, conversionGTE: stubs.conversionGTE, @@ -40,7 +40,7 @@ const sendUtils = proxyquire('../send.utils.js', { conversionGreaterThan: stubs.conversionGreaterThan, conversionLessThan: stubs.conversionLessThan, }, - '../../token-util': { calcTokenAmount: stubs.calcTokenAmount }, + '../../../helpers/utils/token-util': { calcTokenAmount: stubs.calcTokenAmount }, 'ethereumjs-abi': { rawEncode: stubs.rawEncode, }, @@ -285,11 +285,10 @@ describe('send utils', () => { [ { value: 123, - fromNumericBase: 'dec', + fromNumericBase: 'hex', }, { value: 'calc:1610', - fromNumericBase: 'dec', }, ] ) @@ -317,6 +316,7 @@ describe('send utils', () => { from: 'mockAddress', gas: '0x64x0.95', to: '0xisContract', + value: '0xff', } beforeEach(() => { @@ -374,7 +374,7 @@ describe('send utils', () => { assert.equal(baseMockParams.estimateGasMethod.callCount, 1) assert.deepEqual( baseMockParams.estimateGasMethod.getCall(0).args[0], - { gasPrice: undefined, value: undefined, data, from: baseExpectedCall.from, gas: baseExpectedCall.gas}, + { gasPrice: undefined, value: '0xff', data, from: baseExpectedCall.from, gas: baseExpectedCall.gas}, ) assert.equal(result, '0xabc16') }) diff --git a/ui/app/components/send/to-autocomplete.component.js b/ui/app/components/app/send/to-autocomplete.component.js index 9e270db75..183967c58 100644 --- a/ui/app/components/send/to-autocomplete.component.js +++ b/ui/app/components/app/send/to-autocomplete.component.js @@ -1,7 +1,7 @@ import React, {Component} from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import AccountListItem from '../send/account-list-item/account-list-item.component' +import AccountListItem from './account-list-item/account-list-item.component' export default class ToAutoComplete extends Component { diff --git a/ui/app/components/send/to-autocomplete/index.js b/ui/app/components/app/send/to-autocomplete/index.js index 244d301d1..244d301d1 100644 --- a/ui/app/components/send/to-autocomplete/index.js +++ b/ui/app/components/app/send/to-autocomplete/index.js diff --git a/ui/app/components/send/to-autocomplete/to-autocomplete.js b/ui/app/components/app/send/to-autocomplete/to-autocomplete.js index 39d15dfa7..d3db8cb59 100644 --- a/ui/app/components/send/to-autocomplete/to-autocomplete.js +++ b/ui/app/components/app/send/to-autocomplete/to-autocomplete.js @@ -4,8 +4,8 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const AccountListItem = require('../account-list-item/account-list-item.component').default const connect = require('react-redux').connect -const Tooltip = require('../../tooltip') -const checksumAddress = require('../../../util').checksumAddress +const Tooltip = require('../../../ui/tooltip') +const checksumAddress = require('../../../../helpers/utils/util').checksumAddress ToAutoComplete.contextTypes = { t: PropTypes.func, diff --git a/ui/app/components/shapeshift-form.js b/ui/app/components/app/shapeshift-form.js index a842bcc8b..11459fd5e 100644 --- a/ui/app/components/shapeshift-form.js +++ b/ui/app/components/app/shapeshift-form.js @@ -4,12 +4,12 @@ const PropTypes = require('prop-types') const Component = require('react').Component const connect = require('react-redux').connect const classnames = require('classnames') -const { qrcode } = require('qrcode-npm') -const { shapeShiftSubview, pairUpdate, buyWithShapeShift } = require('../actions') -const { isValidAddress } = require('../util') +const qrcode = require('qrcode-generator') +const { shapeShiftSubview, pairUpdate, buyWithShapeShift } = require('../../store/actions') +const { isValidAddress } = require('../../helpers/utils/util') const SimpleDropdown = require('./dropdowns/simple-dropdown') -import Button from './button' +import Button from '../ui/button' function mapStateToProps (state) { const { diff --git a/ui/app/components/shift-list-item.js b/ui/app/components/app/shift-list-item.js index 0461b615a..f5fa00047 100644 --- a/ui/app/components/shift-list-item.js +++ b/ui/app/components/app/shift-list-item.js @@ -3,14 +3,13 @@ const Component = require('react').Component const PropTypes = require('prop-types') const h = require('react-hyperscript') const connect = require('react-redux').connect -const vreme = new (require('vreme'))() const explorerLink = require('etherscan-link').createExplorerLink -const actions = require('../actions') -const addressSummary = require('../util').addressSummary +const actions = require('../../store/actions') +const { formatDate, addressSummary } = require('../../helpers/utils/util') -const CopyButton = require('./copyButton') -const EthBalance = require('./eth-balance') -const Tooltip = require('./tooltip') +const CopyButton = require('../ui/copyButton') +const EthBalance = require('../ui/eth-balance') +const Tooltip = require('../ui/tooltip') ShiftListItem.contextTypes = { @@ -67,10 +66,6 @@ ShiftListItem.prototype.render = function () { ]) } -function formatDate (date) { - return vreme.format(new Date(date), 'March 16 2014 14:30') -} - ShiftListItem.prototype.renderUtilComponents = function () { var props = this.props const { conversionRate, currentCurrency } = props diff --git a/ui/app/components/sidebars/index.js b/ui/app/components/app/sidebars/index.js index 732925f69..732925f69 100644 --- a/ui/app/components/sidebars/index.js +++ b/ui/app/components/app/sidebars/index.js diff --git a/ui/app/components/sidebars/index.scss b/ui/app/components/app/sidebars/index.scss index 5ab0664df..08181426f 100644 --- a/ui/app/components/sidebars/index.scss +++ b/ui/app/components/app/sidebars/index.scss @@ -1,3 +1,5 @@ +@import 'sidebar-content'; + .sidebar-right-enter { transition: transform 300ms ease-in-out; transform: translateX(-100%); @@ -58,6 +60,11 @@ width: 408px; left: calc(100% - 408px); } + + @media screen and (max-width: $break-small) { + width: 100%; + left: 0%; + } } .sidebar-overlay { @@ -71,4 +78,4 @@ opacity: 1; visibility: visible; background-color: rgba(0, 0, 0, .3); -}
\ No newline at end of file +} diff --git a/ui/app/components/app/sidebars/sidebar-content.scss b/ui/app/components/app/sidebars/sidebar-content.scss new file mode 100644 index 000000000..ca6b0a458 --- /dev/null +++ b/ui/app/components/app/sidebars/sidebar-content.scss @@ -0,0 +1,112 @@ +.sidebar-left { + display: flex; + + .gas-modal-page-container { + display: flex; + + .page-container { + flex: 1; + max-width: 100%; + + &__content { + display: flex; + overflow-y: initial; + } + + @media screen and (max-width: $break-small) { + max-width: 344px; + min-height: auto; + } + + @media screen and (min-width: $break-small) { + max-height: none; + } + } + + .gas-price-chart { + margin-left: 10px; + + &__root { + max-height: 160px !important; + } + } + + .page-container__bottom { + display: flex; + flex-direction: column; + flex-flow: space-between; + height: 100%; + } + + .page-container__content { + overflow-y: inherit; + } + + .basic-tab-content { + height: auto; + margin-bottom: 0px; + border-bottom: 1px solid #d2d8dd; + flex: 1 1 70%; + + @media screen and (max-width: $break-small) { + padding-left: 14px; + padding-bottom: 21px; + } + + .gas-price-button-group--alt { + @media screen and (max-width: $break-small) { + max-width: 318px; + + &__time-estimate { + font-size: 12px; + } + } + } + } + + .advanced-tab { + @media screen and (min-width: $break-small) { + flex: 1 1 70%; + } + + &__fee-chart { + height: 320px; + + @media screen and (max-width: $break-small) { + height: initial; + } + } + + &__fee-chart__speed-buttons { + bottom: 77px; + + @media screen and (max-width: $break-small) { + display: none; + } + } + } + + .gas-modal-content { + display: flex; + flex-direction: column; + width: 100%; + + &__info-row-wrapper { + display: flex; + @media screen and (min-width: $break-small) { + flex: 1 1 30%; + } + } + + &__info-row { + height: 170px; + + @media screen and (max-width: $break-small) { + height: initial; + display: flex; + justify-content: center; + } + } + } + } +}
\ No newline at end of file diff --git a/ui/app/components/app/sidebars/sidebar.component.js b/ui/app/components/app/sidebars/sidebar.component.js new file mode 100644 index 000000000..b9e0f9e81 --- /dev/null +++ b/ui/app/components/app/sidebars/sidebar.component.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ReactCSSTransitionGroup from 'react-addons-css-transition-group' +import WalletView from '../wallet-view' +import { WALLET_VIEW_SIDEBAR } from './sidebar.constants' +import CustomizeGas from '../gas-customization/gas-modal-page-container/' + +export default class Sidebar extends Component { + + static propTypes = { + sidebarOpen: PropTypes.bool, + hideSidebar: PropTypes.func, + sidebarShouldClose: PropTypes.bool, + transitionName: PropTypes.string, + type: PropTypes.string, + sidebarProps: PropTypes.object, + onOverlayClose: PropTypes.func, + }; + + renderOverlay () { + const { onOverlayClose } = this.props + + return <div + className="sidebar-overlay" + onClick={() => { + onOverlayClose && onOverlayClose() + this.props.hideSidebar() + } + } /> + } + + renderSidebarContent () { + const { type, sidebarProps = {} } = this.props + const { transaction = {} } = sidebarProps + switch (type) { + case WALLET_VIEW_SIDEBAR: + return <WalletView responsiveDisplayClassname={'sidebar-right' } /> + case 'customize-gas': + return <div className={'sidebar-left'}><CustomizeGas transaction={transaction} /></div> + default: + return null + } + + } + + componentDidUpdate (prevProps) { + if (!prevProps.sidebarShouldClose && this.props.sidebarShouldClose) { + this.props.hideSidebar() + } + } + + render () { + const { transitionName, sidebarOpen, sidebarShouldClose } = this.props + + return ( + <div> + <ReactCSSTransitionGroup + transitionName={transitionName} + transitionEnterTimeout={300} + transitionLeaveTimeout={200} + > + { sidebarOpen && !sidebarShouldClose ? this.renderSidebarContent() : null } + </ReactCSSTransitionGroup> + { sidebarOpen && !sidebarShouldClose ? this.renderOverlay() : null } + </div> + ) + } + +} diff --git a/ui/app/components/sidebars/sidebar.constants.js b/ui/app/components/app/sidebars/sidebar.constants.js index 1613a8245..1613a8245 100644 --- a/ui/app/components/sidebars/sidebar.constants.js +++ b/ui/app/components/app/sidebars/sidebar.constants.js diff --git a/ui/app/components/sidebars/tests/sidebars-component.test.js b/ui/app/components/app/sidebars/tests/sidebars-component.test.js index e2d77518a..cee22aca8 100644 --- a/ui/app/components/sidebars/tests/sidebars-component.test.js +++ b/ui/app/components/app/sidebars/tests/sidebars-component.test.js @@ -6,6 +6,7 @@ import ReactCSSTransitionGroup from 'react-addons-css-transition-group' import Sidebar from '../sidebar.component.js' import WalletView from '../../wallet-view' +import CustomizeGas from '../../gas-customization/gas-modal-page-container/' const propsMethodSpies = { hideSidebar: sinon.spy(), @@ -59,6 +60,14 @@ describe('Sidebar Component', function () { assert.equal(renderSidebarContent.props.responsiveDisplayClassname, 'sidebar-right') }) + it('should render sidebar content with the correct props', () => { + wrapper.setProps({ type: 'customize-gas' }) + renderSidebarContent = wrapper.instance().renderSidebarContent() + const renderedSidebarContent = shallow(renderSidebarContent) + assert(renderedSidebarContent.hasClass('sidebar-left')) + assert(renderedSidebarContent.childAt(0).is(CustomizeGas)) + }) + it('should not render with an unrecognized type', () => { wrapper.setProps({ type: 'foobar' }) renderSidebarContent = wrapper.instance().renderSidebarContent() diff --git a/ui/app/components/signature-request.js b/ui/app/components/app/signature-request.js index 85af3b00b..e47791b67 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/app/signature-request.js @@ -2,7 +2,9 @@ const Component = require('react').Component const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits -import Identicon from './identicon' +import { ENVIRONMENT_TYPE_NOTIFICATION } from '../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../app/scripts/lib/util' +import Identicon from '../ui/identicon' const connect = require('react-redux').connect const ethUtil = require('ethereumjs-util') const classnames = require('classnames') @@ -10,10 +12,10 @@ const { compose } = require('recompose') const { withRouter } = require('react-router-dom') const { ObjectInspector } = require('react-inspector') -const AccountDropdownMini = require('./dropdowns/account-dropdown-mini') +import AccountDropdownMini from '../ui/account-dropdown-mini' -const actions = require('../actions') -const { conversionUtil } = require('../conversion-util') +const actions = require('../../store/actions') +const { conversionUtil } = require('../../helpers/utils/conversion-util') const { getSelectedAccount, @@ -21,12 +23,12 @@ const { getSelectedAddress, accountsWithSendEtherInfoSelector, conversionRateSelector, -} = require('../selectors.js') +} = require('../../selectors/selectors.js') -import { clearConfirmTransaction } from '../ducks/confirm-transaction.duck' -import Button from './button' +import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck' +import Button from '../ui/button' -const { DEFAULT_ROUTE } = require('../routes') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') function mapStateToProps (state) { return { @@ -47,13 +49,50 @@ function mapDispatchToProps (dispatch) { } } +function mergeProps (stateProps, dispatchProps, ownProps) { + const { + signPersonalMessage, + signTypedMessage, + cancelPersonalMessage, + cancelTypedMessage, + signMessage, + cancelMessage, + txData, + } = ownProps + + 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 { + ...stateProps, + ...dispatchProps, + ...ownProps, + txData, + cancel, + sign, + } +} + SignatureRequest.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( withRouter, - connect(mapStateToProps, mapDispatchToProps) + connect(mapStateToProps, mapDispatchToProps, mergeProps) )(SignatureRequest) @@ -63,7 +102,24 @@ function SignatureRequest (props) { this.state = { selectedAccount: props.selectedAccount, - accountDropdownOpen: false, + } +} + +SignatureRequest.prototype.componentDidMount = function () { + const { clearConfirmTransaction, cancel } = this.props + const { metricsEvent } = this.context + if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_NOTIFICATION) { + window.onbeforeunload = event => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Sign Request', + name: 'Cancel Sig Request Via Notification Close', + }, + }) + clearConfirmTransaction() + cancel(event) + } } } @@ -82,10 +138,7 @@ SignatureRequest.prototype.renderHeader = function () { } SignatureRequest.prototype.renderAccountDropdown = function () { - const { - selectedAccount, - accountDropdownOpen, - } = this.state + const { selectedAccount } = this.state const { accounts, @@ -98,10 +151,7 @@ SignatureRequest.prototype.renderAccountDropdown = function () { h(AccountDropdownMini, { selectedAccount, accounts, - onSelect: selectedAccount => this.setState({ selectedAccount }), - dropdownOpen: accountDropdownOpen, - openDropdown: () => this.setState({ accountDropdownOpen: true }), - closeDropdown: () => this.setState({ accountDropdownOpen: false }), + disabled: true, }), ]) @@ -164,7 +214,7 @@ SignatureRequest.prototype.msgHexToText = function (hex) { try { const stripped = ethUtil.stripHexPrefix(hex) const buff = Buffer.from(stripped, 'hex') - return buff.toString('utf8') + return buff.length === 32 ? hex : buff.toString('utf8') } catch (e) { return hex } @@ -239,30 +289,7 @@ SignatureRequest.prototype.renderBody = function () { } 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 - } + const { cancel, sign } = this.props return h('div.request-signature__footer', [ h(Button, { @@ -271,6 +298,13 @@ SignatureRequest.prototype.renderFooter = function () { className: 'request-signature__footer__cancel-button', onClick: event => { cancel(event).then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Sign Request', + name: 'Cancel', + }, + }) this.props.clearConfirmTransaction() this.props.history.push(DEFAULT_ROUTE) }) @@ -279,8 +313,16 @@ SignatureRequest.prototype.renderFooter = function () { h(Button, { type: 'primary', large: true, + className: 'request-signature__footer__sign-button', onClick: event => { sign(event).then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Sign Request', + name: 'Confirm', + }, + }) this.props.clearConfirmTransaction() this.props.history.push(DEFAULT_ROUTE) }) diff --git a/ui/app/components/app/tab-bar.js b/ui/app/components/app/tab-bar.js new file mode 100644 index 000000000..43923989a --- /dev/null +++ b/ui/app/components/app/tab-bar.js @@ -0,0 +1,37 @@ +import React, { Component } from 'react' +const PropTypes = require('prop-types') +const classnames = require('classnames') + +class TabBar extends Component { + render () { + const { tabs = [], onSelect, isActive } = this.props + + return ( + <div className="tab-bar"> + {tabs.map(({ key, content, description }) => ( + <div + key={key} + className={classnames('tab-bar__tab pointer', { + 'tab-bar__tab--active': isActive(key, content), + })} + onClick={() => onSelect(key)} + > + <div className="tab-bar__tab__content"> + <div className="tab-bar__tab__content__title">{content}</div> + <div className="tab-bar__tab__content__description">{description}</div> + </div> + <div className="tab-bar__tab__caret" /> + </div> + ))} + </div> + ) + } +} + +TabBar.propTypes = { + isActive: PropTypes.func.isRequired, + tabs: PropTypes.array, + onSelect: PropTypes.func, +} + +module.exports = TabBar diff --git a/ui/app/components/token-cell.js b/ui/app/components/app/token-cell.js index 75ba347fa..cef809e8a 100644 --- a/ui/app/components/token-cell.js +++ b/ui/app/components/app/token-cell.js @@ -1,12 +1,13 @@ const Component = require('react').Component +const PropTypes = require('prop-types') const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect -import Identicon from './identicon' -const prefixForNetwork = require('../../lib/etherscan-prefix-for-network') -const selectors = require('../selectors') -const actions = require('../actions') -const { conversionUtil, multiplyCurrencies } = require('../conversion-util') +import Identicon from '../ui/identicon' +const prefixForNetwork = require('../../../lib/etherscan-prefix-for-network') +const selectors = require('../../selectors/selectors') +const actions = require('../../store/actions') +const { conversionUtil, multiplyCurrencies } = require('../../helpers/utils/conversion-util') const TokenMenuDropdown = require('./dropdowns/token-menu-dropdown.js') @@ -40,6 +41,10 @@ function TokenCell () { } } +TokenCell.contextTypes = { + metricsEvent: PropTypes.func, +} + TokenCell.prototype.render = function () { const { tokenMenuOpen } = this.state const props = this.props @@ -88,6 +93,13 @@ TokenCell.prototype.render = function () { // onClick: this.view.bind(this, address, userAddress, network), onClick: () => { setSelectedToken(address) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Token Menu', + name: 'Clicked Token', + }, + }) selectedTokenAddress !== address && sidebarOpen && hideSidebar() }, }, [ diff --git a/ui/app/components/token-list.js b/ui/app/components/app/token-list.js index 6a88f30bf..2188e7020 100644 --- a/ui/app/components/token-list.js +++ b/ui/app/components/app/token-list.js @@ -5,7 +5,7 @@ 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') +const selectors = require('../../selectors/selectors') const log = require('loglevel') function mapStateToProps (state) { @@ -134,17 +134,17 @@ TokenList.prototype.createFreshTokenTracker = function () { }) } -TokenList.prototype.componentDidUpdate = function (nextProps) { +TokenList.prototype.componentDidUpdate = function (prevProps) { const { network: oldNet, userAddress: oldAddress, tokens, - } = this.props + } = prevProps const { network: newNet, userAddress: newAddress, tokens: newTokens, - } = nextProps + } = this.props const isLoading = newNet === 'loading' const missingInfo = !oldNet || !newNet || !oldAddress || !newAddress diff --git a/ui/app/components/transaction-action/index.js b/ui/app/components/app/transaction-action/index.js index a6e9097f1..a6e9097f1 100644 --- a/ui/app/components/transaction-action/index.js +++ b/ui/app/components/app/transaction-action/index.js diff --git a/ui/app/components/transaction-action/tests/transaction-action.component.test.js b/ui/app/components/app/transaction-action/tests/transaction-action.component.test.js index b22a9db39..b22a9db39 100644 --- a/ui/app/components/transaction-action/tests/transaction-action.component.test.js +++ b/ui/app/components/app/transaction-action/tests/transaction-action.component.test.js diff --git a/ui/app/components/transaction-action/transaction-action.component.js b/ui/app/components/app/transaction-action/transaction-action.component.js index 1de91cb71..4a5efdaae 100644 --- a/ui/app/components/transaction-action/transaction-action.component.js +++ b/ui/app/components/app/transaction-action/transaction-action.component.js @@ -1,8 +1,8 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { getTransactionActionKey } from '../../helpers/transactions.util' -import { camelCaseToCapitalize } from '../../helpers/common.util' +import { getTransactionActionKey } from '../../../helpers/utils/transactions.util' +import { camelCaseToCapitalize } from '../../../helpers/utils/common.util' export default class TransactionAction extends PureComponent { static contextTypes = { diff --git a/ui/app/components/transaction-activity-log/index.js b/ui/app/components/app/transaction-activity-log/index.js index a33da15a3..a33da15a3 100644 --- a/ui/app/components/transaction-activity-log/index.js +++ b/ui/app/components/app/transaction-activity-log/index.js diff --git a/ui/app/components/transaction-activity-log/index.scss b/ui/app/components/app/transaction-activity-log/index.scss index 27f3006b3..00c17e6aa 100644 --- a/ui/app/components/transaction-activity-log/index.scss +++ b/ui/app/components/app/transaction-activity-log/index.scss @@ -1,7 +1,8 @@ .transaction-activity-log { - &__card { - background: $white; - height: 100%; + &__title { + border-bottom: 1px solid #d8d8d8; + padding-bottom: 4px; + text-transform: capitalize; } &__activities-container { @@ -21,8 +22,8 @@ left: 0; top: 0; height: 100%; - width: 6px; - border-right: 1px solid $scorpion; + width: 7px; + border-right: 1px solid #909090; } &:first-child::after { @@ -40,22 +41,25 @@ } &__activity-icon { - width: 13px; - height: 13px; + width: 15px; + height: 15px; margin-right: 6px; border-radius: 50%; - background: $scorpion; + background: #909090; flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + z-index: 1; } &__activity-text { - color: $scorpion; + color: $dusty-gray; font-size: .75rem; + cursor: pointer; - @media screen and (min-width: $break-large) { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + &:hover { + color: $black; } } @@ -64,6 +68,16 @@ font-weight: 500; } + &__entry-container { + min-width: 0; + } + + &__action-link { + font-size: .75rem; + cursor: pointer; + color: $curious-blue; + } + b { font-weight: 500; } diff --git a/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.component.test.js b/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.component.test.js new file mode 100644 index 000000000..a2946e53d --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.component.test.js @@ -0,0 +1,101 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionActivityLog from '../transaction-activity-log.component' + +describe('TransactionActivityLog Component', () => { + it('should render properly', () => { + const activities = [ + { + eventKey: 'transactionCreated', + hash: '0xe46c7f9b39af2fbf1c53e66f72f80343ab54c2c6dba902d51fb98ada08fe1a63', + id: 2005383477493174, + timestamp: 1543957986150, + value: '0x2386f26fc10000', + }, { + eventKey: 'transactionSubmitted', + hash: '0xe46c7f9b39af2fbf1c53e66f72f80343ab54c2c6dba902d51fb98ada08fe1a63', + id: 2005383477493174, + timestamp: 1543957987853, + value: '0x1319718a5000', + }, { + eventKey: 'transactionResubmitted', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 2005383477493175, + timestamp: 1543957991563, + value: '0x1502634b5800', + }, { + eventKey: 'transactionConfirmed', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 2005383477493175, + timestamp: 1543958029960, + value: '0x1502634b5800', + }, + ] + + const wrapper = shallow( + <TransactionActivityLog + activities={activities} + className="test-class" + inlineRetryIndex={-1} + inlineCancelIndex={-1} + nativeCurrency="ETH" + onCancel={() => {}} + onRetry={() => {}} + primaryTransactionStatus="confirmed" + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-activity-log')) + assert.ok(wrapper.hasClass('test-class')) + }) + + it('should render inline retry and cancel buttons', () => { + const activities = [ + { + eventKey: 'transactionCreated', + hash: '0xa', + id: 1, + timestamp: 1, + value: '0x1', + }, { + eventKey: 'transactionSubmitted', + hash: '0xa', + id: 1, + timestamp: 2, + value: '0x1', + }, { + eventKey: 'transactionResubmitted', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 2, + timestamp: 3, + value: '0x1', + }, { + eventKey: 'transactionCancelAttempted', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 3, + timestamp: 4, + value: '0x1', + }, + ] + + const wrapper = shallow( + <TransactionActivityLog + activities={activities} + className="test-class" + inlineRetryIndex={2} + inlineCancelIndex={3} + nativeCurrency="ETH" + onCancel={() => {}} + onRetry={() => {}} + primaryTransactionStatus="pending" + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-activity-log')) + assert.ok(wrapper.hasClass('test-class')) + assert.equal(wrapper.find('.transaction-activity-log__action-link').length, 2) + }) +}) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js b/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.container.test.js index a7c35f51e..a7c35f51e 100644 --- a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js +++ b/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.container.test.js diff --git a/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.util.test.js b/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.util.test.js new file mode 100644 index 000000000..d014b8886 --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/tests/transaction-activity-log.util.test.js @@ -0,0 +1,335 @@ +import assert from 'assert' +import { combineTransactionHistories, getActivities } from '../transaction-activity-log.util' + +describe('combineTransactionHistories', () => { + it('should return no activites for an empty list of transactions', () => { + assert.deepEqual(combineTransactionHistories([]), []) + }) + + it('should return activities for an array of transactions', () => { + const transactions = [ + { + estimatedGas: '0x5208', + hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3', + history: [ + { + 'id': 6400627574331058, + 'time': 1543958845581, + 'status': 'unapproved', + 'metamaskNetworkId': '3', + 'loadingDefaults': true, + 'txParams': { + 'from': '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + 'to': '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + 'value': '0x2386f26fc10000', + 'gas': '0x5208', + 'gasPrice': '0x3b9aca00', + }, + 'type': 'standard', + }, + [{ 'op': 'replace', 'path': '/status', 'value': 'approved', 'note': 'txStateManager: setting status to approved', 'timestamp': 1543958847813 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'submitted', 'note': 'txStateManager: setting status to submitted', 'timestamp': 1543958848147 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'dropped', 'note': 'txStateManager: setting status to dropped', 'timestamp': 1543958897181 }, { 'op': 'add', 'path': '/replacedBy', 'value': '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33' }], + ], + id: 6400627574331058, + loadingDefaults: false, + metamaskNetworkId: '3', + status: 'dropped', + submittedTime: 1543958848135, + time: 1543958845581, + txParams: { + from: '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0x32', + to: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + value: '0x2386f26fc10000', + }, + type: 'standard', + }, { + hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33', + history: [ + { + 'id': 6400627574331060, + 'time': 1543958857697, + 'status': 'unapproved', + 'metamaskNetworkId': '3', + 'loadingDefaults': false, + 'txParams': { + 'from': '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + 'to': '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + 'value': '0x2386f26fc10000', + 'gas': '0x5208', + 'gasPrice': '0x3b9aca00', + 'nonce': '0x32', + }, + 'lastGasPrice': '0x4190ab00', + 'type': 'retry', + }, + [{ 'op': 'replace', 'path': '/txParams/gasPrice', 'value': '0x481f2280', 'note': 'confTx: user approved transaction', 'timestamp': 1543958859470 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'approved', 'note': 'txStateManager: setting status to approved', 'timestamp': 1543958859485 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'signed', 'note': 'transactions#publishTransaction', 'timestamp': 1543958859889 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'submitted', 'note': 'txStateManager: setting status to submitted', 'timestamp': 1543958860061 }], [{ 'op': 'add', 'path': '/firstRetryBlockNumber', 'value': '0x45a0fd', 'note': 'transactions/pending-tx-tracker#event: tx:block-update', 'timestamp': 1543958896466 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'confirmed', 'timestamp': 1543958897165 }], + ], + id: 6400627574331060, + lastGasPrice: '0x4190ab00', + loadingDefaults: false, + metamaskNetworkId: '3', + status: 'confirmed', + submittedTime: 1543958860054, + time: 1543958857697, + txParams: { + from: '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + gas: '0x5208', + gasPrice: '0x481f2280', + nonce: '0x32', + to: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + value: '0x2386f26fc10000', + }, + txReceipt: { + status: '0x1', + }, + type: 'retry', + }, + ] + + const expected = [ + { + id: 6400627574331058, + hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3', + eventKey: 'transactionCreated', + timestamp: 1543958845581, + value: '0x2386f26fc10000', + }, { + id: 6400627574331058, + hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3', + eventKey: 'transactionSubmitted', + timestamp: 1543958848147, + value: '0x1319718a5000', + }, { + id: 6400627574331060, + hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33', + eventKey: 'transactionResubmitted', + timestamp: 1543958860061, + value: '0x171c3a061400', + }, { + id: 6400627574331060, + hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33', + eventKey: 'transactionConfirmed', + timestamp: 1543958897165, + value: '0x171c3a061400', + }, + ] + + assert.deepEqual(combineTransactionHistories(transactions), expected) + }) +}) + +describe('getActivities', () => { + it('should return no activities for an empty history', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + assert.deepEqual(getActivities(transaction), []) + }) + + it('should return activities for a transaction\'s history', () => { + const transaction = { + history: [ + { + id: 5559712943815343, + loadingDefaults: true, + metamaskNetworkId: '3', + status: 'unapproved', + time: 1535507561452, + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + }, + [ + { + op: 'replace', + path: '/loadingDefaults', + timestamp: 1535507561515, + value: false, + }, + { + op: 'add', + path: '/gasPriceSpecified', + value: true, + }, + { + op: 'add', + path: '/gasLimitSpecified', + value: true, + }, + { + op: 'add', + path: '/estimatedGas', + value: '0x5208', + }, + ], + [ + { + note: '#newUnapprovedTransaction - adding the origin', + op: 'add', + path: '/origin', + timestamp: 1535507561516, + value: 'MetaMask', + }, + [], + ], + [ + { + note: 'confTx: user approved transaction', + op: 'replace', + path: '/txParams/gasPrice', + timestamp: 1535664571504, + value: '0x77359400', + }, + ], + [ + { + note: 'txStateManager: setting status to approved', + op: 'replace', + path: '/status', + timestamp: 1535507564302, + value: 'approved', + }, + ], + [ + { + note: 'transactions#approveTransaction', + op: 'add', + path: '/txParams/nonce', + timestamp: 1535507564439, + value: '0xa4', + }, + { + op: 'add', + path: '/nonceDetails', + value: { + local: {}, + network: {}, + params: {}, + }, + }, + ], + [ + { + note: 'transactions#publishTransaction', + op: 'replace', + path: '/status', + timestamp: 1535507564518, + value: 'signed', + }, + { + op: 'add', + path: '/rawTx', + value: '0xf86b81a4843b9aca008252089450a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706872386f26fc10000802aa007b30119fc4fc5954fad727895b7e3ba80a78d197e95703cc603bcf017879151a01c50beda40ffaee541da9c05b9616247074f25f392800e0ad6c7a835d5366edf', + }, + ], + [], + [ + { + note: 'transactions#setTxHash', + op: 'add', + path: '/hash', + timestamp: 1535507564658, + value: '0x7acc4987b5c0dfa8d423798a8c561138259de1f98a62e3d52e7e83c0e0dd9fb7', + }, + ], + [ + { + note: 'txStateManager - add submitted time stamp', + op: 'add', + path: '/submittedTime', + timestamp: 1535507564660, + value: 1535507564660, + }, + ], + [ + { + note: 'txStateManager: setting status to submitted', + op: 'replace', + path: '/status', + timestamp: 1535507564665, + value: 'submitted', + }, + ], + [ + { + note: 'transactions/pending-tx-tracker#event: tx:block-update', + op: 'add', + path: '/firstRetryBlockNumber', + timestamp: 1535507575476, + value: '0x3bf624', + }, + ], + [ + { + note: 'txStateManager: setting status to confirmed', + op: 'replace', + path: '/status', + timestamp: 1535507615993, + value: 'confirmed', + }, + ], + ], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + hash: '0xabc', + } + + const expectedResult = [ + { + 'eventKey': 'transactionCreated', + 'timestamp': 1535507561452, + 'value': '0x2386f26fc10000', + 'id': 1, + 'hash': '0xabc', + }, + { + 'eventKey': 'transactionSubmitted', + 'timestamp': 1535507564665, + 'value': '0x2632e314a000', + 'id': 1, + 'hash': '0xabc', + }, + { + 'eventKey': 'transactionConfirmed', + 'timestamp': 1535507615993, + 'value': '0x2632e314a000', + 'id': 1, + 'hash': '0xabc', + }, + ] + + assert.deepEqual(getActivities(transaction, true), expectedResult) + }) +}) diff --git a/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/index.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/index.js new file mode 100644 index 000000000..86b12360a --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/index.js @@ -0,0 +1 @@ +export { default } from './transaction-activity-log-icon.component' diff --git a/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js new file mode 100644 index 000000000..871716002 --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js @@ -0,0 +1,55 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import { + TRANSACTION_CREATED_EVENT, + TRANSACTION_SUBMITTED_EVENT, + TRANSACTION_RESUBMITTED_EVENT, + TRANSACTION_CONFIRMED_EVENT, + TRANSACTION_DROPPED_EVENT, + TRANSACTION_ERRORED_EVENT, + TRANSACTION_CANCEL_ATTEMPTED_EVENT, + TRANSACTION_CANCEL_SUCCESS_EVENT, +} from '../transaction-activity-log.constants' + +const imageHash = { + [TRANSACTION_CREATED_EVENT]: '/images/icons/new.svg', + [TRANSACTION_SUBMITTED_EVENT]: '/images/icons/submitted.svg', + [TRANSACTION_RESUBMITTED_EVENT]: '/images/icons/retry.svg', + [TRANSACTION_CONFIRMED_EVENT]: '/images/icons/confirm.svg', + [TRANSACTION_DROPPED_EVENT]: '/images/icons/cancelled.svg', + [TRANSACTION_ERRORED_EVENT]: '/images/icons/error.svg', + [TRANSACTION_CANCEL_ATTEMPTED_EVENT]: '/images/icons/cancelled.svg', + [TRANSACTION_CANCEL_SUCCESS_EVENT]: '/images/icons/cancelled.svg', +} + +export default class TransactionActivityLogIcon extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + className: PropTypes.string, + eventKey: PropTypes.oneOf(Object.keys(imageHash)), + } + + render () { + const { className, eventKey } = this.props + const imagePath = imageHash[eventKey] + + return ( + <div className={classnames('transaction-activity-log-icon', className)}> + { + imagePath && ( + <img + src={imagePath} + height={9} + width={9} + /> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/app/transaction-activity-log/transaction-activity-log.component.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log.component.js new file mode 100644 index 000000000..de4d29750 --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log.component.js @@ -0,0 +1,131 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { getEthConversionFromWeiHex, getValueFromWeiHex } from '../../../helpers/utils/conversions.util' +import { formatDate } from '../../../helpers/utils/util' +import TransactionActivityLogIcon from './transaction-activity-log-icon' +import { CONFIRMED_STATUS } from './transaction-activity-log.constants' +import prefixForNetwork from '../../../../lib/etherscan-prefix-for-network' + +export default class TransactionActivityLog extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricEvent: PropTypes.func, + } + + static propTypes = { + activities: PropTypes.array, + className: PropTypes.string, + conversionRate: PropTypes.number, + inlineRetryIndex: PropTypes.number, + inlineCancelIndex: PropTypes.number, + nativeCurrency: PropTypes.string, + onCancel: PropTypes.func, + onRetry: PropTypes.func, + primaryTransaction: PropTypes.object, + } + + handleActivityClick = hash => { + const { primaryTransaction } = this.props + const { metamaskNetworkId } = primaryTransaction + + const prefix = prefixForNetwork(metamaskNetworkId) + const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` + + global.platform.openWindow({ url: etherscanUrl }) + } + + renderInlineRetry (index, activity) { + const { t } = this.context + const { inlineRetryIndex, primaryTransaction = {}, onRetry } = this.props + const { status } = primaryTransaction + const { id } = activity + + return status !== CONFIRMED_STATUS && index === inlineRetryIndex + ? ( + <div + className="transaction-activity-log__action-link" + onClick={() => onRetry(id)} + > + { t('speedUpTransaction') } + </div> + ) : null + } + + renderInlineCancel (index, activity) { + const { t } = this.context + const { inlineCancelIndex, primaryTransaction = {}, onCancel } = this.props + const { status } = primaryTransaction + const { id } = activity + + return status !== CONFIRMED_STATUS && index === inlineCancelIndex + ? ( + <div + className="transaction-activity-log__action-link" + onClick={() => onCancel(id)} + > + { t('speedUpCancellation') } + </div> + ) : null + } + + renderActivity (activity, index) { + const { conversionRate, nativeCurrency } = this.props + const { eventKey, value, timestamp, hash } = activity + const ethValue = index === 0 + ? `${getValueFromWeiHex({ + value, + fromCurrency: nativeCurrency, + toCurrency: nativeCurrency, + conversionRate, + numberOfDecimals: 6, + })} ${nativeCurrency}` + : getEthConversionFromWeiHex({ + value, + fromCurrency: nativeCurrency, + conversionRate, + numberOfDecimals: 3, + }) + const formattedTimestamp = formatDate(timestamp, 'T \'on\' M/d/y') + const activityText = this.context.t(eventKey, [ethValue, formattedTimestamp]) + + return ( + <div + key={index} + className="transaction-activity-log__activity" + > + <TransactionActivityLogIcon + className="transaction-activity-log__activity-icon" + eventKey={eventKey} + /> + <div className="transaction-activity-log__entry-container"> + <div + className="transaction-activity-log__activity-text" + title={activityText} + onClick={() => this.handleActivityClick(hash)} + > + { activityText } + </div> + { this.renderInlineRetry(index, activity) } + { this.renderInlineCancel(index, activity) } + </div> + </div> + ) + } + + render () { + const { t } = this.context + const { className, activities } = this.props + + return ( + <div className={classnames('transaction-activity-log', className)}> + <div className="transaction-activity-log__title"> + { t('activityLog') } + </div> + <div className="transaction-activity-log__activities-container"> + { activities.map((activity, index) => this.renderActivity(activity, index)) } + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/transaction-activity-log/transaction-activity-log.constants.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log.constants.js new file mode 100644 index 000000000..72e63d85c --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log.constants.js @@ -0,0 +1,13 @@ +export const TRANSACTION_CREATED_EVENT = 'transactionCreated' +export const TRANSACTION_SUBMITTED_EVENT = 'transactionSubmitted' +export const TRANSACTION_RESUBMITTED_EVENT = 'transactionResubmitted' +export const TRANSACTION_CONFIRMED_EVENT = 'transactionConfirmed' +export const TRANSACTION_DROPPED_EVENT = 'transactionDropped' +export const TRANSACTION_UPDATED_EVENT = 'transactionUpdated' +export const TRANSACTION_ERRORED_EVENT = 'transactionErrored' +export const TRANSACTION_CANCEL_ATTEMPTED_EVENT = 'transactionCancelAttempted' +export const TRANSACTION_CANCEL_SUCCESS_EVENT = 'transactionCancelSuccess' + +export const SUBMITTED_STATUS = 'submitted' +export const CONFIRMED_STATUS = 'confirmed' +export const DROPPED_STATUS = 'dropped' diff --git a/ui/app/components/app/transaction-activity-log/transaction-activity-log.container.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log.container.js new file mode 100644 index 000000000..11b20f245 --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log.container.js @@ -0,0 +1,44 @@ +import { connect } from 'react-redux' +import R from 'ramda' +import TransactionActivityLog from './transaction-activity-log.component' +import { conversionRateSelector, getNativeCurrency } from '../../../selectors/selectors' +import { combineTransactionHistories } from './transaction-activity-log.util' +import { + TRANSACTION_RESUBMITTED_EVENT, + TRANSACTION_CANCEL_ATTEMPTED_EVENT, +} from './transaction-activity-log.constants' + +const matchesEventKey = matchEventKey => ({ eventKey }) => eventKey === matchEventKey + +const mapStateToProps = state => { + return { + conversionRate: conversionRateSelector(state), + nativeCurrency: getNativeCurrency(state), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { + transactionGroup: { + transactions = [], + primaryTransaction, + } = {}, + ...restOwnProps + } = ownProps + + const activities = combineTransactionHistories(transactions) + const inlineRetryIndex = R.findLastIndex(matchesEventKey(TRANSACTION_RESUBMITTED_EVENT))(activities) + const inlineCancelIndex = R.findLastIndex(matchesEventKey(TRANSACTION_CANCEL_ATTEMPTED_EVENT))(activities) + + return { + ...stateProps, + ...dispatchProps, + ...restOwnProps, + activities, + inlineRetryIndex, + inlineCancelIndex, + primaryTransaction, + } +} + +export default connect(mapStateToProps, null, mergeProps)(TransactionActivityLog) diff --git a/ui/app/components/app/transaction-activity-log/transaction-activity-log.util.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log.util.js new file mode 100644 index 000000000..b74513879 --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log.util.js @@ -0,0 +1,233 @@ +import { getHexGasTotal } from '../../../helpers/utils/confirm-tx.util' + +// path constants +const STATUS_PATH = '/status' +const GAS_PRICE_PATH = '/txParams/gasPrice' +const GAS_LIMIT_PATH = '/txParams/gas' + +// op constants +const REPLACE_OP = 'replace' + +import { + // event constants + TRANSACTION_CREATED_EVENT, + TRANSACTION_SUBMITTED_EVENT, + TRANSACTION_RESUBMITTED_EVENT, + TRANSACTION_CONFIRMED_EVENT, + TRANSACTION_DROPPED_EVENT, + TRANSACTION_UPDATED_EVENT, + TRANSACTION_ERRORED_EVENT, + TRANSACTION_CANCEL_ATTEMPTED_EVENT, + TRANSACTION_CANCEL_SUCCESS_EVENT, + // status constants + SUBMITTED_STATUS, + CONFIRMED_STATUS, + DROPPED_STATUS, +} from './transaction-activity-log.constants' + +import { + TRANSACTION_TYPE_CANCEL, + TRANSACTION_TYPE_RETRY, +} from '../../../../../app/scripts/controllers/transactions/enums' + +const eventPathsHash = { + [STATUS_PATH]: true, + [GAS_PRICE_PATH]: true, + [GAS_LIMIT_PATH]: true, +} + +const statusHash = { + [SUBMITTED_STATUS]: TRANSACTION_SUBMITTED_EVENT, + [CONFIRMED_STATUS]: TRANSACTION_CONFIRMED_EVENT, + [DROPPED_STATUS]: TRANSACTION_DROPPED_EVENT, +} + +/** + * @name getActivities + * @param {Object} transaction - txMeta object + * @param {boolean} isFirstTransaction - True if the transaction is the first created transaction + * in the list of transactions with the same nonce. If so, we use this transaction to create the + * transactionCreated activity. + * @returns {Array} + */ +export function getActivities (transaction, isFirstTransaction = false) { + const { + id, + hash, + history = [], + txParams: { gas: paramsGasLimit, gasPrice: paramsGasPrice}, + xReceipt: { status } = {}, + type, + } = transaction + + let cachedGasLimit = '0x0' + let cachedGasPrice = '0x0' + + const historyActivities = history.reduce((acc, base, index) => { + // First history item should be transaction creation + if (index === 0 && !Array.isArray(base) && base.txParams) { + const { time: timestamp, txParams: { value, gas = '0x0', gasPrice = '0x0' } = {} } = base + // The cached gas limit and gas price are used to display the gas fee in the activity log. We + // need to cache these values because the status update history events don't provide us with + // the latest gas limit and gas price. + cachedGasLimit = gas + cachedGasPrice = gasPrice + + if (isFirstTransaction) { + return acc.concat({ + id, + hash, + eventKey: TRANSACTION_CREATED_EVENT, + timestamp, + value, + }) + } + // An entry in the history may be an array of more sub-entries. + } else if (Array.isArray(base)) { + const events = [] + + base.forEach(entry => { + const { op, path, value, timestamp: entryTimestamp } = entry + // Not all sub-entries in a history entry have a timestamp. If the sub-entry does not have a + // timestamp, the first sub-entry in a history entry should. + const timestamp = entryTimestamp || base[0] && base[0].timestamp + + if (path in eventPathsHash && op === REPLACE_OP) { + switch (path) { + case STATUS_PATH: { + const gasFee = cachedGasLimit === '0x0' && cachedGasPrice === '0x0' + ? getHexGasTotal({ gasLimit: paramsGasLimit, gasPrice: paramsGasPrice }) + : getHexGasTotal({ gasLimit: cachedGasLimit, gasPrice: cachedGasPrice }) + + if (value in statusHash) { + let eventKey = statusHash[value] + + // If the status is 'submitted', we need to determine whether the event is a + // transaction retry or a cancellation attempt. + if (value === SUBMITTED_STATUS) { + if (type === TRANSACTION_TYPE_RETRY) { + eventKey = TRANSACTION_RESUBMITTED_EVENT + } else if (type === TRANSACTION_TYPE_CANCEL) { + eventKey = TRANSACTION_CANCEL_ATTEMPTED_EVENT + } + } else if (value === CONFIRMED_STATUS) { + if (type === TRANSACTION_TYPE_CANCEL) { + eventKey = TRANSACTION_CANCEL_SUCCESS_EVENT + } + } + + events.push({ + id, + hash, + eventKey, + timestamp, + value: gasFee, + }) + } + + break + } + + // If the gas price or gas limit has been changed, we update the gasFee of the + // previously submitted event. These events happen when the gas limit and gas price is + // changed at the confirm screen. + case GAS_PRICE_PATH: + case GAS_LIMIT_PATH: { + const lastEvent = events[events.length - 1] || {} + const { lastEventKey } = lastEvent + + if (path === GAS_LIMIT_PATH) { + cachedGasLimit = value + } else if (path === GAS_PRICE_PATH) { + cachedGasPrice = value + } + + if (lastEventKey === TRANSACTION_SUBMITTED_EVENT || + lastEventKey === TRANSACTION_RESUBMITTED_EVENT) { + lastEvent.value = getHexGasTotal({ + gasLimit: cachedGasLimit, + gasPrice: cachedGasPrice, + }) + } + + break + } + + default: { + events.push({ + id, + hash, + eventKey: TRANSACTION_UPDATED_EVENT, + timestamp, + }) + } + } + } + }) + + return acc.concat(events) + } + + return acc + }, []) + + // If txReceipt.status is '0x0', that means that an on-chain error occured for the transaction, + // so we add an error entry to the Activity Log. + return status === '0x0' + ? historyActivities.concat({ id, hash, eventKey: TRANSACTION_ERRORED_EVENT }) + : historyActivities +} + +/** + * @description Removes "Transaction dropped" activities from a list of sorted activities if one of + * the transactions has been confirmed. Typically, if multiple transactions have the same nonce, + * once one transaction is confirmed, the rest are dropped. In this case, we don't want to show + * multiple "Transaction dropped" activities, and instead want to show a single "Transaction + * confirmed". + * @param {Array} activities - List of sorted activities generated from the getActivities function. + * @returns {Array} + */ +function filterSortedActivities (activities) { + const filteredActivities = [] + const hasConfirmedActivity = Boolean(activities.find(({ eventKey }) => ( + eventKey === TRANSACTION_CONFIRMED_EVENT || eventKey === TRANSACTION_CANCEL_SUCCESS_EVENT + ))) + let addedDroppedActivity = false + + activities.forEach(activity => { + if (activity.eventKey === TRANSACTION_DROPPED_EVENT) { + if (!hasConfirmedActivity && !addedDroppedActivity) { + filteredActivities.push(activity) + addedDroppedActivity = true + } + } else { + filteredActivities.push(activity) + } + }) + + return filteredActivities +} + +/** + * Combines the histories of an array of transactions into a single array. + * @param {Array} transactions - Array of txMeta transaction objects. + * @returns {Array} + */ +export function combineTransactionHistories (transactions = []) { + if (!transactions.length) { + return [] + } + + const activities = [] + + transactions.forEach((transaction, index) => { + // The first transaction should be the transaction with the earliest submittedTime. We show the + // 'created' and 'submitted' activities here. All subsequent transactions will use 'resubmitted' + // instead. + const transactionActivities = getActivities(transaction, index === 0) + activities.push(...transactionActivities) + }) + + const sortedActivities = activities.sort((a, b) => a.timestamp - b.timestamp) + return filterSortedActivities(sortedActivities) +} diff --git a/ui/app/components/transaction-breakdown/index.js b/ui/app/components/app/transaction-breakdown/index.js index 4a5b52663..4a5b52663 100644 --- a/ui/app/components/transaction-breakdown/index.js +++ b/ui/app/components/app/transaction-breakdown/index.js diff --git a/ui/app/components/transaction-breakdown/index.scss b/ui/app/components/app/transaction-breakdown/index.scss index 1bb108943..c8144eac2 100644 --- a/ui/app/components/transaction-breakdown/index.scss +++ b/ui/app/components/app/transaction-breakdown/index.scss @@ -1,9 +1,10 @@ -@import './transaction-breakdown-row/index'; +@import 'transaction-breakdown-row/index'; .transaction-breakdown { - &__card { - background: $white; - height: 100%; + &__title { + border-bottom: 1px solid #d8d8d8; + padding-bottom: 4px; + text-transform: capitalize; } &__row-title { diff --git a/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js b/ui/app/components/app/transaction-breakdown/tests/transaction-breakdown.component.test.js index d18cd420c..4512b84f0 100644 --- a/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js +++ b/ui/app/components/app/transaction-breakdown/tests/transaction-breakdown.component.test.js @@ -2,8 +2,6 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import TransactionBreakdown from '../transaction-breakdown.component' -import TransactionBreakdownRow from '../transaction-breakdown-row' -import Card from '../../card' describe('TransactionBreakdown Component', () => { it('should render properly', () => { @@ -31,7 +29,5 @@ describe('TransactionBreakdown Component', () => { assert.ok(wrapper.hasClass('transaction-breakdown')) assert.ok(wrapper.hasClass('test-class')) - assert.equal(wrapper.find(Card).length, 1) - assert.equal(wrapper.find(Card).find(TransactionBreakdownRow).length, 4) }) }) diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/index.js index 557bf75fb..557bf75fb 100644 --- a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/index.js diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/index.scss index 8c73be1a6..8c73be1a6 100644 --- a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/index.scss diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js index c19399dbb..82e40fce2 100644 --- a/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js @@ -2,7 +2,7 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import TransactionBreakdownRow from '../transaction-breakdown-row.component' -import Button from '../../../button' +import Button from '../../../../ui/button' describe('TransactionBreakdownRow Component', () => { it('should render text properly', () => { diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js index c11ff8efa..c11ff8efa 100644 --- a/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js diff --git a/ui/app/components/app/transaction-breakdown/transaction-breakdown.component.js b/ui/app/components/app/transaction-breakdown/transaction-breakdown.component.js new file mode 100644 index 000000000..5642e0fa5 --- /dev/null +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown.component.js @@ -0,0 +1,106 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import TransactionBreakdownRow from './transaction-breakdown-row' +import CurrencyDisplay from '../../ui/currency-display' +import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' +import HexToDecimal from '../../ui/hex-to-decimal' +import { GWEI, PRIMARY, SECONDARY } from '../../../helpers/constants/common' + +export default class TransactionBreakdown extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + transaction: PropTypes.object, + className: PropTypes.string, + nativeCurrency: PropTypes.string.isRequired, + showFiat: PropTypes.bool, + gas: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + gasPrice: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + gasUsed: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + totalInHex: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + } + + static defaultProps = { + transaction: {}, + showFiat: true, + } + + render () { + const { t } = this.context + const { gas, gasPrice, value, className, nativeCurrency, showFiat, totalInHex, gasUsed } = this.props + + return ( + <div className={classnames('transaction-breakdown', className)}> + <div className="transaction-breakdown__title"> + { t('transaction') } + </div> + <TransactionBreakdownRow title={t('amount')}> + <UserPreferencedCurrencyDisplay + className="transaction-breakdown__value" + type={PRIMARY} + value={value} + /> + </TransactionBreakdownRow> + <TransactionBreakdownRow + title={`${t('gasLimit')} (${t('units')})`} + className="transaction-breakdown__row-title" + > + {typeof gas !== 'undefined' + ? <HexToDecimal + className="transaction-breakdown__value" + value={gas} + /> + : '?' + } + </TransactionBreakdownRow> + { + typeof gasUsed === 'string' && ( + <TransactionBreakdownRow + title={`${t('gasUsed')} (${t('units')})`} + className="transaction-breakdown__row-title" + > + <HexToDecimal + className="transaction-breakdown__value" + value={gasUsed} + /> + </TransactionBreakdownRow> + ) + } + <TransactionBreakdownRow title={t('gasPrice')}> + {typeof gasPrice !== 'undefined' + ? <CurrencyDisplay + className="transaction-breakdown__value" + currency={nativeCurrency} + denomination={GWEI} + value={gasPrice} + hideLabel + /> + : '?' + } + </TransactionBreakdownRow> + <TransactionBreakdownRow title={t('total')}> + <div> + <UserPreferencedCurrencyDisplay + className="transaction-breakdown__value transaction-breakdown__value--eth-total" + type={PRIMARY} + value={totalInHex} + /> + { + showFiat && ( + <UserPreferencedCurrencyDisplay + className="transaction-breakdown__value" + type={SECONDARY} + value={totalInHex} + /> + ) + } + </div> + </TransactionBreakdownRow> + </div> + ) + } +} diff --git a/ui/app/components/app/transaction-breakdown/transaction-breakdown.container.js b/ui/app/components/app/transaction-breakdown/transaction-breakdown.container.js new file mode 100644 index 000000000..82f377358 --- /dev/null +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown.container.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux' +import TransactionBreakdown from './transaction-breakdown.component' +import {getIsMainnet, getNativeCurrency, preferencesSelector} from '../../../selectors/selectors' +import { getHexGasTotal } from '../../../helpers/utils/confirm-tx.util' +import { sumHexes } from '../../../helpers/utils/transactions.util' + +const mapStateToProps = (state, ownProps) => { + const { transaction } = ownProps + const { txParams: { gas, gasPrice, value } = {}, txReceipt: { gasUsed } = {} } = transaction + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + + const gasLimit = typeof gasUsed === 'string' ? gasUsed : gas + + const hexGasTotal = gasLimit && gasPrice && getHexGasTotal({ gasLimit, gasPrice }) || '0x0' + const totalInHex = sumHexes(hexGasTotal, value) + + return { + nativeCurrency: getNativeCurrency(state), + showFiat: (isMainnet || !!showFiatInTestnets), + totalInHex, + gas, + gasPrice, + value, + gasUsed, + } +} + +export default connect(mapStateToProps)(TransactionBreakdown) diff --git a/ui/app/components/transaction-list-item-details/index.js b/ui/app/components/app/transaction-list-item-details/index.js index 0e878d032..0e878d032 100644 --- a/ui/app/components/transaction-list-item-details/index.js +++ b/ui/app/components/app/transaction-list-item-details/index.js diff --git a/ui/app/components/transaction-list-item-details/index.scss b/ui/app/components/app/transaction-list-item-details/index.scss index 54cf834cc..7cb253e4e 100644 --- a/ui/app/components/transaction-list-item-details/index.scss +++ b/ui/app/components/app/transaction-list-item-details/index.scss @@ -1,11 +1,16 @@ .transaction-list-item-details { &__header { - margin-bottom: 8px; + margin: 8px 16px; display: flex; justify-content: space-between; align-items: center; } + &__body { + background: #fafbfc; + padding: 8px 16px; + } + &__header-buttons { display: flex; flex-direction: row; @@ -17,6 +22,11 @@ &:not(:last-child) { margin-right: 8px; } + + &__copy-icon { + width: 10px; + height: 10px; + } } &__sender-to-recipient-container { @@ -45,5 +55,9 @@ &__transaction-activity-log { flex: 2; min-width: 0; + + @media screen and (min-width: $break-large) { + padding-left: 12px; + } } } diff --git a/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js b/ui/app/components/app/transaction-list-item-details/tests/transaction-list-item-details.component.test.js index f2bbe8789..c4e118b01 100644 --- a/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js +++ b/ui/app/components/app/transaction-list-item-details/tests/transaction-list-item-details.component.test.js @@ -2,8 +2,8 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import TransactionListItemDetails from '../transaction-list-item-details.component' -import Button from '../../button' -import SenderToRecipient from '../../sender-to-recipient' +import Button from '../../../ui/button' +import SenderToRecipient from '../../../ui/sender-to-recipient' import TransactionBreakdown from '../../transaction-breakdown' import TransactionActivityLog from '../../transaction-activity-log' @@ -23,15 +23,21 @@ describe('TransactionListItemDetails Component', () => { }, } + const transactionGroup = { + transactions: [transaction], + primaryTransaction: transaction, + initialTransaction: transaction, + } + const wrapper = shallow( <TransactionListItemDetails - transaction={transaction} + transactionGroup={transactionGroup} />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } ) assert.ok(wrapper.hasClass('transaction-list-item-details')) - assert.equal(wrapper.find(Button).length, 1) + assert.equal(wrapper.find(Button).length, 2) assert.equal(wrapper.find(SenderToRecipient).length, 1) assert.equal(wrapper.find(TransactionBreakdown).length, 1) assert.equal(wrapper.find(TransactionActivityLog).length, 1) @@ -52,15 +58,24 @@ describe('TransactionListItemDetails Component', () => { }, } + const transactionGroup = { + transactions: [transaction], + primaryTransaction: transaction, + initialTransaction: transaction, + nonce: '0xa4', + hasRetried: false, + hasCancelled: false, + } + const wrapper = shallow( <TransactionListItemDetails - transaction={transaction} + transactionGroup={transactionGroup} showRetry={true} />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } ) assert.ok(wrapper.hasClass('transaction-list-item-details')) - assert.equal(wrapper.find(Button).length, 2) + assert.equal(wrapper.find(Button).length, 3) }) }) diff --git a/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js new file mode 100644 index 000000000..4a3b04998 --- /dev/null +++ b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -0,0 +1,215 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import copyToClipboard from 'copy-to-clipboard' +import SenderToRecipient from '../../ui/sender-to-recipient' +import { FLAT_VARIANT } from '../../ui/sender-to-recipient/sender-to-recipient.constants' +import TransactionActivityLog from '../transaction-activity-log' +import TransactionBreakdown from '../transaction-breakdown' +import Button from '../../ui/button' +import Tooltip from '../../ui/tooltip' +import prefixForNetwork from '../../../../lib/etherscan-prefix-for-network' + +export default class TransactionListItemDetails extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + onCancel: PropTypes.func, + onRetry: PropTypes.func, + showCancel: PropTypes.bool, + showRetry: PropTypes.bool, + cancelDisabled: PropTypes.bool, + transactionGroup: PropTypes.object, + } + + state = { + justCopied: false, + cancelDisabled: false, + } + + handleEtherscanClick = () => { + const { transactionGroup: { primaryTransaction } } = this.props + const { hash, metamaskNetworkId } = primaryTransaction + + const prefix = prefixForNetwork(metamaskNetworkId) + const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` + + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Activity Log', + name: 'Clicked "View on Etherscan"', + }, + }) + + global.platform.openWindow({ url: etherscanUrl }) + } + + handleCancel = event => { + const { transactionGroup: { initialTransaction: { id } = {} } = {}, onCancel } = this.props + + event.stopPropagation() + onCancel(id) + } + + handleRetry = event => { + const { transactionGroup: { initialTransaction: { id } = {} } = {}, onRetry } = this.props + + event.stopPropagation() + onRetry(id) + } + + handleCopyTxId = () => { + const { transactionGroup} = this.props + const { primaryTransaction: transaction } = transactionGroup + const { hash } = transaction + + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Activity Log', + name: 'Copied Transaction ID', + }, + }) + + this.setState({ justCopied: true }, () => { + copyToClipboard(hash) + setTimeout(() => this.setState({ justCopied: false }), 1000) + }) + } + + renderCancel () { + const { t } = this.context + const { + showCancel, + cancelDisabled, + } = this.props + + if (!showCancel) { + return null + } + + return cancelDisabled + ? ( + <Tooltip title={t('notEnoughGas')}> + <div> + <Button + type="raised" + onClick={this.handleCancel} + className="transaction-list-item-details__header-button" + disabled + > + { t('cancel') } + </Button> + </div> + </Tooltip> + ) + : ( + <Button + type="raised" + onClick={this.handleCancel} + className="transaction-list-item-details__header-button" + > + { t('cancel') } + </Button> + ) + } + + render () { + const { t } = this.context + const { justCopied } = this.state + const { + transactionGroup, + showRetry, + onCancel, + onRetry, + } = this.props + const { primaryTransaction: transaction } = transactionGroup + const { txParams: { to, from } = {} } = transaction + + return ( + <div className="transaction-list-item-details"> + <div className="transaction-list-item-details__header"> + <div>{ t('details') }</div> + <div className="transaction-list-item-details__header-buttons"> + { + showRetry && ( + <Button + type="raised" + onClick={this.handleRetry} + className="transaction-list-item-details__header-button" + > + { t('speedUp') } + </Button> + ) + } + { this.renderCancel() } + <Tooltip title={justCopied ? t('copiedTransactionId') : t('copyTransactionId')}> + <Button + type="raised" + onClick={this.handleCopyTxId} + className="transaction-list-item-details__header-button" + > + <img + className="transaction-list-item-details__header-button__copy-icon" + src="/images/copy-to-clipboard.svg" + /> + </Button> + </Tooltip> + <Tooltip title={t('viewOnEtherscan')}> + <Button + type="raised" + onClick={this.handleEtherscanClick} + className="transaction-list-item-details__header-button" + > + <img src="/images/arrow-popout.svg" /> + </Button> + </Tooltip> + </div> + </div> + <div className="transaction-list-item-details__body"> + <div className="transaction-list-item-details__sender-to-recipient-container"> + <SenderToRecipient + variant={FLAT_VARIANT} + addressOnly + recipientAddress={to} + senderAddress={from} + onRecipientClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Activity Log', + name: 'Copied "To" Address', + }, + }) + }} + onSenderClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Activity Log', + name: 'Copied "From" Address', + }, + }) + }} + /> + </div> + <div className="transaction-list-item-details__cards-container"> + <TransactionBreakdown + transaction={transaction} + className="transaction-list-item-details__transaction-breakdown" + /> + <TransactionActivityLog + transactionGroup={transactionGroup} + className="transaction-list-item-details__transaction-activity-log" + onCancel={onCancel} + onRetry={onRetry} + /> + </div> + </div> + </div> + ) + } +} diff --git a/ui/app/components/transaction-list-item/index.js b/ui/app/components/app/transaction-list-item/index.js index 697cc55e9..697cc55e9 100644 --- a/ui/app/components/transaction-list-item/index.js +++ b/ui/app/components/app/transaction-list-item/index.js diff --git a/ui/app/components/transaction-list-item/index.scss b/ui/app/components/app/transaction-list-item/index.scss index ac0e7beeb..9e73a546c 100644 --- a/ui/app/components/transaction-list-item/index.scss +++ b/ui/app/components/app/transaction-list-item/index.scss @@ -80,6 +80,8 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; + max-width: 100%; &--primary { text-align: end; @@ -115,12 +117,6 @@ } } - &__details-container { - padding: 8px 16px 16px; - background: #f3f4f7; - width: 100%; - } - &__expander { max-height: 0px; width: 100%; diff --git a/ui/app/components/transaction-list-item/transaction-list-item.component.js b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js index 696634fe0..c7d9dd7c7 100644 --- a/ui/app/components/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js @@ -1,16 +1,16 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import Identicon from '../identicon' +import Identicon from '../../ui/identicon' import TransactionStatus from '../transaction-status' import TransactionAction from '../transaction-action' import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' -import TokenCurrencyDisplay from '../token-currency-display' +import TokenCurrencyDisplay from '../../ui/token-currency-display' import TransactionListItemDetails from '../transaction-list-item-details' -import { CONFIRM_TRANSACTION_ROUTE } from '../../routes' -import { UNAPPROVED_STATUS, TOKEN_METHOD_TRANSFER } from '../../constants/transactions' -import { PRIMARY, SECONDARY } from '../../constants/common' -import { getStatusKey } from '../../helpers/transactions.util' +import { CONFIRM_TRANSACTION_ROUTE } from '../../../helpers/constants/routes' +import { UNAPPROVED_STATUS, TOKEN_METHOD_TRANSFER } from '../../../helpers/constants/transactions' +import { PRIMARY, SECONDARY } from '../../../helpers/constants/common' +import { getStatusKey } from '../../../helpers/utils/transactions.util' export default class TransactionListItem extends PureComponent { static propTypes = { @@ -18,15 +18,29 @@ export default class TransactionListItem extends PureComponent { history: PropTypes.object, methodData: PropTypes.object, nonceAndDate: PropTypes.string, + primaryTransaction: PropTypes.object, retryTransaction: PropTypes.func, setSelectedToken: PropTypes.func, showCancelModal: PropTypes.func, showCancel: PropTypes.bool, + hasEnoughCancelGas: PropTypes.bool, showRetry: PropTypes.bool, + showFiat: PropTypes.bool, token: PropTypes.object, tokenData: PropTypes.object, transaction: PropTypes.object, + transactionGroup: PropTypes.object, value: PropTypes.string, + fetchBasicGasAndTimeEstimates: PropTypes.func, + fetchGasEstimates: PropTypes.func, + } + + static defaultProps = { + showFiat: true, + } + + static contextTypes = { + metricsEvent: PropTypes.func, } state = { @@ -46,36 +60,61 @@ export default class TransactionListItem extends PureComponent { return } + if (!showTransactionDetails) { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Expand Transaction', + }, + }) + } + this.setState({ showTransactionDetails: !showTransactionDetails }) } - handleCancel = () => { - const { transaction: { id, txParams: { gasPrice } } = {}, showCancelModal } = this.props - showCancelModal(id, gasPrice) + handleCancel = id => { + const { + primaryTransaction: { txParams: { gasPrice } } = {}, + transaction: { id: initialTransactionId }, + showCancelModal, + } = this.props + + const cancelId = id || initialTransactionId + showCancelModal(cancelId, gasPrice) } - handleRetry = () => { + /** + * @name handleRetry + * @description Resubmits a transaction. Retrying a transaction within a list of transactions with + * the same nonce requires keeping the original value while increasing the gas price of the latest + * transaction. + * @param {number} id - Transaction id + */ + handleRetry = id => { const { - transaction: { txParams: { to } = {} }, + primaryTransaction: { txParams: { gasPrice } } = {}, + transaction: { txParams: { to } = {}, id: initialTransactionId }, methodData: { name } = {}, setSelectedToken, + retryTransaction, + fetchBasicGasAndTimeEstimates, + fetchGasEstimates, } = this.props if (name === TOKEN_METHOD_TRANSFER) { setSelectedToken(to) } - return this.resubmit() - } + const retryId = id || initialTransactionId - resubmit () { - const { transaction: { id }, retryTransaction, history } = this.props - return retryTransaction(id) - .then(id => history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`)) + return fetchBasicGasAndTimeEstimates() + .then(basicEstimates => fetchGasEstimates(basicEstimates.blockTime)) + .then(retryTransaction(retryId, gasPrice)) } renderPrimaryCurrency () { - const { token, transaction: { txParams: { data } = {} } = {}, value } = this.props + const { token, primaryTransaction: { txParams: { data } = {} } = {}, value } = this.props return token ? ( @@ -96,9 +135,9 @@ export default class TransactionListItem extends PureComponent { } renderSecondaryCurrency () { - const { token, value } = this.props + const { token, value, showFiat } = this.props - return token + return token || !showFiat ? null : ( <UserPreferencedCurrencyDisplay @@ -113,12 +152,15 @@ export default class TransactionListItem extends PureComponent { render () { const { assetImages, + transaction, methodData, nonceAndDate, + primaryTransaction, showCancel, + hasEnoughCancelGas, showRetry, tokenData, - transaction, + transactionGroup, } = this.props const { txParams = {} } = transaction const { showTransactionDetails } = this.state @@ -151,11 +193,11 @@ export default class TransactionListItem extends PureComponent { </div> <TransactionStatus className="transaction-list-item__status" - statusKey={getStatusKey(transaction)} + statusKey={getStatusKey(primaryTransaction)} title={( - (transaction.err && transaction.err.rpc) - ? transaction.err.rpc.message - : transaction.err && transaction.err.message + (primaryTransaction.err && primaryTransaction.err.rpc) + ? primaryTransaction.err.rpc.message + : primaryTransaction.err && primaryTransaction.err.message )} /> { this.renderPrimaryCurrency() } @@ -168,11 +210,12 @@ export default class TransactionListItem extends PureComponent { showTransactionDetails && ( <div className="transaction-list-item__details-container"> <TransactionListItemDetails - transaction={transaction} + transactionGroup={transactionGroup} onRetry={this.handleRetry} showRetry={showRetry && methodData.done} onCancel={this.handleCancel} showCancel={showCancel} + cancelDisabled={!hasEnoughCancelGas} /> </div> ) diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.container.js b/ui/app/components/app/transaction-list-item/transaction-list-item.container.js new file mode 100644 index 000000000..de8a3bbba --- /dev/null +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.container.js @@ -0,0 +1,98 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import withMethodData from '../../../helpers/higher-order-components/with-method-data' +import TransactionListItem from './transaction-list-item.component' +import { setSelectedToken, showModal, showSidebar, addKnownMethodData } from '../../../store/actions' +import { hexToDecimal } from '../../../helpers/utils/conversions.util' +import { getTokenData } from '../../../helpers/utils/transactions.util' +import { getHexGasTotal, increaseLastGasPrice } from '../../../helpers/utils/confirm-tx.util' +import { formatDate } from '../../../helpers/utils/util' +import { + fetchBasicGasAndTimeEstimates, + fetchGasEstimates, + setCustomGasPriceForRetry, + setCustomGasLimit, +} from '../../../ducks/gas/gas.duck' +import { getIsMainnet, preferencesSelector, getSelectedAddress, conversionRateSelector } from '../../../selectors/selectors' +import { isBalanceSufficient } from '../send/send.utils' + +const mapStateToProps = (state, ownProps) => { + const { metamask: { knownMethodData, accounts } } = state + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const { transactionGroup: { primaryTransaction } = {} } = ownProps + const { txParams: { gas: gasLimit, gasPrice } = {} } = primaryTransaction + const selectedAccountBalance = accounts[getSelectedAddress(state)].balance + + const hasEnoughCancelGas = primaryTransaction.txParams && isBalanceSufficient({ + amount: '0x0', + gasTotal: getHexGasTotal({ + gasPrice: increaseLastGasPrice(gasPrice), + gasLimit, + }), + balance: selectedAccountBalance, + conversionRate: conversionRateSelector(state), + }) + + return { + knownMethodData, + showFiat: (isMainnet || !!showFiatInTestnets), + selectedAccountBalance, + hasEnoughCancelGas, + } +} + +const mapDispatchToProps = dispatch => { + return { + fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()), + fetchGasEstimates: (blockTime) => dispatch(fetchGasEstimates(blockTime)), + setSelectedToken: tokenAddress => dispatch(setSelectedToken(tokenAddress)), + addKnownMethodData: (fourBytePrefix, methodData) => dispatch(addKnownMethodData(fourBytePrefix, methodData)), + retryTransaction: (transaction, gasPrice) => { + dispatch(setCustomGasPriceForRetry(gasPrice || transaction.txParams.gasPrice)) + dispatch(setCustomGasLimit(transaction.txParams.gas)) + dispatch(showSidebar({ + transitionName: 'sidebar-left', + type: 'customize-gas', + props: { transaction }, + })) + }, + showCancelModal: (transactionId, originalGasPrice) => { + return dispatch(showModal({ name: 'CANCEL_TRANSACTION', transactionId, originalGasPrice })) + }, + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { transactionGroup: { primaryTransaction, initialTransaction } = {} } = ownProps + const { retryTransaction, ...restDispatchProps } = dispatchProps + const { txParams: { nonce, data } = {}, time } = initialTransaction + const { txParams: { value } = {} } = primaryTransaction + + const tokenData = data && getTokenData(data) + const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) + + return { + ...stateProps, + ...restDispatchProps, + ...ownProps, + value, + nonceAndDate, + tokenData, + transaction: initialTransaction, + primaryTransaction, + retryTransaction: (transactionId, gasPrice) => { + const { transactionGroup: { transactions = [] } } = ownProps + const transaction = transactions.find(tx => tx.id === transactionId) || {} + const increasedGasPrice = increaseLastGasPrice(gasPrice) + retryTransaction(transaction, increasedGasPrice) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps), + withMethodData, +)(TransactionListItem) diff --git a/ui/app/components/transaction-list/index.js b/ui/app/components/app/transaction-list/index.js index 688994367..688994367 100644 --- a/ui/app/components/transaction-list/index.js +++ b/ui/app/components/app/transaction-list/index.js diff --git a/ui/app/components/transaction-list/index.scss b/ui/app/components/app/transaction-list/index.scss index ba7ffd87b..a486f4112 100644 --- a/ui/app/components/transaction-list/index.scss +++ b/ui/app/components/app/transaction-list/index.scss @@ -33,7 +33,7 @@ &__empty { flex: 1; display: grid; - grid-template-rows: 35% 1fr; + grid-template-rows: auto; padding-top: 8px; } diff --git a/ui/app/components/transaction-list/transaction-list.component.js b/ui/app/components/app/transaction-list/transaction-list.component.js index eef60186d..fc5488884 100644 --- a/ui/app/components/transaction-list/transaction-list.component.js +++ b/ui/app/components/app/transaction-list/transaction-list.component.js @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import TransactionListItem from '../transaction-list-item' import ShapeShiftTransactionListItem from '../shift-list-item' -import { TRANSACTION_TYPE_SHAPESHIFT } from '../../constants/transactions' +import { TRANSACTION_TYPE_SHAPESHIFT } from '../../../helpers/constants/transactions' export default class TransactionList extends PureComponent { static contextTypes = { @@ -12,13 +12,11 @@ export default class TransactionList extends PureComponent { static defaultProps = { pendingTransactions: [], completedTransactions: [], - transactionToRetry: {}, } static propTypes = { pendingTransactions: PropTypes.array, completedTransactions: PropTypes.array, - transactionToRetry: PropTypes.object, selectedToken: PropTypes.object, updateNetworkNonce: PropTypes.func, assetImages: PropTypes.object, @@ -37,26 +35,34 @@ export default class TransactionList extends PureComponent { } } - shouldShowRetry = transaction => { - const { transactionToRetry } = this.props - const { id, submittedTime } = transaction - return id === transactionToRetry.id && Date.now() - submittedTime > 30000 + shouldShowRetry = (transactionGroup, isEarliestNonce) => { + const { transactions = [], hasRetried } = transactionGroup + const [earliestTransaction = {}] = transactions + const { submittedTime } = earliestTransaction + return Date.now() - submittedTime > 30000 && isEarliestNonce && !hasRetried + } + + shouldShowCancel (transactionGroup) { + const { hasCancelled } = transactionGroup + return !hasCancelled } renderTransactions () { const { t } = this.context const { pendingTransactions = [], completedTransactions = [] } = this.props + const pendingLength = pendingTransactions.length + return ( <div className="transaction-list__transactions"> { - pendingTransactions.length > 0 && ( + pendingLength > 0 && ( <div className="transaction-list__pending-transactions"> <div className="transaction-list__header"> { `${t('queue')} (${pendingTransactions.length})` } </div> { - pendingTransactions.map((transaction, index) => ( - this.renderTransaction(transaction, index, true) + pendingTransactions.map((transactionGroup, index) => ( + this.renderTransaction(transactionGroup, index, true) )) } </div> @@ -68,8 +74,8 @@ export default class TransactionList extends PureComponent { </div> { completedTransactions.length > 0 - ? completedTransactions.map((transaction, index) => ( - this.renderTransaction(transaction, index) + ? completedTransactions.map((transactionGroup, index) => ( + this.renderTransaction(transactionGroup, index) )) : this.renderEmpty() } @@ -78,21 +84,22 @@ export default class TransactionList extends PureComponent { ) } - renderTransaction (transaction, index, showCancel) { + renderTransaction (transactionGroup, index, isPendingTx = false) { const { selectedToken, assetImages } = this.props + const { transactions = [] } = transactionGroup - return transaction.key === TRANSACTION_TYPE_SHAPESHIFT + return transactions[0].key === TRANSACTION_TYPE_SHAPESHIFT ? ( <ShapeShiftTransactionListItem - { ...transaction } + { ...transactions[0] } key={`shapeshift${index}`} /> ) : ( <TransactionListItem - transaction={transaction} - key={transaction.id} - showRetry={this.shouldShowRetry(transaction)} - showCancel={showCancel} + transactionGroup={transactionGroup} + key={`${transactionGroup.nonce}:${index}`} + showRetry={isPendingTx && this.shouldShowRetry(transactionGroup, index === 0)} + showCancel={isPendingTx && this.shouldShowCancel(transactionGroup)} token={selectedToken} assetImages={assetImages} /> diff --git a/ui/app/components/transaction-list/transaction-list.container.js b/ui/app/components/app/transaction-list/transaction-list.container.js index 2e946c67d..67a24588b 100644 --- a/ui/app/components/transaction-list/transaction-list.container.js +++ b/ui/app/components/app/transaction-list/transaction-list.container.js @@ -3,24 +3,17 @@ import { withRouter } from 'react-router-dom' import { compose } from 'recompose' import TransactionList from './transaction-list.component' import { - pendingTransactionsSelector, - submittedPendingTransactionsSelector, - completedTransactionsSelector, -} from '../../selectors/transactions' -import { getSelectedAddress, getAssetImages } from '../../selectors' -import { selectedTokenSelector } from '../../selectors/tokens' -import { getLatestSubmittedTxWithNonce } from '../../helpers/transactions.util' -import { updateNetworkNonce } from '../../actions' + nonceSortedCompletedTransactionsSelector, + nonceSortedPendingTransactionsSelector, +} from '../../../selectors/transactions' +import { getSelectedAddress, getAssetImages } from '../../../selectors/selectors' +import { selectedTokenSelector } from '../../../selectors/tokens' +import { updateNetworkNonce } from '../../../store/actions' const mapStateToProps = state => { - const pendingTransactions = pendingTransactionsSelector(state) - const submittedPendingTransactions = submittedPendingTransactionsSelector(state) - const networkNonce = state.appState.networkNonce - return { - completedTransactions: completedTransactionsSelector(state), - pendingTransactions, - transactionToRetry: getLatestSubmittedTxWithNonce(submittedPendingTransactions, networkNonce), + completedTransactions: nonceSortedCompletedTransactionsSelector(state), + pendingTransactions: nonceSortedPendingTransactionsSelector(state), selectedToken: selectedTokenSelector(state), selectedAddress: getSelectedAddress(state), assetImages: getAssetImages(state), diff --git a/ui/app/components/transaction-status/index.js b/ui/app/components/app/transaction-status/index.js index dece41e9c..dece41e9c 100644 --- a/ui/app/components/transaction-status/index.js +++ b/ui/app/components/app/transaction-status/index.js diff --git a/ui/app/components/transaction-status/index.scss b/ui/app/components/app/transaction-status/index.scss index 26a1f5d38..e7daafeef 100644 --- a/ui/app/components/transaction-status/index.scss +++ b/ui/app/components/app/transaction-status/index.scss @@ -1,6 +1,6 @@ .transaction-status { height: 26px; - width: 81px; + width: 84px; border-radius: 4px; background-color: #f0f0f0; color: #5e6064; @@ -12,22 +12,34 @@ @media screen and (max-width: $break-small) { height: 16px; - width: 70px; + width: 72px; font-size: .5rem; } &--confirmed { background-color: #eafad7; color: #609a1c; + + .transaction-status__transaction-count { + border: 1px solid #609a1c; + } } &--approved, &--submitted { background-color: #FFF2DB; color: #CA810A; + + .transaction-status__transaction-count { + border: 1px solid #CA810A; + } } &--failed { background: lighten($monzo, 56%); color: $monzo; + + .transaction-status__transaction-count { + border: 1px solid $monzo; + } } } diff --git a/ui/app/components/app/transaction-status/tests/transaction-status.component.test.js b/ui/app/components/app/transaction-status/tests/transaction-status.component.test.js new file mode 100644 index 000000000..ec1d580bd --- /dev/null +++ b/ui/app/components/app/transaction-status/tests/transaction-status.component.test.js @@ -0,0 +1,33 @@ +import React from 'react' +import assert from 'assert' +import { mount } from 'enzyme' +import TransactionStatus from '../transaction-status.component' +import Tooltip from '../../../ui/tooltip-v2' + +describe('TransactionStatus Component', () => { + it('should render APPROVED properly', () => { + const wrapper = mount( + <TransactionStatus + statusKey="approved" + title="test-title" + />, + { context: { t: str => str.toUpperCase() } } + ) + + assert.ok(wrapper) + assert.equal(wrapper.text(), 'APPROVED') + assert.equal(wrapper.find(Tooltip).props().title, 'test-title') + }) + + it('should render SUBMITTED properly', () => { + const wrapper = mount( + <TransactionStatus + statusKey="submitted" + />, + { context: { t: str => str.toUpperCase() } } + ) + + assert.ok(wrapper) + assert.equal(wrapper.text(), 'PENDING') + }) +}) diff --git a/ui/app/components/transaction-status/transaction-status.component.js b/ui/app/components/app/transaction-status/transaction-status.component.js index c22baf18a..d3a239539 100644 --- a/ui/app/components/transaction-status/transaction-status.component.js +++ b/ui/app/components/app/transaction-status/transaction-status.component.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import Tooltip from '../tooltip-v2' +import Tooltip from '../../ui/tooltip-v2' import { UNAPPROVED_STATUS, REJECTED_STATUS, @@ -11,7 +11,8 @@ import { CONFIRMED_STATUS, FAILED_STATUS, DROPPED_STATUS, -} from '../../constants/transactions' + CANCELLED_STATUS, +} from '../../../helpers/constants/transactions' const statusToClassNameHash = { [UNAPPROVED_STATUS]: 'transaction-status--unapproved', @@ -22,10 +23,10 @@ const statusToClassNameHash = { [CONFIRMED_STATUS]: 'transaction-status--confirmed', [FAILED_STATUS]: 'transaction-status--failed', [DROPPED_STATUS]: 'transaction-status--dropped', + [CANCELLED_STATUS]: 'transaction-status--failed', } const statusToTextHash = { - [APPROVED_STATUS]: 'pending', [SUBMITTED_STATUS]: 'pending', } @@ -50,7 +51,10 @@ export default class TransactionStatus extends PureComponent { return ( <div className={classnames('transaction-status', className, statusToClassNameHash[statusKey])}> - <Tooltip position="top" title={title}> + <Tooltip + position="top" + title={title} + > { statusText } </Tooltip> </div> diff --git a/ui/app/components/transaction-view-balance/index.js b/ui/app/components/app/transaction-view-balance/index.js index 8824737f7..8824737f7 100644 --- a/ui/app/components/transaction-view-balance/index.js +++ b/ui/app/components/app/transaction-view-balance/index.js diff --git a/ui/app/components/transaction-view-balance/index.scss b/ui/app/components/app/transaction-view-balance/index.scss index 659f896ff..bdcd536b0 100644 --- a/ui/app/components/transaction-view-balance/index.scss +++ b/ui/app/components/app/transaction-view-balance/index.scss @@ -6,27 +6,29 @@ height: 54px; min-width: 0; + @media screen and (max-width: $break-small) { + flex-direction: column; + height: initial; + width: 100%; + } + &__balance { margin: 0 12px; display: flex; flex-direction: column; min-width: 0; + position: relative; @media screen and (max-width: $break-small) { align-items: center; margin: 16px 0; + padding: 0 16px; + max-width: 100%; } } - &__token-balance { - margin-left: 12px; - font-size: 1.5rem; - - @media screen and (max-width: $break-small) { - margin: 12px 0; - margin-left: 0; - font-size: 1.75rem; - } + &__primary-container { + display: flex; } &__primary-balance { @@ -34,9 +36,24 @@ @media screen and (max-width: $break-small) { font-size: 1.75rem; + width: 100%; + justify-content: center; } } + &__cached-star { + margin-left: 4px; + } + + &__cached-balance, &__cached-star { + color: $web-orange; + } + + &__cached-secondary-balance { + color: rgba(220, 153, 18, 0.6901960784313725); + font-size: 1.15rem; + } + &__secondary-balance { font-size: 1.15rem; color: #a0a0a0; @@ -51,6 +68,7 @@ @media screen and (max-width: $break-small) { flex-direction: column; + width: 100%; } } @@ -71,9 +89,4 @@ margin-right: 12px; } } - - @media screen and (max-width: $break-small) { - flex-direction: column; - height: initial - } } diff --git a/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js b/ui/app/components/app/transaction-view-balance/tests/token-view-balance.component.test.js index 513a8aac9..0e2882e9c 100644 --- a/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js +++ b/ui/app/components/app/transaction-view-balance/tests/token-view-balance.component.test.js @@ -2,9 +2,9 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import sinon from 'sinon' -import TokenBalance from '../../token-balance' +import TokenBalance from '../../../ui/token-balance' import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' -import { SEND_ROUTE } from '../../../routes' +import { SEND_ROUTE } from '../../../../helpers/constants/routes' import TransactionViewBalance from '../transaction-view-balance.component' const propsMethodSpies = { @@ -16,6 +16,7 @@ const historySpies = { } const t = (str1, str2) => str2 ? str1 + str2 : str1 +const metricsEvent = () => ({}) describe('TransactionViewBalance Component', () => { afterEach(() => { @@ -31,7 +32,7 @@ describe('TransactionViewBalance Component', () => { ethBalance={123} fiatBalance={456} currentCurrency="usd" - />, { context: { t } }) + />, { context: { t, metricsEvent } }) assert.equal(wrapper.find('.transaction-view-balance').length, 1) assert.equal(wrapper.find('.transaction-view-balance__button').length, 2) diff --git a/ui/app/components/app/transaction-view-balance/transaction-view-balance.component.js b/ui/app/components/app/transaction-view-balance/transaction-view-balance.component.js new file mode 100644 index 000000000..8559e2233 --- /dev/null +++ b/ui/app/components/app/transaction-view-balance/transaction-view-balance.component.js @@ -0,0 +1,145 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Button from '../../ui/button' +import Identicon from '../../ui/identicon' +import TokenBalance from '../../ui/token-balance' +import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' +import { SEND_ROUTE } from '../../../helpers/constants/routes' +import { PRIMARY, SECONDARY } from '../../../helpers/constants/common' +import Tooltip from '../../ui/tooltip-v2' + +export default class TransactionViewBalance extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + showDepositModal: PropTypes.func, + selectedToken: PropTypes.object, + history: PropTypes.object, + network: PropTypes.string, + balance: PropTypes.string, + assetImage: PropTypes.string, + balanceIsCached: PropTypes.bool, + showFiat: PropTypes.bool, + } + + static defaultProps = { + showFiat: true, + } + + renderBalance () { + const { selectedToken, balance, balanceIsCached, showFiat } = this.props + + return selectedToken + ? ( + <div className="transaction-view-balance__balance"> + <TokenBalance + token={selectedToken} + withSymbol + className="transaction-view-balance__primary-balance" + /> + </div> + ) : ( + <Tooltip position="top" title={this.context.t('balanceOutdated')} disabled={!balanceIsCached}> + <div className="transaction-view-balance__balance"> + <div className="transaction-view-balance__primary-container"> + <UserPreferencedCurrencyDisplay + className={classnames('transaction-view-balance__primary-balance', { + 'transaction-view-balance__cached-balance': balanceIsCached, + })} + value={balance} + type={PRIMARY} + ethNumberOfDecimals={4} + hideTitle={true} + /> + { + balanceIsCached ? <span className="transaction-view-balance__cached-star">*</span> : null + } + </div> + { + showFiat && ( + <UserPreferencedCurrencyDisplay + className={classnames({ + 'transaction-view-balance__cached-secondary-balance': balanceIsCached, + 'transaction-view-balance__secondary-balance': !balanceIsCached, + })} + value={balance} + type={SECONDARY} + ethNumberOfDecimals={4} + hideTitle={true} + /> + ) + } + </div> + </Tooltip> + ) + } + + renderButtons () { + const { t, metricsEvent } = this.context + const { selectedToken, showDepositModal, history } = this.props + + return ( + <div className="transaction-view-balance__buttons"> + { + !selectedToken && ( + <Button + type="primary" + className="transaction-view-balance__button" + onClick={() => { + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Clicked Deposit', + }, + }) + showDepositModal() + }} + > + { t('deposit') } + </Button> + ) + } + <Button + type="primary" + className="transaction-view-balance__button" + onClick={() => { + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Clicked Send', + }, + }) + history.push(SEND_ROUTE) + }} + > + { t('send') } + </Button> + </div> + ) + } + + render () { + const { network, selectedToken, assetImage } = this.props + + return ( + <div className="transaction-view-balance"> + <div className="transaction-view-balance__balance-container"> + <Identicon + diameter={50} + address={selectedToken && selectedToken.address} + network={network} + image={assetImage} + /> + { this.renderBalance() } + </div> + { this.renderButtons() } + </div> + ) + } +} diff --git a/ui/app/components/transaction-view-balance/transaction-view-balance.container.js b/ui/app/components/app/transaction-view-balance/transaction-view-balance.container.js index cb8078ec1..41a4525dc 100644 --- a/ui/app/components/transaction-view-balance/transaction-view-balance.container.js +++ b/ui/app/components/app/transaction-view-balance/transaction-view-balance.container.js @@ -2,12 +2,24 @@ import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' import { compose } from 'recompose' import TransactionViewBalance from './transaction-view-balance.component' -import { getSelectedToken, getSelectedAddress, getNativeCurrency, getSelectedTokenAssetImage } from '../../selectors' -import { showModal } from '../../actions' +import { + getSelectedToken, + getSelectedAddress, + getNativeCurrency, + getSelectedTokenAssetImage, + getMetaMaskAccounts, + isBalanceCached, + preferencesSelector, + getIsMainnet, +} from '../../../selectors/selectors' +import { showModal } from '../../../store/actions' const mapStateToProps = state => { + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) const selectedAddress = getSelectedAddress(state) - const { metamask: { network, accounts } } = state + const { metamask: { network } } = state + const accounts = getMetaMaskAccounts(state) const account = accounts[selectedAddress] const { balance } = account @@ -17,6 +29,8 @@ const mapStateToProps = state => { balance, nativeCurrency: getNativeCurrency(state), assetImage: getSelectedTokenAssetImage(state), + balanceIsCached: isBalanceCached(state), + showFiat: (isMainnet || !!showFiatInTestnets), } } diff --git a/ui/app/components/transaction-view/index.js b/ui/app/components/app/transaction-view/index.js index 9eb0c3c83..9eb0c3c83 100644 --- a/ui/app/components/transaction-view/index.js +++ b/ui/app/components/app/transaction-view/index.js diff --git a/ui/app/components/transaction-view/index.scss b/ui/app/components/app/transaction-view/index.scss index 13187f0e5..13187f0e5 100644 --- a/ui/app/components/transaction-view/index.scss +++ b/ui/app/components/app/transaction-view/index.scss diff --git a/ui/app/components/transaction-view/transaction-view.component.js b/ui/app/components/app/transaction-view/transaction-view.component.js index 7014ca173..7014ca173 100644 --- a/ui/app/components/transaction-view/transaction-view.component.js +++ b/ui/app/components/app/transaction-view/transaction-view.component.js diff --git a/ui/app/components/app/ui-migration-annoucement/index.js b/ui/app/components/app/ui-migration-annoucement/index.js new file mode 100644 index 000000000..c6c8cc619 --- /dev/null +++ b/ui/app/components/app/ui-migration-annoucement/index.js @@ -0,0 +1 @@ +export {default} from './ui-migration-announcement.container' diff --git a/ui/app/components/app/ui-migration-annoucement/index.scss b/ui/app/components/app/ui-migration-annoucement/index.scss new file mode 100644 index 000000000..6138a3079 --- /dev/null +++ b/ui/app/components/app/ui-migration-annoucement/index.scss @@ -0,0 +1,22 @@ +.ui-migration-announcement { + position: absolute; + z-index: 9999; + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + background: $white; + + p { + box-sizing: border-box; + padding: 1em; + font-size: 12pt; + } + + p:last-of-type { + cursor: pointer; + text-decoration: underline; + font-weight: bold; + } +} diff --git a/ui/app/components/app/ui-migration-annoucement/ui-migration-annoucement.component.js b/ui/app/components/app/ui-migration-annoucement/ui-migration-annoucement.component.js new file mode 100644 index 000000000..7a4124972 --- /dev/null +++ b/ui/app/components/app/ui-migration-annoucement/ui-migration-annoucement.component.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types' +import React, {PureComponent} from 'react' + +export default class UiMigrationAnnouncement extends PureComponent { + static contextTypes = { + t: PropTypes.func.isRequired, + } + + static defaultProps = { + shouldShowAnnouncement: true, + }; + + static propTypes = { + onClose: PropTypes.func.isRequired, + shouldShowAnnouncement: PropTypes.bool, + } + + render () { + const { t } = this.context + const { onClose, shouldShowAnnouncement } = this.props + + if (!shouldShowAnnouncement) { + return null + } + + return ( + <div className="ui-migration-announcement"> + <p>{t('uiMigrationAnnouncement')}</p> + <p onClick={onClose}>{t('close')}</p> + </div> + ) + } +} diff --git a/ui/app/components/app/ui-migration-annoucement/ui-migration-announcement.container.js b/ui/app/components/app/ui-migration-annoucement/ui-migration-announcement.container.js new file mode 100644 index 000000000..55efd5a44 --- /dev/null +++ b/ui/app/components/app/ui-migration-annoucement/ui-migration-announcement.container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux' +import UiMigrationAnnouncement from './ui-migration-annoucement.component' +import { setCompletedUiMigration } from '../../../store/actions' + +const mapStateToProps = (state) => { + const shouldShowAnnouncement = !state.metamask.completedUiMigration + + return { + shouldShowAnnouncement, + } +} + +const mapDispatchToProps = dispatch => { + return { + onClose () { + dispatch(setCompletedUiMigration()) + }, + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(UiMigrationAnnouncement) diff --git a/ui/app/components/user-preferenced-currency-display/index.js b/ui/app/components/app/user-preferenced-currency-display/index.js index 0deddaecf..0deddaecf 100644 --- a/ui/app/components/user-preferenced-currency-display/index.js +++ b/ui/app/components/app/user-preferenced-currency-display/index.js diff --git a/ui/app/components/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js b/ui/app/components/app/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js index ead584c26..51b2a3c4f 100644 --- a/ui/app/components/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js +++ b/ui/app/components/app/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js @@ -2,7 +2,7 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display.component' -import CurrencyDisplay from '../../currency-display' +import CurrencyDisplay from '../../../ui/currency-display' describe('UserPreferencedCurrencyDisplay Component', () => { describe('rendering', () => { diff --git a/ui/app/components/user-preferenced-currency-display/tests/user-preferenced-currency-display.container.test.js b/ui/app/components/app/user-preferenced-currency-display/tests/user-preferenced-currency-display.container.test.js index ba1c23d83..88d63baae 100644 --- a/ui/app/components/user-preferenced-currency-display/tests/user-preferenced-currency-display.container.test.js +++ b/ui/app/components/app/user-preferenced-currency-display/tests/user-preferenced-currency-display.container.test.js @@ -21,6 +21,10 @@ describe('UserPreferencedCurrencyDisplay container', () => { nativeCurrency: 'ETH', preferences: { useNativeCurrencyAsPrimaryCurrency: true, + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', }, }, } @@ -28,6 +32,30 @@ describe('UserPreferencedCurrencyDisplay container', () => { assert.deepEqual(mapStateToProps(mockState), { nativeCurrency: 'ETH', useNativeCurrencyAsPrimaryCurrency: true, + isMainnet: true, + showFiatInTestnets: false, + }) + }) + + it('should return the correct props when not in mainnet and showFiatInTestnets is true', () => { + const mockState = { + metamask: { + nativeCurrency: 'ETH', + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + showFiatInTestnets: true, + }, + provider: { + type: 'rinkeby', + }, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { + nativeCurrency: 'ETH', + useNativeCurrencyAsPrimaryCurrency: true, + isMainnet: false, + showFiatInTestnets: true, }) }) }) @@ -41,6 +69,8 @@ describe('UserPreferencedCurrencyDisplay container', () => { stateProps: { useNativeCurrencyAsPrimaryCurrency: true, nativeCurrency: 'ETH', + isMainnet: true, + showFiatInTestnets: false, }, ownProps: { type: 'PRIMARY', @@ -56,6 +86,8 @@ describe('UserPreferencedCurrencyDisplay container', () => { stateProps: { useNativeCurrencyAsPrimaryCurrency: false, nativeCurrency: 'ETH', + isMainnet: true, + showFiatInTestnets: false, }, ownProps: { type: 'PRIMARY', @@ -71,6 +103,8 @@ describe('UserPreferencedCurrencyDisplay container', () => { stateProps: { useNativeCurrencyAsPrimaryCurrency: true, nativeCurrency: 'ETH', + isMainnet: true, + showFiatInTestnets: false, }, ownProps: { type: 'SECONDARY', @@ -88,6 +122,8 @@ describe('UserPreferencedCurrencyDisplay container', () => { stateProps: { useNativeCurrencyAsPrimaryCurrency: false, nativeCurrency: 'ETH', + isMainnet: true, + showFiatInTestnets: false, }, ownProps: { type: 'SECONDARY', @@ -103,6 +139,57 @@ describe('UserPreferencedCurrencyDisplay container', () => { prefix: 'b', }, }, + { + stateProps: { + useNativeCurrencyAsPrimaryCurrency: false, + nativeCurrency: 'ETH', + isMainnet: false, + showFiatInTestnets: false, + }, + ownProps: { + type: 'PRIMARY', + }, + result: { + currency: 'ETH', + nativeCurrency: 'ETH', + numberOfDecimals: 6, + prefix: undefined, + }, + }, + { + stateProps: { + useNativeCurrencyAsPrimaryCurrency: false, + nativeCurrency: 'ETH', + isMainnet: false, + showFiatInTestnets: true, + }, + ownProps: { + type: 'PRIMARY', + }, + result: { + currency: undefined, + nativeCurrency: 'ETH', + numberOfDecimals: 2, + prefix: undefined, + }, + }, + { + stateProps: { + useNativeCurrencyAsPrimaryCurrency: false, + nativeCurrency: 'ETH', + isMainnet: true, + showFiatInTestnets: true, + }, + ownProps: { + type: 'PRIMARY', + }, + result: { + currency: undefined, + nativeCurrency: 'ETH', + numberOfDecimals: 2, + prefix: undefined, + }, + }, ] tests.forEach(({ stateProps, ownProps, result }) => { diff --git a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.component.js b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js index f2a834ea7..4b64b26c0 100644 --- a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.component.js +++ b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import { PRIMARY, SECONDARY, ETH } from '../../constants/common' -import CurrencyDisplay from '../currency-display' +import { PRIMARY, SECONDARY, ETH } from '../../../helpers/constants/common' +import CurrencyDisplay from '../../ui/currency-display' export default class UserPreferencedCurrencyDisplay extends PureComponent { static propTypes = { @@ -10,6 +10,7 @@ export default class UserPreferencedCurrencyDisplay extends PureComponent { value: PropTypes.string, numberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), hideLabel: PropTypes.bool, + hideTitle: PropTypes.bool, style: PropTypes.object, showEthLogo: PropTypes.bool, ethLogoHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), diff --git a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.container.js b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.container.js index 7999301ad..42d156f92 100644 --- a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.container.js +++ b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.container.js @@ -1,19 +1,26 @@ import { connect } from 'react-redux' import UserPreferencedCurrencyDisplay from './user-preferenced-currency-display.component' -import { preferencesSelector } from '../../selectors' -import { ETH, PRIMARY, SECONDARY } from '../../constants/common' +import { preferencesSelector, getIsMainnet } from '../../../selectors/selectors' +import { ETH, PRIMARY, SECONDARY } from '../../../helpers/constants/common' const mapStateToProps = (state, ownProps) => { - const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) + const { + useNativeCurrencyAsPrimaryCurrency, + showFiatInTestnets, + } = preferencesSelector(state) + + const isMainnet = getIsMainnet(state) return { useNativeCurrencyAsPrimaryCurrency, + showFiatInTestnets, + isMainnet, nativeCurrency: state.metamask.nativeCurrency, } } const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { useNativeCurrencyAsPrimaryCurrency, nativeCurrency, ...restStateProps } = stateProps + const { useNativeCurrencyAsPrimaryCurrency, showFiatInTestnets, isMainnet, nativeCurrency, ...restStateProps } = stateProps const { type, numberOfDecimals: propsNumberOfDecimals, @@ -40,6 +47,12 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { prefix = propsPrefix || fiatPrefix } + if (!isMainnet && !showFiatInTestnets) { + currency = nativeCurrency || ETH + numberOfDecimals = propsNumberOfDecimals || ethNumberOfDecimals || 6 + prefix = propsPrefix || ethPrefix + } + return { ...restStateProps, ...dispatchProps, diff --git a/ui/app/components/user-preferenced-currency-input/index.js b/ui/app/components/app/user-preferenced-currency-input/index.js index 4dc70db3d..4dc70db3d 100644 --- a/ui/app/components/user-preferenced-currency-input/index.js +++ b/ui/app/components/app/user-preferenced-currency-input/index.js diff --git a/ui/app/components/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js b/ui/app/components/app/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js index 710b5d519..3802e16f3 100644 --- a/ui/app/components/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js +++ b/ui/app/components/app/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js @@ -2,7 +2,7 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import UserPreferencedCurrencyInput from '../user-preferenced-currency-input.component' -import CurrencyInput from '../../currency-input' +import CurrencyInput from '../../../ui/currency-input' describe('UserPreferencedCurrencyInput Component', () => { describe('rendering', () => { diff --git a/ui/app/components/user-preferenced-currency-input/tests/user-preferenced-currency-input.container.test.js b/ui/app/components/app/user-preferenced-currency-input/tests/user-preferenced-currency-input.container.test.js index 959726443..959726443 100644 --- a/ui/app/components/user-preferenced-currency-input/tests/user-preferenced-currency-input.container.test.js +++ b/ui/app/components/app/user-preferenced-currency-input/tests/user-preferenced-currency-input.container.test.js diff --git a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.component.js b/ui/app/components/app/user-preferenced-currency-input/user-preferenced-currency-input.component.js index 463e66b80..7c0ec1734 100644 --- a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.component.js +++ b/ui/app/components/app/user-preferenced-currency-input/user-preferenced-currency-input.component.js @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import CurrencyInput from '../currency-input' +import CurrencyInput from '../../ui/currency-input' export default class UserPreferencedCurrencyInput extends PureComponent { static propTypes = { diff --git a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.container.js b/ui/app/components/app/user-preferenced-currency-input/user-preferenced-currency-input.container.js index 0b88eb5a7..72f17fde4 100644 --- a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.container.js +++ b/ui/app/components/app/user-preferenced-currency-input/user-preferenced-currency-input.container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux' import UserPreferencedCurrencyInput from './user-preferenced-currency-input.component' -import { preferencesSelector } from '../../selectors' +import { preferencesSelector } from '../../../selectors/selectors' const mapStateToProps = state => { const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) diff --git a/ui/app/components/user-preferenced-token-input/index.js b/ui/app/components/app/user-preferenced-token-input/index.js index 54167e633..54167e633 100644 --- a/ui/app/components/user-preferenced-token-input/index.js +++ b/ui/app/components/app/user-preferenced-token-input/index.js diff --git a/ui/app/components/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js b/ui/app/components/app/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js index d85bddeeb..41cfd51f9 100644 --- a/ui/app/components/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js +++ b/ui/app/components/app/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js @@ -2,7 +2,7 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import UserPreferencedTokenInput from '../user-preferenced-token-input.component' -import TokenInput from '../../token-input' +import TokenInput from '../../../ui/token-input' describe('UserPreferencedCurrencyInput Component', () => { describe('rendering', () => { diff --git a/ui/app/components/user-preferenced-token-input/tests/user-preferenced-token-input.container.test.js b/ui/app/components/app/user-preferenced-token-input/tests/user-preferenced-token-input.container.test.js index 2f89fba90..2f89fba90 100644 --- a/ui/app/components/user-preferenced-token-input/tests/user-preferenced-token-input.container.test.js +++ b/ui/app/components/app/user-preferenced-token-input/tests/user-preferenced-token-input.container.test.js diff --git a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.component.js b/ui/app/components/app/user-preferenced-token-input/user-preferenced-token-input.component.js index 8f14231ac..24133188d 100644 --- a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.component.js +++ b/ui/app/components/app/user-preferenced-token-input/user-preferenced-token-input.component.js @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import TokenInput from '../token-input' +import TokenInput from '../../ui/token-input' export default class UserPreferencedTokenInput extends PureComponent { static propTypes = { diff --git a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.container.js b/ui/app/components/app/user-preferenced-token-input/user-preferenced-token-input.container.js index 3305d4e29..4a20b20d9 100644 --- a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.container.js +++ b/ui/app/components/app/user-preferenced-token-input/user-preferenced-token-input.container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux' import UserPreferencedTokenInput from './user-preferenced-token-input.component' -import { preferencesSelector } from '../../selectors' +import { preferencesSelector } from '../../../selectors/selectors' const mapStateToProps = state => { const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) diff --git a/ui/app/components/wallet-view.js b/ui/app/components/app/wallet-view.js index e050e0ee6..cec8228b1 100644 --- a/ui/app/components/wallet-view.js +++ b/ui/app/components/app/wallet-view.js @@ -6,16 +6,16 @@ const { withRouter } = require('react-router-dom') const { compose } = require('recompose') const inherits = require('util').inherits const classnames = require('classnames') -const { checksumAddress } = require('../util') -import Identicon from './identicon' +const { checksumAddress } = require('../../helpers/utils/util') +import Identicon from '../ui/identicon' // const AccountDropdowns = require('./dropdowns/index.js').AccountDropdowns -const Tooltip = require('./tooltip-v2.js').default +const Tooltip = require('../ui/tooltip-v2.js').default const copyToClipboard = require('copy-to-clipboard') -const actions = require('../actions') -const BalanceComponent = require('./balance-component') +const actions = require('../../store/actions') +import BalanceComponent from '../ui/balance' const TokenList = require('./token-list') -const selectors = require('../selectors') -const { ADD_TOKEN_ROUTE } = require('../routes') +const selectors = require('../../selectors/selectors') +const { ADD_TOKEN_ROUTE } = require('../../helpers/constants/routes') import AddTokenButton from './add-token-button' @@ -26,6 +26,7 @@ module.exports = compose( WalletView.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } WalletView.defaultProps = { @@ -38,8 +39,7 @@ function mapStateToProps (state) { network: state.metamask.network, sidebarOpen: state.appState.sidebar.isOpen, identities: state.metamask.identities, - accounts: state.metamask.accounts, - tokens: state.metamask.tokens, + accounts: selectors.getMetaMaskAccounts(state), keyrings: state.metamask.keyrings, selectedAddress: selectors.getSelectedAddress(state), selectedAccount: selectors.getSelectedAccount(state), @@ -106,10 +106,18 @@ WalletView.prototype.renderAddToken = function () { hideSidebar, history, } = this.props + const { metricsEvent } = this.context return h(AddTokenButton, { onClick () { history.push(ADD_TOKEN_ROUTE) + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Token Menu', + name: 'Clicked "Add Token"', + }, + }) if (sidebarOpen) { hideSidebar() } @@ -125,10 +133,11 @@ WalletView.prototype.render = function () { showAccountDetailModal, hideSidebar, identities, + network, } = this.props // temporary logs + fake extra wallets - const checksummedAddress = checksumAddress(selectedAddress) + const checksummedAddress = checksumAddress(selectedAddress, network) if (!selectedAddress) { throw new Error('selectedAddress should not be ' + String(selectedAddress)) @@ -196,6 +205,13 @@ WalletView.prototype.render = function () { }), onClick: () => { copyToClipboard(checksummedAddress) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Copied Address', + }, + }) this.setState({ hasCopied: true }) setTimeout(() => this.setState({ hasCopied: false }), 3000) }, diff --git a/ui/app/components/balance-component.js b/ui/app/components/balance-component.js deleted file mode 100644 index 4e2769ee8..000000000 --- a/ui/app/components/balance-component.js +++ /dev/null @@ -1,111 +0,0 @@ -const Component = require('react').Component -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const inherits = require('util').inherits -import TokenBalance from './token-balance' -import Identicon from './identicon' -import UserPreferencedCurrencyDisplay from './user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../constants/common' -const { getNativeCurrency, getAssetImages, conversionRateSelector, getCurrentCurrency} = require('../selectors') - -const { formatBalance } = 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, - nativeCurrency: getNativeCurrency(state), - conversionRate: conversionRateSelector(state), - currentCurrency: getCurrentCurrency(state), - assetImages: getAssetImages(state), - } -} - -inherits(BalanceComponent, Component) -function BalanceComponent () { - Component.call(this) -} - -BalanceComponent.prototype.render = function () { - const props = this.props - const { token, network, assetImages } = props - const address = token && token.address - const image = assetImages && address ? assetImages[token.address] : undefined - - 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, - network, - image, - }), - - 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 { account, nativeCurrency } = props - const balanceValue = account && account.balance - const needsParse = 'needsParse' in props ? props.needsParse : true - const formattedBalance = balanceValue ? formatBalance(balanceValue, 6, needsParse, nativeCurrency) : '...' - 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(UserPreferencedCurrencyDisplay, { - className: 'token-amount', - value: balanceValue, - type: PRIMARY, - ethNumberOfDecimals: 4, - }), - - showFiat && h(UserPreferencedCurrencyDisplay, { - value: balanceValue, - type: SECONDARY, - ethNumberOfDecimals: 4, - }), - ]) -} - -BalanceComponent.prototype.getFiatDisplayNumber = function (formattedBalance, conversionRate) { - if (formattedBalance === 'None') return formattedBalance - if (conversionRate === 0) return 'N/A' - - const splitBalance = formattedBalance.split(' ') - - const convertedNumber = (Number(splitBalance[0]) * conversionRate) - const wholePart = Math.floor(convertedNumber) - const decimalPart = convertedNumber - wholePart - - return wholePart + Number(decimalPart.toPrecision(2)) -} diff --git a/ui/app/components/confirm-page-container/index.scss b/ui/app/components/confirm-page-container/index.scss deleted file mode 100644 index af7a5b555..000000000 --- a/ui/app/components/confirm-page-container/index.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import './confirm-page-container-content/index'; - -@import './confirm-page-container-header/index'; - -@import './confirm-detail-row/index'; diff --git a/ui/app/components/currency-input/index.scss b/ui/app/components/currency-input/index.scss deleted file mode 100644 index fcb2db461..000000000 --- a/ui/app/components/currency-input/index.scss +++ /dev/null @@ -1,7 +0,0 @@ -.currency-input { - &__conversion-component { - font-size: 12px; - line-height: 12px; - padding-left: 1px; - } -} diff --git a/ui/app/components/currency-input/tests/currency-input.container.test.js b/ui/app/components/currency-input/tests/currency-input.container.test.js deleted file mode 100644 index 5d72958e6..000000000 --- a/ui/app/components/currency-input/tests/currency-input.container.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' - -let mapStateToProps, mergeProps - -proxyquire('../currency-input.container.js', { - 'react-redux': { - connect: (ms, md, mp) => { - mapStateToProps = ms - mergeProps = mp - return () => ({}) - }, - }, -}) - -describe('CurrencyInput container', () => { - describe('mapStateToProps()', () => { - it('should return the correct props', () => { - const mockState = { - metamask: { - conversionRate: 280.45, - currentCurrency: 'usd', - nativeCurrency: 'ETH', - }, - } - - assert.deepEqual(mapStateToProps(mockState), { - conversionRate: 280.45, - currentCurrency: 'usd', - nativeCurrency: 'ETH', - }) - }) - }) - - describe('mergeProps()', () => { - it('should return the correct props', () => { - const mockStateProps = { - conversionRate: 280.45, - currentCurrency: 'usd', - nativeCurrency: 'ETH', - } - const mockDispatchProps = {} - - assert.deepEqual(mergeProps(mockStateProps, mockDispatchProps, { useFiat: true }), { - conversionRate: 280.45, - currentCurrency: 'usd', - nativeCurrency: 'ETH', - useFiat: true, - suffix: 'USD', - }) - - assert.deepEqual(mergeProps(mockStateProps, mockDispatchProps, {}), { - conversionRate: 280.45, - currentCurrency: 'usd', - nativeCurrency: 'ETH', - suffix: 'ETH', - }) - }) - }) -}) diff --git a/ui/app/components/dropdowns/account-dropdown-mini.js b/ui/app/components/dropdowns/account-dropdown-mini.js deleted file mode 100644 index 261eb0aa2..000000000 --- a/ui/app/components/dropdowns/account-dropdown-mini.js +++ /dev/null @@ -1,75 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const AccountListItem = require('../send/account-list-item/account-list-item.component').default - -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/components/network-dropdown-icon.js b/ui/app/components/dropdowns/components/network-dropdown-icon.js deleted file mode 100644 index a45da4c10..000000000 --- a/ui/app/components/dropdowns/components/network-dropdown-icon.js +++ /dev/null @@ -1,31 +0,0 @@ -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', - diameter = '12', - } = this.props - - return h(`.menu-icon-circle${isSelected ? '--active' : ''}`, {}, - h('div', { - style: { - background: backgroundColor, - border: innerBorder, - height: `${diameter}px`, - width: `${diameter}px`, - }, - }) - ) -} diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss deleted file mode 100644 index e27b0f182..000000000 --- a/ui/app/components/index.scss +++ /dev/null @@ -1,63 +0,0 @@ -@import './app-header/index'; - -@import './add-token-button/index'; - -@import './button-group/index'; - -@import './card/index'; - -@import './confirm-page-container/index'; - -@import './currency-input/index'; - -@import './currency-display/index'; - -@import './error-message/index'; - -@import './export-text-container/index'; - -@import './identicon/index'; - -@import './info-box/index'; - -@import './menu-bar/index'; - -@import './modal/index'; - -@import './modals/index'; - -@import './network-display/index'; - -@import './page-container/index'; - -@import './pages/index'; - -@import './provider-page-container/index'; - -@import './selected-account/index'; - -@import './sender-to-recipient/index'; - -@import './tabs/index'; - -@import './transaction-activity-log/index'; - -@import './transaction-breakdown/index'; - -@import './transaction-view/index'; - -@import './transaction-view-balance/index'; - -@import './transaction-list/index'; - -@import './transaction-list-item/index'; - -@import './transaction-list-item-details/index'; - -@import './transaction-status/index'; - -@import './app-header/index'; - -@import './sidebars/index'; - -@import './unit-input/index'; diff --git a/ui/app/components/menu-bar/menu-bar.component.js b/ui/app/components/menu-bar/menu-bar.component.js deleted file mode 100644 index 7460e8dd5..000000000 --- a/ui/app/components/menu-bar/menu-bar.component.js +++ /dev/null @@ -1,63 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Tooltip from '../tooltip' -import SelectedAccount from '../selected-account' -import AccountDetailsDropdown from '../dropdowns/account-details-dropdown.js' - -export default class MenuBar extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - hideSidebar: PropTypes.func, - isMascara: PropTypes.bool, - sidebarOpen: PropTypes.bool, - showSidebar: PropTypes.func, - } - - state = { accountDetailsMenuOpen: false } - - render () { - const { t } = this.context - const { isMascara, sidebarOpen, hideSidebar, showSidebar } = this.props - const { accountDetailsMenuOpen } = this.state - - return ( - <div className="menu-bar"> - <Tooltip - title={t('menu')} - position="bottom" - > - <div - className="fa fa-bars menu-bar__sidebar-button" - onClick={() => sidebarOpen ? hideSidebar() : showSidebar()} - /> - </Tooltip> - <SelectedAccount /> - { - !isMascara && ( - <Tooltip - title={t('accountOptions')} - position="bottom" - > - <div - className="fa fa-ellipsis-h fa-lg menu-bar__open-in-browser" - onClick={() => this.setState({ accountDetailsMenuOpen: true })} - > - </div> - </Tooltip> - ) - } - { - accountDetailsMenuOpen && ( - <AccountDetailsDropdown - className="menu-bar__account-details-dropdown" - onClose={() => this.setState({ accountDetailsMenuOpen: false })} - /> - ) - } - </div> - ) - } -} diff --git a/ui/app/components/modals/index.scss b/ui/app/components/modals/index.scss deleted file mode 100644 index 45453a582..000000000 --- a/ui/app/components/modals/index.scss +++ /dev/null @@ -1,9 +0,0 @@ -@import './cancel-transaction/index'; - -@import './confirm-remove-account/index'; - -@import './customize-gas/index'; - -@import './qr-scanner/index'; - -@import './transaction-confirmed/index'; diff --git a/ui/app/components/modals/welcome-beta/index.js b/ui/app/components/modals/welcome-beta/index.js deleted file mode 100644 index 49e45b9d7..000000000 --- a/ui/app/components/modals/welcome-beta/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './welcome-beta.container' diff --git a/ui/app/components/modals/welcome-beta/welcome-beta.container.js b/ui/app/components/modals/welcome-beta/welcome-beta.container.js deleted file mode 100644 index c5123ad47..000000000 --- a/ui/app/components/modals/welcome-beta/welcome-beta.container.js +++ /dev/null @@ -1,4 +0,0 @@ -import WelcomeBeta from './welcome-beta.component' -import withModalProps from '../../../higher-order-components/with-modal-props' - -export default withModalProps(WelcomeBeta) diff --git a/ui/app/components/pages/authenticated.js b/ui/app/components/pages/authenticated.js deleted file mode 100644 index 1f6b0be49..000000000 --- a/ui/app/components/pages/authenticated.js +++ /dev/null @@ -1,34 +0,0 @@ -const { connect } = require('react-redux') -const PropTypes = require('prop-types') -const { Redirect } = require('react-router-dom') -const h = require('react-hyperscript') -const MetamaskRoute = require('./metamask-route') -const { UNLOCK_ROUTE, INITIALIZE_ROUTE } = require('../../routes') - -const Authenticated = props => { - const { isUnlocked, isInitialized } = props - - switch (true) { - case isUnlocked && isInitialized: - return h(MetamaskRoute, { ...props }) - case !isInitialized: - return h(Redirect, { to: { pathname: INITIALIZE_ROUTE } }) - default: - return h(Redirect, { to: { pathname: UNLOCK_ROUTE } }) - } -} - -Authenticated.propTypes = { - isUnlocked: PropTypes.bool, - isInitialized: PropTypes.bool, -} - -const mapStateToProps = state => { - const { metamask: { isUnlocked, isInitialized } } = state - return { - isUnlocked, - isInitialized, - } -} - -module.exports = connect(mapStateToProps)(Authenticated) diff --git a/ui/app/components/pages/index.scss b/ui/app/components/pages/index.scss deleted file mode 100644 index 6551278f5..000000000 --- a/ui/app/components/pages/index.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import './unlock-page/index'; - -@import './add-token/index'; - -@import './confirm-add-token/index'; - -@import './settings/index'; diff --git a/ui/app/components/pages/initialized.js b/ui/app/components/pages/initialized.js deleted file mode 100644 index 3adf67b28..000000000 --- a/ui/app/components/pages/initialized.js +++ /dev/null @@ -1,25 +0,0 @@ -const { connect } = require('react-redux') -const PropTypes = require('prop-types') -const { Redirect } = require('react-router-dom') -const h = require('react-hyperscript') -const { INITIALIZE_ROUTE } = require('../../routes') -const MetamaskRoute = require('./metamask-route') - -const Initialized = props => { - return props.isInitialized - ? h(MetamaskRoute, { ...props }) - : h(Redirect, { to: { pathname: INITIALIZE_ROUTE } }) -} - -Initialized.propTypes = { - isInitialized: PropTypes.bool, -} - -const mapStateToProps = state => { - const { metamask: { isInitialized } } = state - return { - isInitialized, - } -} - -module.exports = connect(mapStateToProps)(Initialized) diff --git a/ui/app/components/pages/metamask-route.js b/ui/app/components/pages/metamask-route.js deleted file mode 100644 index 23c5b5199..000000000 --- a/ui/app/components/pages/metamask-route.js +++ /dev/null @@ -1,28 +0,0 @@ -const { connect } = require('react-redux') -const PropTypes = require('prop-types') -const { Route } = require('react-router-dom') -const h = require('react-hyperscript') - -const MetamaskRoute = ({ component, mascaraComponent, isMascara, ...props }) => { - return ( - h(Route, { - ...props, - component: isMascara && mascaraComponent ? mascaraComponent : component, - }) - ) -} - -MetamaskRoute.propTypes = { - component: PropTypes.func, - mascaraComponent: PropTypes.func, - isMascara: PropTypes.bool, -} - -const mapStateToProps = state => { - const { metamask: { isMascara } } = state - return { - isMascara, - } -} - -module.exports = connect(mapStateToProps)(MetamaskRoute) diff --git a/ui/app/components/pages/settings/index.scss b/ui/app/components/pages/settings/index.scss deleted file mode 100644 index 138ebcfc5..000000000 --- a/ui/app/components/pages/settings/index.scss +++ /dev/null @@ -1,80 +0,0 @@ -@import './info-tab/index'; - -@import './settings-tab/index'; - -.settings-page { - position: relative; - background: $white; - display: flex; - flex-flow: column nowrap; - - &__header { - padding: 25px 25px 0; - } - - &__close-button::after { - content: '\00D7'; - font-size: 40px; - color: $dusty-gray; - position: absolute; - top: 25px; - right: 30px; - cursor: pointer; - } - - &__content { - padding: 25px; - height: auto; - overflow: auto; - } - - &__content-row { - display: flex; - flex-direction: row; - padding: 10px 0 20px; - - @media screen and (max-width: 575px) { - flex-direction: column; - padding: 10px 0; - } - } - - &__content-item { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - padding: 0 5px; - min-height: 71px; - - @media screen and (max-width: 575px) { - height: initial; - padding: 5px 0; - } - - &--without-height { - height: initial; - } - } - - &__content-label { - text-transform: capitalize; - } - - &__content-description { - font-size: 14px; - color: $dusty-gray; - padding-top: 5px; - } - - &__content-item-col { - max-width: 300px; - display: flex; - flex-direction: column; - - @media screen and (max-width: 575px) { - max-width: 100%; - width: 100%; - } - } -} diff --git a/ui/app/components/pages/settings/settings.component.js b/ui/app/components/pages/settings/settings.component.js deleted file mode 100644 index 94a97bba1..000000000 --- a/ui/app/components/pages/settings/settings.component.js +++ /dev/null @@ -1,54 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import { Switch, Route, matchPath } from 'react-router-dom' -import TabBar from '../../tab-bar' -import SettingsTab from './settings-tab' -import InfoTab from './info-tab' -import { DEFAULT_ROUTE, SETTINGS_ROUTE, INFO_ROUTE } from '../../../routes' - -export default class SettingsPage extends PureComponent { - static propTypes = { - location: PropTypes.object, - history: PropTypes.object, - t: PropTypes.func, - } - - static contextTypes = { - t: PropTypes.func, - } - - render () { - const { history, location } = this.props - - return ( - <div className="main-container settings-page"> - <div className="settings-page__header"> - <div - className="settings-page__close-button" - onClick={() => history.push(DEFAULT_ROUTE)} - /> - <TabBar - tabs={[ - { content: this.context.t('settings'), key: SETTINGS_ROUTE }, - { content: this.context.t('info'), key: INFO_ROUTE }, - ]} - isActive={key => matchPath(location.pathname, { path: key, exact: true })} - onSelect={key => history.push(key)} - /> - </div> - <Switch> - <Route - exact - path={INFO_ROUTE} - component={InfoTab} - /> - <Route - exact - path={SETTINGS_ROUTE} - component={SettingsTab} - /> - </Switch> - </div> - ) - } -} diff --git a/ui/app/components/send/account-list-item/account-list-item.component.js b/ui/app/components/send/account-list-item/account-list-item.component.js deleted file mode 100644 index 14bb7471f..000000000 --- a/ui/app/components/send/account-list-item/account-list-item.component.js +++ /dev/null @@ -1,76 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import { checksumAddress } from '../../../util' -import Identicon from '../../identicon' -import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../constants/common' - -export default class AccountListItem extends Component { - - static propTypes = { - account: PropTypes.object, - className: PropTypes.string, - conversionRate: PropTypes.number, - currentCurrency: PropTypes.string, - displayAddress: PropTypes.bool, - displayBalance: PropTypes.bool, - handleClick: PropTypes.func, - icon: PropTypes.node, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - render () { - const { - account, - className, - displayAddress = false, - displayBalance = true, - handleClick, - icon = null, - } = this.props - - const { name, address, balance } = account || {} - - return (<div - className={`account-list-item ${className}`} - onClick={() => handleClick({ name, address, balance })} - > - - <div className="account-list-item__top-row"> - <Identicon - address={address} - className="account-list-item__identicon" - diameter={18} - /> - - <div className="account-list-item__account-name">{ name || address }</div> - - {icon && <div className="account-list-item__icon">{ icon }</div>} - - </div> - - {displayAddress && name && <div className="account-list-item__account-address"> - { checksumAddress(address) } - </div>} - - { - displayBalance && ( - <div className="account-list-item__account-balances"> - <UserPreferencedCurrencyDisplay - type={PRIMARY} - value={balance} - /> - <UserPreferencedCurrencyDisplay - type={SECONDARY} - value={balance} - /> - </div> - ) - } - - </div>) - } -} diff --git a/ui/app/components/send/account-list-item/tests/account-list-item-container.test.js b/ui/app/components/send/account-list-item/tests/account-list-item-container.test.js deleted file mode 100644 index 7c2f5fcb2..000000000 --- a/ui/app/components/send/account-list-item/tests/account-list-item-container.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' - -let mapStateToProps - -proxyquire('../account-list-item.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - return () => ({}) - }, - }, - '../send.selectors.js': { - getConversionRate: (s) => `mockConversionRate:${s}`, - getCurrentCurrency: (s) => `mockCurrentCurrency:${s}`, - getNativeCurrency: (s) => `mockNativeCurrency:${s}`, - }, -}) - -describe('account-list-item container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - conversionRate: 'mockConversionRate:mockState', - currentCurrency: 'mockCurrentCurrency:mockState', - nativeCurrency: 'mockNativeCurrency:mockState', - }) - }) - - }) - -}) diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js deleted file mode 100644 index b490a7fd7..000000000 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js +++ /dev/null @@ -1,22 +0,0 @@ -const { - multiplyCurrencies, - subtractCurrencies, -} = require('../../../../../conversion-util') -const ethUtil = require('ethereumjs-util') - -function calcMaxAmount ({ balance, gasTotal, selectedToken, tokenBalance }) { - const { decimals } = selectedToken || {} - const multiplier = Math.pow(10, Number(decimals || 0)) - - return selectedToken - ? multiplyCurrencies(tokenBalance, multiplier, {toNumericBase: 'hex'}) - : subtractCurrencies( - ethUtil.addHexPrefix(balance), - ethUtil.addHexPrefix(gasTotal), - { toNumericBase: 'hex' } - ) -} - -module.exports = { - calcMaxAmount, -} diff --git a/ui/app/components/send/send-content/send-content-README.md b/ui/app/components/send/send-content/send-content-README.md deleted file mode 100644 index e69de29bb..000000000 --- a/ui/app/components/send/send-content/send-content-README.md +++ /dev/null diff --git a/ui/app/components/send/send-content/send-content.scss b/ui/app/components/send/send-content/send-content.scss deleted file mode 100644 index e69de29bb..000000000 --- a/ui/app/components/send/send-content/send-content.scss +++ /dev/null diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown-README.md b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown-README.md deleted file mode 100644 index e69de29bb..000000000 --- a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown-README.md +++ /dev/null diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.component.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.component.js deleted file mode 100644 index 4f43a9d61..000000000 --- a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.component.js +++ /dev/null @@ -1,46 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import AccountListItem from '../../../account-list-item/' -import SendDropdownList from '../../send-dropdown-list/' - -export default class FromDropdown extends Component { - - static propTypes = { - accounts: PropTypes.array, - closeDropdown: PropTypes.func, - dropdownOpen: PropTypes.bool, - onSelect: PropTypes.func, - openDropdown: PropTypes.func, - selectedAccount: PropTypes.object, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - render () { - const { - accounts, - closeDropdown, - dropdownOpen, - openDropdown, - selectedAccount, - onSelect, - } = this.props - - return <div className="send-v2__from-dropdown"> - <AccountListItem - account={selectedAccount} - handleClick={openDropdown} - icon={<i className={`fa fa-caret-down fa-lg`} style={ { color: '#dedede' } }/>} - /> - {dropdownOpen && <SendDropdownList - accounts={accounts} - closeDropdown={closeDropdown} - onSelect={onSelect} - activeAddress={selectedAccount.address} - />} - </div> - } - -} diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.scss b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.scss deleted file mode 100644 index e69de29bb..000000000 --- a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.scss +++ /dev/null diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js deleted file mode 100644 index 2314ef4e3..000000000 --- a/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './from-dropdown.component' diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js deleted file mode 100644 index 84fcb281e..000000000 --- a/ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import sinon from 'sinon' -import FromDropdown from '../from-dropdown.component.js' - -import AccountListItem from '../../../../account-list-item/account-list-item.container' -import SendDropdownList from '../../../send-dropdown-list/send-dropdown-list.component' - -const propsMethodSpies = { - closeDropdown: sinon.spy(), - openDropdown: sinon.spy(), - onSelect: sinon.spy(), -} - -describe('FromDropdown Component', function () { - let wrapper - - beforeEach(() => { - wrapper = shallow(<FromDropdown - accounts={['mockAccount']} - closeDropdown={propsMethodSpies.closeDropdown} - dropdownOpen={false} - onSelect={propsMethodSpies.onSelect} - openDropdown={propsMethodSpies.openDropdown} - selectedAccount={ { address: 'mockAddress' } } - />, { context: { t: str => str + '_t' } }) - }) - - afterEach(() => { - propsMethodSpies.closeDropdown.resetHistory() - propsMethodSpies.openDropdown.resetHistory() - propsMethodSpies.onSelect.resetHistory() - }) - - describe('render', () => { - it('should render a div with a .send-v2__from-dropdown class', () => { - assert.equal(wrapper.find('.send-v2__from-dropdown').length, 1) - }) - - it('should render an AccountListItem as the first child of the .send-v2__from-dropdown div', () => { - assert(wrapper.find('.send-v2__from-dropdown').childAt(0).is(AccountListItem)) - }) - - it('should pass the correct props to AccountListItem', () => { - const { - account, - handleClick, - icon, - } = wrapper.find('.send-v2__from-dropdown').childAt(0).props() - assert.deepEqual(account, { address: 'mockAddress' }) - assert.deepEqual( - icon, - <i className={`fa fa-caret-down fa-lg`} style={ { color: '#dedede' } }/> - ) - assert.equal(propsMethodSpies.openDropdown.callCount, 0) - handleClick() - assert.equal(propsMethodSpies.openDropdown.callCount, 1) - }) - - it('should not render a SendDropdownList when dropdownOpen is false', () => { - assert.equal(wrapper.find(SendDropdownList).length, 0) - }) - - it('should render a SendDropdownList when dropdownOpen is true', () => { - wrapper.setProps({ dropdownOpen: true }) - assert(wrapper.find(SendDropdownList).length, 1) - }) - - it('should pass the correct props to the SendDropdownList]', () => { - wrapper.setProps({ dropdownOpen: true }) - const { - accounts, - closeDropdown, - onSelect, - activeAddress, - } = wrapper.find(SendDropdownList).props() - assert.deepEqual(accounts, ['mockAccount']) - assert.equal(activeAddress, 'mockAddress') - assert.equal(propsMethodSpies.closeDropdown.callCount, 0) - closeDropdown() - assert.equal(propsMethodSpies.closeDropdown.callCount, 1) - assert.equal(propsMethodSpies.onSelect.callCount, 0) - onSelect() - assert.equal(propsMethodSpies.onSelect.callCount, 1) - }) - }) -}) diff --git a/ui/app/components/send/send-content/send-from-row/send-from-row-README.md b/ui/app/components/send/send-content/send-from-row/send-from-row-README.md deleted file mode 100644 index e69de29bb..000000000 --- a/ui/app/components/send/send-content/send-from-row/send-from-row-README.md +++ /dev/null diff --git a/ui/app/components/send/send-content/send-from-row/send-from-row.component.js b/ui/app/components/send/send-content/send-from-row/send-from-row.component.js deleted file mode 100644 index 3e0e0de22..000000000 --- a/ui/app/components/send/send-content/send-from-row/send-from-row.component.js +++ /dev/null @@ -1,63 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import SendRowWrapper from '../send-row-wrapper/' -import FromDropdown from './from-dropdown/' - -export default class SendFromRow extends Component { - - static propTypes = { - closeFromDropdown: PropTypes.func, - conversionRate: PropTypes.number, - from: PropTypes.object, - fromAccounts: PropTypes.array, - fromDropdownOpen: PropTypes.bool, - openFromDropdown: PropTypes.func, - tokenContract: PropTypes.object, - updateSendFrom: PropTypes.func, - setSendTokenBalance: PropTypes.func, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - async handleFromChange (newFrom) { - const { - updateSendFrom, - tokenContract, - setSendTokenBalance, - } = this.props - - if (tokenContract) { - const usersToken = await tokenContract.balanceOf(newFrom.address) - setSendTokenBalance(usersToken) - } - updateSendFrom(newFrom) - } - - render () { - const { - closeFromDropdown, - conversionRate, - from, - fromAccounts, - fromDropdownOpen, - openFromDropdown, - } = this.props - - return ( - <SendRowWrapper label={`${this.context.t('from')}:`}> - <FromDropdown - accounts={fromAccounts} - closeDropdown={() => closeFromDropdown()} - conversionRate={conversionRate} - dropdownOpen={fromDropdownOpen} - onSelect={newFrom => this.handleFromChange(newFrom)} - openDropdown={() => openFromDropdown()} - selectedAccount={from} - /> - </SendRowWrapper> - ) - } - -} diff --git a/ui/app/components/send/send-content/send-from-row/send-from-row.container.js b/ui/app/components/send/send-content/send-from-row/send-from-row.container.js deleted file mode 100644 index 33cb63b43..000000000 --- a/ui/app/components/send/send-content/send-from-row/send-from-row.container.js +++ /dev/null @@ -1,46 +0,0 @@ -import { connect } from 'react-redux' -import { - accountsWithSendEtherInfoSelector, - getConversionRate, - getSelectedTokenContract, - getSendFromObject, -} from '../../send.selectors.js' -import { - getFromDropdownOpen, -} from './send-from-row.selectors.js' -import { calcTokenBalance } from '../../send.utils.js' -import { - updateSendFrom, - setSendTokenBalance, -} from '../../../../actions' -import { - closeFromDropdown, - openFromDropdown, -} from '../../../../ducks/send.duck' -import SendFromRow from './send-from-row.component' - -export default connect(mapStateToProps, mapDispatchToProps)(SendFromRow) - -function mapStateToProps (state) { - return { - conversionRate: getConversionRate(state), - from: getSendFromObject(state), - fromAccounts: accountsWithSendEtherInfoSelector(state), - fromDropdownOpen: getFromDropdownOpen(state), - tokenContract: getSelectedTokenContract(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - closeFromDropdown: () => dispatch(closeFromDropdown()), - openFromDropdown: () => dispatch(openFromDropdown()), - updateSendFrom: newFrom => dispatch(updateSendFrom(newFrom)), - setSendTokenBalance: (usersToken, selectedToken) => { - if (!usersToken) return - - const tokenBalance = calcTokenBalance({ usersToken, selectedToken }) - dispatch(setSendTokenBalance(tokenBalance)) - }, - } -} diff --git a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-component.test.js b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-component.test.js deleted file mode 100644 index 9ba8d1739..000000000 --- a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-component.test.js +++ /dev/null @@ -1,121 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import sinon from 'sinon' -import SendFromRow from '../send-from-row.component.js' - -import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component' -import FromDropdown from '../from-dropdown/from-dropdown.component' - -const propsMethodSpies = { - closeFromDropdown: sinon.spy(), - openFromDropdown: sinon.spy(), - updateSendFrom: sinon.spy(), - setSendTokenBalance: sinon.spy(), -} - -sinon.spy(SendFromRow.prototype, 'handleFromChange') - -describe('SendFromRow Component', function () { - let wrapper - let instance - - beforeEach(() => { - wrapper = shallow(<SendFromRow - closeFromDropdown={propsMethodSpies.closeFromDropdown} - conversionRate={15} - from={ { address: 'mockAddress' } } - fromAccounts={['mockAccount']} - fromDropdownOpen={false} - openFromDropdown={propsMethodSpies.openFromDropdown} - setSendTokenBalance={propsMethodSpies.setSendTokenBalance} - tokenContract={null} - updateSendFrom={propsMethodSpies.updateSendFrom} - />, { context: { t: str => str + '_t' } }) - instance = wrapper.instance() - }) - - afterEach(() => { - propsMethodSpies.closeFromDropdown.resetHistory() - propsMethodSpies.openFromDropdown.resetHistory() - propsMethodSpies.updateSendFrom.resetHistory() - propsMethodSpies.setSendTokenBalance.resetHistory() - SendFromRow.prototype.handleFromChange.resetHistory() - }) - - describe('handleFromChange', () => { - - it('should call updateSendFrom', () => { - assert.equal(propsMethodSpies.updateSendFrom.callCount, 0) - instance.handleFromChange('mockFrom') - assert.equal(propsMethodSpies.updateSendFrom.callCount, 1) - assert.deepEqual( - propsMethodSpies.updateSendFrom.getCall(0).args, - ['mockFrom'] - ) - }) - - it('should call tokenContract.balanceOf and setSendTokenBalance if tokenContract is defined', async () => { - wrapper.setProps({ - tokenContract: { - balanceOf: () => new Promise((resolve) => resolve('mockUsersToken')), - }, - }) - assert.equal(propsMethodSpies.setSendTokenBalance.callCount, 0) - await instance.handleFromChange('mockFrom') - assert.equal(propsMethodSpies.setSendTokenBalance.callCount, 1) - assert.deepEqual( - propsMethodSpies.setSendTokenBalance.getCall(0).args, - ['mockUsersToken'] - ) - }) - - }) - - describe('render', () => { - it('should render a SendRowWrapper component', () => { - assert.equal(wrapper.find(SendRowWrapper).length, 1) - }) - - it('should pass the correct props to SendRowWrapper', () => { - const { - label, - } = wrapper.find(SendRowWrapper).props() - - assert.equal(label, 'from_t:') - }) - - it('should render an FromDropdown as a child of the SendRowWrapper', () => { - assert(wrapper.find(SendRowWrapper).childAt(0).is(FromDropdown)) - }) - - it('should render the FromDropdown with the correct props', () => { - const { - accounts, - closeDropdown, - conversionRate, - dropdownOpen, - onSelect, - openDropdown, - selectedAccount, - } = wrapper.find(SendRowWrapper).childAt(0).props() - assert.deepEqual(accounts, ['mockAccount']) - assert.equal(dropdownOpen, false) - assert.equal(conversionRate, 15) - assert.deepEqual(selectedAccount, { address: 'mockAddress' }) - assert.equal(propsMethodSpies.closeFromDropdown.callCount, 0) - closeDropdown() - assert.equal(propsMethodSpies.closeFromDropdown.callCount, 1) - assert.equal(propsMethodSpies.openFromDropdown.callCount, 0) - openDropdown() - assert.equal(propsMethodSpies.openFromDropdown.callCount, 1) - assert.equal(SendFromRow.prototype.handleFromChange.callCount, 0) - onSelect('mockNewFrom') - assert.equal(SendFromRow.prototype.handleFromChange.callCount, 1) - assert.deepEqual( - SendFromRow.prototype.handleFromChange.getCall(0).args, - ['mockNewFrom'] - ) - }) - }) -}) diff --git a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-container.test.js b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-container.test.js deleted file mode 100644 index e080b2fe3..000000000 --- a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-container.test.js +++ /dev/null @@ -1,110 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - updateSendFrom: sinon.spy(), - setSendTokenBalance: sinon.spy(), -} -const duckActionSpies = { - closeFromDropdown: sinon.spy(), - openFromDropdown: sinon.spy(), -} - -proxyquire('../send-from-row.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - '../../send.selectors.js': { - accountsWithSendEtherInfoSelector: (s) => `mockFromAccounts:${s}`, - getConversionRate: (s) => `mockConversionRate:${s}`, - getSelectedTokenContract: (s) => `mockTokenContract:${s}`, - getSendFromObject: (s) => `mockFrom:${s}`, - }, - './send-from-row.selectors.js': { getFromDropdownOpen: (s) => `mockFromDropdownOpen:${s}` }, - '../../send.utils.js': { calcTokenBalance: ({ usersToken, selectedToken }) => usersToken + selectedToken }, - '../../../../actions': actionSpies, - '../../../../ducks/send.duck': duckActionSpies, -}) - -describe('send-from-row container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - conversionRate: 'mockConversionRate:mockState', - from: 'mockFrom:mockState', - fromAccounts: 'mockFromAccounts:mockState', - fromDropdownOpen: 'mockFromDropdownOpen:mockState', - tokenContract: 'mockTokenContract:mockState', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - }) - - describe('closeFromDropdown()', () => { - it('should dispatch a closeFromDropdown action', () => { - mapDispatchToPropsObject.closeFromDropdown() - assert(dispatchSpy.calledOnce) - assert(duckActionSpies.closeFromDropdown.calledOnce) - assert.equal( - duckActionSpies.closeFromDropdown.getCall(0).args[0], - undefined - ) - }) - }) - - describe('openFromDropdown()', () => { - it('should dispatch a openFromDropdown action', () => { - mapDispatchToPropsObject.openFromDropdown() - assert(dispatchSpy.calledOnce) - assert(duckActionSpies.openFromDropdown.calledOnce) - assert.equal( - duckActionSpies.openFromDropdown.getCall(0).args[0], - undefined - ) - }) - }) - - describe('updateSendFrom()', () => { - it('should dispatch an updateSendFrom action', () => { - mapDispatchToPropsObject.updateSendFrom('mockFrom') - assert(dispatchSpy.calledOnce) - assert.equal( - actionSpies.updateSendFrom.getCall(0).args[0], - 'mockFrom' - ) - }) - }) - - describe('setSendTokenBalance()', () => { - it('should dispatch an setSendTokenBalance action', () => { - mapDispatchToPropsObject.setSendTokenBalance('mockUsersToken', 'mockSelectedToken') - assert(dispatchSpy.calledOnce) - assert.equal( - actionSpies.setSendTokenBalance.getCall(0).args[0], - 'mockUsersTokenmockSelectedToken' - ) - }) - }) - - }) - -}) diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js deleted file mode 100644 index 91b58cfd0..000000000 --- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js +++ /dev/null @@ -1,48 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import SendRowWrapper from '../send-row-wrapper/' -import GasFeeDisplay from './gas-fee-display/gas-fee-display.component' - -export default class SendGasRow extends Component { - - static propTypes = { - conversionRate: PropTypes.number, - convertedCurrency: PropTypes.string, - gasFeeError: PropTypes.bool, - gasLoadingError: PropTypes.bool, - gasTotal: PropTypes.string, - showCustomizeGasModal: PropTypes.func, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - render () { - const { - conversionRate, - convertedCurrency, - gasLoadingError, - gasTotal, - gasFeeError, - showCustomizeGasModal, - } = this.props - - return ( - <SendRowWrapper - label={`${this.context.t('gasFee')}:`} - showError={gasFeeError} - errorType={'gasFee'} - > - <GasFeeDisplay - conversionRate={conversionRate} - convertedCurrency={convertedCurrency} - gasLoadingError={gasLoadingError} - gasTotal={gasTotal} - onClick={() => showCustomizeGasModal()} - /> - </SendRowWrapper> - ) - } - -} diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js deleted file mode 100644 index 8f8e3e4dd..000000000 --- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js +++ /dev/null @@ -1,27 +0,0 @@ -import { connect } from 'react-redux' -import { - getConversionRate, - getCurrentCurrency, - getGasTotal, -} from '../../send.selectors.js' -import { getGasLoadingError, gasFeeIsInError } from './send-gas-row.selectors.js' -import { showModal } from '../../../../actions' -import SendGasRow from './send-gas-row.component' - -export default connect(mapStateToProps, mapDispatchToProps)(SendGasRow) - -function mapStateToProps (state) { - return { - conversionRate: getConversionRate(state), - convertedCurrency: getCurrentCurrency(state), - gasTotal: getGasTotal(state), - gasFeeError: gasFeeIsInError(state), - gasLoadingError: getGasLoadingError(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS' })), - } -} diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js deleted file mode 100644 index 2ce062505..000000000 --- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - showModal: sinon.spy(), -} - -proxyquire('../send-gas-row.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - '../../send.selectors.js': { - getConversionRate: (s) => `mockConversionRate:${s}`, - getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`, - getGasTotal: (s) => `mockGasTotal:${s}`, - }, - './send-gas-row.selectors.js': { - getGasLoadingError: (s) => `mockGasLoadingError:${s}`, - gasFeeIsInError: (s) => `mockGasFeeError:${s}`, - }, - '../../../../actions': actionSpies, -}) - -describe('send-gas-row container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - conversionRate: 'mockConversionRate:mockState', - convertedCurrency: 'mockConvertedCurrency:mockState', - gasTotal: 'mockGasTotal:mockState', - gasFeeError: 'mockGasFeeError:mockState', - gasLoadingError: 'mockGasLoadingError:mockState', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - }) - - describe('showCustomizeGasModal()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.showCustomizeGasModal() - assert(dispatchSpy.calledOnce) - assert.deepEqual( - actionSpies.showModal.getCall(0).args[0], - { name: 'CUSTOMIZE_GAS' } - ) - }) - }) - - }) - -}) diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js b/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js deleted file mode 100644 index 0eeaa3a11..000000000 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js +++ /dev/null @@ -1,21 +0,0 @@ -const { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, -} = require('../../send.constants') -const { isValidAddress } = require('../../../../util') - -function getToErrorObject (to, toError = null, hasHexData = false) { - if (!to) { - if (!hasHexData) { - toError = REQUIRED_ERROR - } - } else if (!isValidAddress(to) && !toError) { - toError = INVALID_RECIPIENT_ADDRESS_ERROR - } - - return { to: toError } -} - -module.exports = { - getToErrorObject, -} diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js deleted file mode 100644 index c779aeb76..000000000 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -import { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, -} from '../../../send.constants' - -const stubs = { - isValidAddress: sinon.stub().callsFake(to => Boolean(to.match(/^[0xabcdef123456798]+$/))), -} - -const toRowUtils = proxyquire('../send-to-row.utils.js', { - '../../../../util': { - isValidAddress: stubs.isValidAddress, - }, -}) -const { - getToErrorObject, -} = toRowUtils - -describe('send-to-row utils', () => { - - describe('getToErrorObject()', () => { - it('should return a required error if to is falsy', () => { - assert.deepEqual(getToErrorObject(null), { - to: REQUIRED_ERROR, - }) - }) - - it('should return null if to is falsy and hexData is truthy', () => { - assert.deepEqual(getToErrorObject(null, undefined, true), { - to: null, - }) - }) - - it('should return an invalid recipient error if to is truthy but invalid', () => { - assert.deepEqual(getToErrorObject('mockInvalidTo'), { - to: INVALID_RECIPIENT_ADDRESS_ERROR, - }) - }) - - it('should return null if to is truthy and valid', () => { - assert.deepEqual(getToErrorObject('0xabc123'), { - to: null, - }) - }) - - it('should return the passed error if to is truthy but invalid if to is truthy and valid', () => { - assert.deepEqual(getToErrorObject('invalid #$ 345878', 'someExplicitError'), { - to: 'someExplicitError', - }) - }) - }) - -}) diff --git a/ui/app/components/sidebars/sidebar.component.js b/ui/app/components/sidebars/sidebar.component.js deleted file mode 100644 index 57cdd7111..000000000 --- a/ui/app/components/sidebars/sidebar.component.js +++ /dev/null @@ -1,49 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import ReactCSSTransitionGroup from 'react-addons-css-transition-group' -import WalletView from '../wallet-view' -import { WALLET_VIEW_SIDEBAR } from './sidebar.constants' - -export default class Sidebar extends Component { - - static propTypes = { - sidebarOpen: PropTypes.bool, - hideSidebar: PropTypes.func, - transitionName: PropTypes.string, - type: PropTypes.string, - }; - - renderOverlay () { - return <div className="sidebar-overlay" onClick={() => this.props.hideSidebar()} /> - } - - renderSidebarContent () { - const { type } = this.props - - switch (type) { - case WALLET_VIEW_SIDEBAR: - return <WalletView responsiveDisplayClassname={'sidebar-right' } /> - default: - return null - } - - } - - render () { - const { transitionName, sidebarOpen } = this.props - - return ( - <div> - <ReactCSSTransitionGroup - transitionName={transitionName} - transitionEnterTimeout={300} - transitionLeaveTimeout={200} - > - { sidebarOpen ? this.renderSidebarContent() : null } - </ReactCSSTransitionGroup> - { sidebarOpen ? this.renderOverlay() : null } - </div> - ) - } - -} diff --git a/ui/app/components/tab-bar.js b/ui/app/components/tab-bar.js deleted file mode 100644 index 0016a09c1..000000000 --- a/ui/app/components/tab-bar.js +++ /dev/null @@ -1,33 +0,0 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const classnames = require('classnames') - -class TabBar extends Component { - render () { - const { tabs = [], onSelect, isActive } = this.props - - return ( - h('.tab-bar', {}, [ - tabs.map(({ key, content }) => { - return h('div', { - className: classnames('tab-bar__tab pointer', { - 'tab-bar__tab--active': isActive(key, content), - }), - onClick: () => onSelect(key), - key, - }, content) - }), - h('div.tab-bar__tab.tab-bar__grow-tab'), - ]) - ) - } -} - -TabBar.propTypes = { - isActive: PropTypes.func.isRequired, - tabs: PropTypes.array, - onSelect: PropTypes.func, -} - -module.exports = TabBar diff --git a/ui/app/components/token-input/tests/token-input.container.test.js b/ui/app/components/token-input/tests/token-input.container.test.js deleted file mode 100644 index d73bc9a94..000000000 --- a/ui/app/components/token-input/tests/token-input.container.test.js +++ /dev/null @@ -1,129 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' - -let mapStateToProps, mergeProps - -proxyquire('../token-input.container.js', { - 'react-redux': { - connect: (ms, md, mp) => { - mapStateToProps = ms - mergeProps = mp - return () => ({}) - }, - }, -}) - -describe('TokenInput container', () => { - describe('mapStateToProps()', () => { - it('should return the correct props when send is empty', () => { - const mockState = { - metamask: { - currentCurrency: 'usd', - tokens: [ - { - address: '0x1', - decimals: '4', - symbol: 'ABC', - }, - ], - selectedTokenAddress: '0x1', - contractExchangeRates: {}, - send: {}, - }, - } - - assert.deepEqual(mapStateToProps(mockState), { - currentCurrency: 'usd', - selectedToken: { - address: '0x1', - decimals: '4', - symbol: 'ABC', - }, - selectedTokenExchangeRate: 0, - }) - }) - - it('should return the correct props when selectedTokenAddress is not found and send is populated', () => { - const mockState = { - metamask: { - currentCurrency: 'usd', - tokens: [ - { - address: '0x1', - decimals: '4', - symbol: 'ABC', - }, - ], - selectedTokenAddress: '0x2', - contractExchangeRates: {}, - send: { - token: { address: 'test' }, - }, - }, - } - - assert.deepEqual(mapStateToProps(mockState), { - currentCurrency: 'usd', - selectedToken: { - address: 'test', - }, - selectedTokenExchangeRate: 0, - }) - }) - - it('should return the correct props when contractExchangeRates is populated', () => { - const mockState = { - metamask: { - currentCurrency: 'usd', - tokens: [ - { - address: '0x1', - decimals: '4', - symbol: 'ABC', - }, - ], - selectedTokenAddress: '0x1', - contractExchangeRates: { - '0x1': 5, - }, - send: {}, - }, - } - - assert.deepEqual(mapStateToProps(mockState), { - currentCurrency: 'usd', - selectedToken: { - address: '0x1', - decimals: '4', - symbol: 'ABC', - }, - selectedTokenExchangeRate: 5, - }) - }) - }) - - describe('mergeProps()', () => { - it('should return the correct props', () => { - const mockStateProps = { - currentCurrency: 'usd', - selectedToken: { - address: '0x1', - decimals: '4', - symbol: 'ABC', - }, - selectedTokenExchangeRate: 5, - } - - assert.deepEqual(mergeProps(mockStateProps, {}, {}), { - currentCurrency: 'usd', - selectedToken: { - address: '0x1', - decimals: '4', - symbol: 'ABC', - }, - selectedTokenExchangeRate: 5, - suffix: 'ABC', - }) - }) - }) -}) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js deleted file mode 100644 index 8687dbbc7..000000000 --- a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import TransactionActivityLog from '../transaction-activity-log.component' -import Card from '../../card' - -describe('TransactionActivityLog Component', () => { - it('should render properly', () => { - const transaction = { - history: [], - id: 1, - status: 'confirmed', - txParams: { - from: '0x1', - gas: '0x5208', - gasPrice: '0x3b9aca00', - nonce: '0xa4', - to: '0x2', - value: '0x2386f26fc10000', - }, - } - - const wrapper = shallow( - <TransactionActivityLog - transaction={transaction} - className="test-class" - />, - { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } - ) - - assert.ok(wrapper.hasClass('transaction-activity-log')) - assert.ok(wrapper.hasClass('test-class')) - assert.equal(wrapper.find(Card).length, 1) - }) -}) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js deleted file mode 100644 index 586500408..000000000 --- a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js +++ /dev/null @@ -1,208 +0,0 @@ -import assert from 'assert' -import { getActivities } from '../transaction-activity-log.util' - -describe('getActivities', () => { - it('should return no activities for an empty history', () => { - const transaction = { - history: [], - id: 1, - status: 'confirmed', - txParams: { - from: '0x1', - gas: '0x5208', - gasPrice: '0x3b9aca00', - nonce: '0xa4', - to: '0x2', - value: '0x2386f26fc10000', - }, - } - - assert.deepEqual(getActivities(transaction), []) - }) - - it('should return activities for a transaction\'s history', () => { - const transaction = { - history: [ - { - id: 5559712943815343, - loadingDefaults: true, - metamaskNetworkId: '3', - status: 'unapproved', - time: 1535507561452, - txParams: { - from: '0x1', - gas: '0x5208', - gasPrice: '0x3b9aca00', - nonce: '0xa4', - to: '0x2', - value: '0x2386f26fc10000', - }, - }, - [ - { - op: 'replace', - path: '/loadingDefaults', - timestamp: 1535507561515, - value: false, - }, - { - op: 'add', - path: '/gasPriceSpecified', - value: true, - }, - { - op: 'add', - path: '/gasLimitSpecified', - value: true, - }, - { - op: 'add', - path: '/estimatedGas', - value: '0x5208', - }, - ], - [ - { - note: '#newUnapprovedTransaction - adding the origin', - op: 'add', - path: '/origin', - timestamp: 1535507561516, - value: 'MetaMask', - }, - [], - ], - [ - { - note: 'confTx: user approved transaction', - op: 'replace', - path: '/txParams/gasPrice', - timestamp: 1535664571504, - value: '0x77359400', - }, - ], - [ - { - note: 'txStateManager: setting status to approved', - op: 'replace', - path: '/status', - timestamp: 1535507564302, - value: 'approved', - }, - ], - [ - { - note: 'transactions#approveTransaction', - op: 'add', - path: '/txParams/nonce', - timestamp: 1535507564439, - value: '0xa4', - }, - { - op: 'add', - path: '/nonceDetails', - value: { - local: {}, - network: {}, - params: {}, - }, - }, - ], - [ - { - note: 'transactions#publishTransaction', - op: 'replace', - path: '/status', - timestamp: 1535507564518, - value: 'signed', - }, - { - op: 'add', - path: '/rawTx', - value: '0xf86b81a4843b9aca008252089450a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706872386f26fc10000802aa007b30119fc4fc5954fad727895b7e3ba80a78d197e95703cc603bcf017879151a01c50beda40ffaee541da9c05b9616247074f25f392800e0ad6c7a835d5366edf', - }, - ], - [], - [ - { - note: 'transactions#setTxHash', - op: 'add', - path: '/hash', - timestamp: 1535507564658, - value: '0x7acc4987b5c0dfa8d423798a8c561138259de1f98a62e3d52e7e83c0e0dd9fb7', - }, - ], - [ - { - note: 'txStateManager - add submitted time stamp', - op: 'add', - path: '/submittedTime', - timestamp: 1535507564660, - value: 1535507564660, - }, - ], - [ - { - note: 'txStateManager: setting status to submitted', - op: 'replace', - path: '/status', - timestamp: 1535507564665, - value: 'submitted', - }, - ], - [ - { - note: 'transactions/pending-tx-tracker#event: tx:block-update', - op: 'add', - path: '/firstRetryBlockNumber', - timestamp: 1535507575476, - value: '0x3bf624', - }, - ], - [ - { - note: 'txStateManager: setting status to confirmed', - op: 'replace', - path: '/status', - timestamp: 1535507615993, - value: 'confirmed', - }, - ], - ], - id: 1, - status: 'confirmed', - txParams: { - from: '0x1', - gas: '0x5208', - gasPrice: '0x3b9aca00', - nonce: '0xa4', - to: '0x2', - value: '0x2386f26fc10000', - }, - } - - const expectedResult = [ - { - 'eventKey': 'transactionCreated', - 'timestamp': 1535507561452, - 'value': '0x2386f26fc10000', - }, - { - 'eventKey': 'transactionUpdatedGas', - 'timestamp': 1535664571504, - 'value': '0x77359400', - }, - { - 'eventKey': 'transactionSubmitted', - 'timestamp': 1535507564665, - 'value': undefined, - }, - { - 'eventKey': 'transactionConfirmed', - 'timestamp': 1535507615993, - 'value': undefined, - }, - ] - - assert.deepEqual(getActivities(transaction), expectedResult) - }) -}) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js deleted file mode 100644 index 58d932a0f..000000000 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js +++ /dev/null @@ -1,96 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { getActivities } from './transaction-activity-log.util' -import Card from '../card' -import { getEthConversionFromWeiHex, getValueFromWeiHex } from '../../helpers/conversions.util' -import { formatDate } from '../../util' - -export default class TransactionActivityLog extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - transaction: PropTypes.object, - className: PropTypes.string, - conversionRate: PropTypes.number, - nativeCurrency: PropTypes.string, - } - - state = { - activities: [], - } - - componentDidMount () { - this.setActivites() - } - - componentDidUpdate (prevProps) { - const { - transaction: { history: prevHistory = [], txReceipt: { status: prevStatus } = {} } = {}, - } = prevProps - const { - transaction: { history = [], txReceipt: { status } = {} } = {}, - } = this.props - - if (prevHistory.length !== history.length || prevStatus !== status) { - this.setActivites() - } - } - - setActivites () { - const activities = getActivities(this.props.transaction) - this.setState({ activities }) - } - - renderActivity (activity, index) { - const { conversionRate, nativeCurrency } = this.props - const { eventKey, value, timestamp } = activity - const ethValue = index === 0 - ? `${getValueFromWeiHex({ - value, - fromCurrency: nativeCurrency, - toCurrency: nativeCurrency, - conversionRate, - numberOfDecimals: 6, - })} ${nativeCurrency}` - : getEthConversionFromWeiHex({ value, fromCurrency: nativeCurrency, conversionRate }) - const formattedTimestamp = formatDate(timestamp) - const activityText = this.context.t(eventKey, [ethValue, formattedTimestamp]) - - return ( - <div - key={index} - className="transaction-activity-log__activity" - > - <div className="transaction-activity-log__activity-icon" /> - <div - className="transaction-activity-log__activity-text" - title={activityText} - > - { activityText } - </div> - </div> - ) - } - - render () { - const { t } = this.context - const { className } = this.props - const { activities } = this.state - - return ( - <div className={classnames('transaction-activity-log', className)}> - <Card - title={t('activityLog')} - className="transaction-activity-log__card" - > - <div className="transaction-activity-log__activities-container"> - { activities.map((activity, index) => this.renderActivity(activity, index)) } - </div> - </Card> - </div> - ) - } -} diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js deleted file mode 100644 index 622f77df1..000000000 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux' -import TransactionActivityLog from './transaction-activity-log.component' -import { conversionRateSelector, getNativeCurrency } from '../../selectors' - -const mapStateToProps = state => { - return { - conversionRate: conversionRateSelector(state), - nativeCurrency: getNativeCurrency(state), - } -} - -export default connect(mapStateToProps)(TransactionActivityLog) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js deleted file mode 100644 index 16597ae1a..000000000 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js +++ /dev/null @@ -1,93 +0,0 @@ -// path constants -const STATUS_PATH = '/status' -const GAS_PRICE_PATH = '/txParams/gasPrice' - -// status constants -const UNAPPROVED_STATUS = 'unapproved' -const SUBMITTED_STATUS = 'submitted' -const CONFIRMED_STATUS = 'confirmed' -const DROPPED_STATUS = 'dropped' - -// op constants -const REPLACE_OP = 'replace' - -// event constants -const TRANSACTION_CREATED_EVENT = 'transactionCreated' -const TRANSACTION_UPDATED_GAS_EVENT = 'transactionUpdatedGas' -const TRANSACTION_SUBMITTED_EVENT = 'transactionSubmitted' -const TRANSACTION_CONFIRMED_EVENT = 'transactionConfirmed' -const TRANSACTION_DROPPED_EVENT = 'transactionDropped' -const TRANSACTION_UPDATED_EVENT = 'transactionUpdated' -const TRANSACTION_ERRORED_EVENT = 'transactionErrored' - -const eventPathsHash = { - [STATUS_PATH]: true, - [GAS_PRICE_PATH]: true, -} - -const statusHash = { - [SUBMITTED_STATUS]: TRANSACTION_SUBMITTED_EVENT, - [CONFIRMED_STATUS]: TRANSACTION_CONFIRMED_EVENT, - [DROPPED_STATUS]: TRANSACTION_DROPPED_EVENT, -} - -function eventCreator (eventKey, timestamp, value) { - return { - eventKey, - timestamp, - value, - } -} - -export function getActivities (transaction) { - const { history = [], txReceipt: { status } = {} } = transaction - - const historyActivities = history.reduce((acc, base) => { - // First history item should be transaction creation - if (!Array.isArray(base) && base.status === UNAPPROVED_STATUS && base.txParams) { - const { time, txParams: { value } = {} } = base - return acc.concat(eventCreator(TRANSACTION_CREATED_EVENT, time, value)) - // An entry in the history may be an array of more sub-entries. - } else if (Array.isArray(base)) { - const events = [] - - base.forEach(entry => { - const { op, path, value, timestamp: entryTimestamp } = entry - // Not all sub-entries in a history entry have a timestamp. If the sub-entry does not have a - // timestamp, the first sub-entry in a history entry should. - const timestamp = entryTimestamp || base[0] && base[0].timestamp - - if (path in eventPathsHash && op === REPLACE_OP) { - switch (path) { - case STATUS_PATH: { - if (value in statusHash) { - events.push(eventCreator(statusHash[value], timestamp)) - } - - break - } - - case GAS_PRICE_PATH: { - events.push(eventCreator(TRANSACTION_UPDATED_GAS_EVENT, timestamp, value)) - break - } - - default: { - events.push(eventCreator(TRANSACTION_UPDATED_EVENT, timestamp)) - } - } - } - }) - - return acc.concat(events) - } - - return acc - }, []) - - // If txReceipt.status is '0x0', that means that an on-chain error occured for the transaction, - // so we add an error entry to the Activity Log. - return status === '0x0' - ? historyActivities.concat(eventCreator(TRANSACTION_ERRORED_EVENT)) - : historyActivities -} diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js deleted file mode 100644 index 3a7647873..000000000 --- a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js +++ /dev/null @@ -1,100 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import TransactionBreakdownRow from './transaction-breakdown-row' -import Card from '../card' -import CurrencyDisplay from '../currency-display' -import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' -import HexToDecimal from '../hex-to-decimal' -import { GWEI, PRIMARY, SECONDARY } from '../../constants/common' -import { getHexGasTotal } from '../../helpers/confirm-transaction/util' -import { sumHexes } from '../../helpers/transactions.util' - -export default class TransactionBreakdown extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - transaction: PropTypes.object, - className: PropTypes.string, - nativeCurrency: PropTypes.string.isRequired, - } - - static defaultProps = { - transaction: {}, - } - - render () { - const { t } = this.context - const { transaction, className, nativeCurrency } = this.props - const { txParams: { gas, gasPrice, value } = {}, txReceipt: { gasUsed } = {} } = transaction - - const gasLimit = typeof gasUsed === 'string' ? gasUsed : gas - - const hexGasTotal = getHexGasTotal({ gasLimit, gasPrice }) - const totalInHex = sumHexes(hexGasTotal, value) - - return ( - <div className={classnames('transaction-breakdown', className)}> - <Card - title={t('transaction')} - className="transaction-breakdown__card" - > - <TransactionBreakdownRow title={t('amount')}> - <UserPreferencedCurrencyDisplay - className="transaction-breakdown__value" - type={PRIMARY} - value={value} - /> - </TransactionBreakdownRow> - <TransactionBreakdownRow - title={`${t('gasLimit')} (${t('units')})`} - className="transaction-breakdown__row-title" - > - <HexToDecimal - className="transaction-breakdown__value" - value={gas} - /> - </TransactionBreakdownRow> - { - typeof gasUsed === 'string' && ( - <TransactionBreakdownRow - title={`${t('gasUsed')} (${t('units')})`} - className="transaction-breakdown__row-title" - > - <HexToDecimal - className="transaction-breakdown__value" - value={gasUsed} - /> - </TransactionBreakdownRow> - ) - } - <TransactionBreakdownRow title={t('gasPrice')}> - <CurrencyDisplay - className="transaction-breakdown__value" - currency={nativeCurrency} - denomination={GWEI} - value={gasPrice} - hideLabel - /> - </TransactionBreakdownRow> - <TransactionBreakdownRow title={t('total')}> - <div> - <UserPreferencedCurrencyDisplay - className="transaction-breakdown__value transaction-breakdown__value--eth-total" - type={PRIMARY} - value={totalInHex} - /> - <UserPreferencedCurrencyDisplay - className="transaction-breakdown__value" - type={SECONDARY} - value={totalInHex} - /> - </div> - </TransactionBreakdownRow> - </Card> - </div> - ) - } -} diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown.container.js b/ui/app/components/transaction-breakdown/transaction-breakdown.container.js deleted file mode 100644 index ed2708e03..000000000 --- a/ui/app/components/transaction-breakdown/transaction-breakdown.container.js +++ /dev/null @@ -1,11 +0,0 @@ -import { connect } from 'react-redux' -import TransactionBreakdown from './transaction-breakdown.component' -import { getNativeCurrency } from '../../selectors' - -const mapStateToProps = (state) => { - return { - nativeCurrency: getNativeCurrency(state), - } -} - -export default connect(mapStateToProps)(TransactionBreakdown) diff --git a/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js b/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js deleted file mode 100644 index a4f28fd63..000000000 --- a/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js +++ /dev/null @@ -1,111 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import SenderToRecipient from '../sender-to-recipient' -import { CARDS_VARIANT } from '../sender-to-recipient/sender-to-recipient.constants' -import TransactionActivityLog from '../transaction-activity-log' -import TransactionBreakdown from '../transaction-breakdown' -import Button from '../button' -import Tooltip from '../tooltip' -import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' - -export default class TransactionListItemDetails extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - onCancel: PropTypes.func, - onRetry: PropTypes.func, - showCancel: PropTypes.bool, - showRetry: PropTypes.bool, - transaction: PropTypes.object, - } - - handleEtherscanClick = () => { - const { hash, metamaskNetworkId } = this.props.transaction - - const prefix = prefixForNetwork(metamaskNetworkId) - const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` - global.platform.openWindow({ url: etherscanUrl }) - this.setState({ showTransactionDetails: true }) - } - - handleCancel = event => { - const { onCancel } = this.props - - event.stopPropagation() - onCancel() - } - - handleRetry = event => { - const { onRetry } = this.props - - event.stopPropagation() - onRetry() - } - - render () { - const { t } = this.context - const { transaction, showCancel, showRetry } = this.props - const { txParams: { to, from } = {} } = transaction - - return ( - <div className="transaction-list-item-details"> - <div className="transaction-list-item-details__header"> - <div>Details</div> - <div className="transaction-list-item-details__header-buttons"> - { - showRetry && ( - <Button - type="raised" - onClick={this.handleRetry} - className="transaction-list-item-details__header-button" - > - { t('speedUp') } - </Button> - ) - } - { - showCancel && ( - <Button - type="raised" - onClick={this.handleCancel} - className="transaction-list-item-details__header-button" - > - { t('cancel') } - </Button> - ) - } - <Tooltip title={t('viewOnEtherscan')}> - <Button - type="raised" - onClick={this.handleEtherscanClick} - className="transaction-list-item-details__header-button" - > - <img src="/images/arrow-popout.svg" /> - </Button> - </Tooltip> - </div> - </div> - <div className="transaction-list-item-details__sender-to-recipient-container"> - <SenderToRecipient - variant={CARDS_VARIANT} - addressOnly - recipientAddress={to} - senderAddress={from} - /> - </div> - <div className="transaction-list-item-details__cards-container"> - <TransactionBreakdown - transaction={transaction} - className="transaction-list-item-details__transaction-breakdown" - /> - <TransactionActivityLog - transaction={transaction} - className="transaction-list-item-details__transaction-activity-log" - /> - </div> - </div> - ) - } -} diff --git a/ui/app/components/transaction-list-item/transaction-list-item.container.js b/ui/app/components/transaction-list-item/transaction-list-item.container.js deleted file mode 100644 index 62ed7a73f..000000000 --- a/ui/app/components/transaction-list-item/transaction-list-item.container.js +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' -import withMethodData from '../../higher-order-components/with-method-data' -import TransactionListItem from './transaction-list-item.component' -import { setSelectedToken, retryTransaction, showModal } from '../../actions' -import { hexToDecimal } from '../../helpers/conversions.util' -import { getTokenData } from '../../helpers/transactions.util' -import { formatDate } from '../../util' - -const mapStateToProps = (state, ownProps) => { - const { transaction: { txParams: { value, nonce, data } = {}, time } = {} } = ownProps - - const tokenData = data && getTokenData(data) - const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) - - return { - value, - nonceAndDate, - tokenData, - } -} - -const mapDispatchToProps = dispatch => { - return { - setSelectedToken: tokenAddress => dispatch(setSelectedToken(tokenAddress)), - retryTransaction: transactionId => dispatch(retryTransaction(transactionId)), - showCancelModal: (transactionId, originalGasPrice) => { - return dispatch(showModal({ name: 'CANCEL_TRANSACTION', transactionId, originalGasPrice })) - }, - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps), - withMethodData, -)(TransactionListItem) diff --git a/ui/app/components/transaction-view-balance/transaction-view-balance.component.js b/ui/app/components/transaction-view-balance/transaction-view-balance.component.js deleted file mode 100644 index 402b0f486..000000000 --- a/ui/app/components/transaction-view-balance/transaction-view-balance.component.js +++ /dev/null @@ -1,98 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Button from '../button' -import Identicon from '../identicon' -import TokenBalance from '../token-balance' -import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' -import { SEND_ROUTE } from '../../routes' -import { PRIMARY, SECONDARY } from '../../constants/common' - -export default class TransactionViewBalance extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - showDepositModal: PropTypes.func, - selectedToken: PropTypes.object, - history: PropTypes.object, - network: PropTypes.string, - balance: PropTypes.string, - assetImage: PropTypes.string, - } - - renderBalance () { - const { selectedToken, balance } = this.props - - return selectedToken - ? ( - <TokenBalance - token={selectedToken} - withSymbol - className="transaction-view-balance__token-balance" - /> - ) : ( - <div className="transaction-view-balance__balance"> - <UserPreferencedCurrencyDisplay - className="transaction-view-balance__primary-balance" - value={balance} - type={PRIMARY} - ethNumberOfDecimals={4} - /> - <UserPreferencedCurrencyDisplay - className="transaction-view-balance__secondary-balance" - value={balance} - type={SECONDARY} - ethNumberOfDecimals={4} - /> - </div> - ) - } - - renderButtons () { - const { t } = this.context - const { selectedToken, showDepositModal, history } = this.props - - return ( - <div className="transaction-view-balance__buttons"> - { - !selectedToken && ( - <Button - type="primary" - className="transaction-view-balance__button" - onClick={() => showDepositModal()} - > - { t('deposit') } - </Button> - ) - } - <Button - type="primary" - className="transaction-view-balance__button" - onClick={() => history.push(SEND_ROUTE)} - > - { t('send') } - </Button> - </div> - ) - } - - render () { - const { network, selectedToken, assetImage } = this.props - - return ( - <div className="transaction-view-balance"> - <div className="transaction-view-balance__balance-container"> - <Identicon - diameter={50} - address={selectedToken && selectedToken.address} - network={network} - image={assetImage} - /> - { this.renderBalance() } - </div> - { this.renderButtons() } - </div> - ) - } -} diff --git a/ui/app/components/ui/account-dropdown-mini/account-dropdown-mini.component.js b/ui/app/components/ui/account-dropdown-mini/account-dropdown-mini.component.js new file mode 100644 index 000000000..8abe1ab18 --- /dev/null +++ b/ui/app/components/ui/account-dropdown-mini/account-dropdown-mini.component.js @@ -0,0 +1,84 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import AccountListItem from '../../app/send/account-list-item/account-list-item.component' + +export default class AccountDropdownMini extends PureComponent { + static propTypes = { + accounts: PropTypes.array.isRequired, + closeDropdown: PropTypes.func, + disabled: PropTypes.bool, + dropdownOpen: PropTypes.bool, + onSelect: PropTypes.func, + openDropdown: PropTypes.func, + selectedAccount: PropTypes.object.isRequired, + } + + static defaultProps = { + closeDropdown: () => {}, + disabled: false, + dropdownOpen: false, + onSelect: () => {}, + openDropdown: () => {}, + } + + getListItemIcon (currentAccount, selectedAccount) { + return currentAccount.address === selectedAccount.address && ( + <i + className="fa fa-check fa-lg" + style={{ color: '#02c9b1' }} + /> + ) + } + + renderDropdown () { + const { accounts, selectedAccount, closeDropdown, onSelect } = this.props + + return ( + <div> + <div + className="account-dropdown-mini__close-area" + onClick={closeDropdown} + /> + <div className="account-dropdown-mini__list"> + { + accounts.map(account => ( + <AccountListItem + key={account.address} + account={account} + displayBalance={false} + displayAddress={false} + handleClick={() => { + onSelect(account) + closeDropdown() + }} + icon={this.getListItemIcon(account, selectedAccount)} + /> + )) + } + </div> + </div> + ) + } + + render () { + const { disabled, selectedAccount, openDropdown, dropdownOpen } = this.props + + return ( + <div className="account-dropdown-mini"> + <AccountListItem + account={selectedAccount} + handleClick={() => !disabled && openDropdown()} + displayBalance={false} + displayAddress={false} + icon={ + !disabled && <i + className="fa fa-caret-down fa-lg" + style={{ color: '#dedede' }} + /> + } + /> + { !disabled && dropdownOpen && this.renderDropdown() } + </div> + ) + } +} diff --git a/ui/app/components/ui/account-dropdown-mini/index.js b/ui/app/components/ui/account-dropdown-mini/index.js new file mode 100644 index 000000000..cb0839e72 --- /dev/null +++ b/ui/app/components/ui/account-dropdown-mini/index.js @@ -0,0 +1 @@ +export { default } from './account-dropdown-mini.component' diff --git a/ui/app/components/ui/account-dropdown-mini/tests/account-dropdown-mini.component.test.js b/ui/app/components/ui/account-dropdown-mini/tests/account-dropdown-mini.component.test.js new file mode 100644 index 000000000..bc74ceb3c --- /dev/null +++ b/ui/app/components/ui/account-dropdown-mini/tests/account-dropdown-mini.component.test.js @@ -0,0 +1,107 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import AccountDropdownMini from '../account-dropdown-mini.component' +import AccountListItem from '../../../app/send/account-list-item/account-list-item.component' + +describe('AccountDropdownMini', () => { + it('should render an account with an icon', () => { + const accounts = [ + { + address: '0x1', + name: 'account1', + balance: '0x1', + }, + { + address: '0x2', + name: 'account2', + balance: '0x2', + }, + { + address: '0x3', + name: 'account3', + balance: '0x3', + }, + ] + + const wrapper = shallow( + <AccountDropdownMini + selectedAccount={{ address: '0x1', name: 'account1', balance: '0x1' }} + accounts={accounts} + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(AccountListItem).length, 1) + const accountListItemProps = wrapper.find(AccountListItem).at(0).props() + assert.equal(accountListItemProps.account.address, '0x1') + const iconProps = accountListItemProps.icon.props + assert.equal(iconProps.className, 'fa fa-caret-down fa-lg') + }) + + it('should render a list of accounts', () => { + const accounts = [ + { + address: '0x1', + name: 'account1', + balance: '0x1', + }, + { + address: '0x2', + name: 'account2', + balance: '0x2', + }, + { + address: '0x3', + name: 'account3', + balance: '0x3', + }, + ] + + const wrapper = shallow( + <AccountDropdownMini + selectedAccount={{ address: '0x1', name: 'account1', balance: '0x1' }} + accounts={accounts} + dropdownOpen={true} + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(AccountListItem).length, 4) + }) + + it('should render a single account when disabled', () => { + const accounts = [ + { + address: '0x1', + name: 'account1', + balance: '0x1', + }, + { + address: '0x2', + name: 'account2', + balance: '0x2', + }, + { + address: '0x3', + name: 'account3', + balance: '0x3', + }, + ] + + const wrapper = shallow( + <AccountDropdownMini + selectedAccount={{ address: '0x1', name: 'account1', balance: '0x1' }} + accounts={accounts} + dropdownOpen={false} + disabled={true} + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(AccountListItem).length, 1) + const accountListItemProps = wrapper.find(AccountListItem).at(0).props() + assert.equal(accountListItemProps.account.address, '0x1') + assert.equal(accountListItemProps.icon, false) + }) +}) diff --git a/ui/app/components/alert/index.js b/ui/app/components/ui/alert/index.js index 5620d847a..5620d847a 100644 --- a/ui/app/components/alert/index.js +++ b/ui/app/components/ui/alert/index.js diff --git a/ui/app/components/ui/balance/balance.component.js b/ui/app/components/ui/balance/balance.component.js new file mode 100644 index 000000000..9a6f71ce5 --- /dev/null +++ b/ui/app/components/ui/balance/balance.component.js @@ -0,0 +1,92 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import TokenBalance from '../token-balance' +import Identicon from '../identicon' +import UserPreferencedCurrencyDisplay from '../../app/user-preferenced-currency-display' +import { PRIMARY, SECONDARY } from '../../../helpers/constants/common' +import { formatBalance } from '../../../helpers/utils/util' + +export default class Balance extends PureComponent { + static propTypes = { + account: PropTypes.object, + assetImages: PropTypes.object, + nativeCurrency: PropTypes.string, + needsParse: PropTypes.bool, + network: PropTypes.string, + showFiat: PropTypes.bool, + token: PropTypes.object, + } + + static defaultProps = { + needsParse: true, + showFiat: true, + } + + renderBalance () { + const { account, nativeCurrency, needsParse, showFiat } = this.props + const balanceValue = account && account.balance + const formattedBalance = balanceValue + ? formatBalance(balanceValue, 6, needsParse, nativeCurrency) + : '...' + + if (formattedBalance === 'None' || formattedBalance === '...') { + return ( + <div className="flex-column balance-display"> + <div className="token-amount"> + { formattedBalance } + </div> + </div> + ) + } + + return ( + <div className="flex-column balance-display"> + <UserPreferencedCurrencyDisplay + className="token-amount" + value={balanceValue} + type={PRIMARY} + ethNumberOfDecimals={4} + /> + { + showFiat && ( + <UserPreferencedCurrencyDisplay + value={balanceValue} + type={SECONDARY} + ethNumberOfDecimals={4} + /> + ) + } + </div> + ) + } + + renderTokenBalance () { + const { token } = this.props + + return ( + <div className="flex-column balance-display"> + <div className="token-amount"> + <TokenBalance token={token} /> + </div> + </div> + ) + } + + render () { + const { token, network, assetImages } = this.props + const address = token && token.address + const image = assetImages && address ? assetImages[token.address] : undefined + + return ( + <div className="balance-container"> + <Identicon + diameter={50} + address={address} + network={network} + image={image} + /> + { token ? this.renderTokenBalance() : this.renderBalance() } + </div> + ) + } +} diff --git a/ui/app/components/ui/balance/balance.container.js b/ui/app/components/ui/balance/balance.container.js new file mode 100644 index 000000000..2ad5c5ad8 --- /dev/null +++ b/ui/app/components/ui/balance/balance.container.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux' +import Balance from './balance.component' +import { + getNativeCurrency, + getAssetImages, + conversionRateSelector, + getCurrentCurrency, + getMetaMaskAccounts, + getIsMainnet, + preferencesSelector, +} from '../../../selectors/selectors' + +const mapStateToProps = state => { + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const accounts = getMetaMaskAccounts(state) + const network = state.metamask.network + const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] + const account = accounts[selectedAddress] + + return { + account, + network, + nativeCurrency: getNativeCurrency(state), + conversionRate: conversionRateSelector(state), + currentCurrency: getCurrentCurrency(state), + assetImages: getAssetImages(state), + showFiat: (isMainnet || !!showFiatInTestnets), + } +} + +export default connect(mapStateToProps)(Balance) diff --git a/ui/app/components/ui/balance/index.js b/ui/app/components/ui/balance/index.js new file mode 100644 index 000000000..f8fb9ea19 --- /dev/null +++ b/ui/app/components/ui/balance/index.js @@ -0,0 +1 @@ +export { default } from './balance.container' diff --git a/ui/app/components/ui/breadcrumbs/breadcrumbs.component.js b/ui/app/components/ui/breadcrumbs/breadcrumbs.component.js new file mode 100644 index 000000000..6644836db --- /dev/null +++ b/ui/app/components/ui/breadcrumbs/breadcrumbs.component.js @@ -0,0 +1,29 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +export default class Breadcrumbs extends PureComponent { + static propTypes = { + className: PropTypes.string, + currentIndex: PropTypes.number, + total: PropTypes.number, + } + + render () { + const { className, currentIndex, total } = this.props + + return ( + <div className={classnames('breadcrumbs', className)}> + { + Array(total).fill().map((_, i) => ( + <div + key={i} + className="breadcrumb" + style={{backgroundColor: i === currentIndex ? '#D8D8D8' : '#FFFFFF'}} + /> + )) + } + </div> + ) + } +} diff --git a/ui/app/components/ui/breadcrumbs/index.js b/ui/app/components/ui/breadcrumbs/index.js new file mode 100644 index 000000000..07a11574f --- /dev/null +++ b/ui/app/components/ui/breadcrumbs/index.js @@ -0,0 +1 @@ +export { default } from './breadcrumbs.component' diff --git a/ui/app/components/ui/breadcrumbs/index.scss b/ui/app/components/ui/breadcrumbs/index.scss new file mode 100644 index 000000000..e23aa7970 --- /dev/null +++ b/ui/app/components/ui/breadcrumbs/index.scss @@ -0,0 +1,15 @@ +.breadcrumbs { + display: flex; + flex-flow: row nowrap; +} + +.breadcrumb { + height: 10px; + width: 10px; + border: 1px solid #979797; + border-radius: 50%; +} + +.breadcrumb + .breadcrumb { + margin-left: 10px; +} diff --git a/ui/app/components/ui/breadcrumbs/tests/breadcrumbs.component.test.js b/ui/app/components/ui/breadcrumbs/tests/breadcrumbs.component.test.js new file mode 100644 index 000000000..5013c5b60 --- /dev/null +++ b/ui/app/components/ui/breadcrumbs/tests/breadcrumbs.component.test.js @@ -0,0 +1,22 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import Breadcrumbs from '../breadcrumbs.component' + +describe('Breadcrumbs Component', () => { + it('should render with the correct colors', () => { + const wrapper = shallow( + <Breadcrumbs + currentIndex={1} + total={3} + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find('.breadcrumbs').length, 1) + assert.equal(wrapper.find('.breadcrumb').length, 3) + assert.equal(wrapper.find('.breadcrumb').at(0).props().style['backgroundColor'], '#FFFFFF') + assert.equal(wrapper.find('.breadcrumb').at(1).props().style['backgroundColor'], '#D8D8D8') + assert.equal(wrapper.find('.breadcrumb').at(2).props().style['backgroundColor'], '#FFFFFF') + }) +}) diff --git a/ui/app/components/button-group/button-group.component.js b/ui/app/components/ui/button-group/button-group.component.js index f99f710ce..17a281030 100644 --- a/ui/app/components/button-group/button-group.component.js +++ b/ui/app/components/ui/button-group/button-group.component.js @@ -5,18 +5,30 @@ import classnames from 'classnames' export default class ButtonGroup extends PureComponent { static propTypes = { defaultActiveButtonIndex: PropTypes.number, + noButtonActiveByDefault: PropTypes.bool, disabled: PropTypes.bool, children: PropTypes.array, className: PropTypes.string, style: PropTypes.object, + newActiveButtonIndex: PropTypes.number, } static defaultProps = { className: 'button-group', + defaultActiveButtonIndex: 0, } state = { - activeButtonIndex: this.props.defaultActiveButtonIndex || 0, + activeButtonIndex: this.props.noButtonActiveByDefault + ? null + : this.props.defaultActiveButtonIndex, + } + + componentDidUpdate (_, prevState) { + // Provides an API for dynamically updating the activeButtonIndex + if (typeof this.props.newActiveButtonIndex === 'number' && prevState.activeButtonIndex !== this.props.newActiveButtonIndex) { + this.setState({ activeButtonIndex: this.props.newActiveButtonIndex }) + } } handleButtonClick (activeButtonIndex) { diff --git a/ui/app/components/button-group/button-group.stories.js b/ui/app/components/ui/button-group/button-group.stories.js index 14e1a7e49..c58c628b3 100644 --- a/ui/app/components/button-group/button-group.stories.js +++ b/ui/app/components/ui/button-group/button-group.stories.js @@ -1,7 +1,7 @@ import React from 'react' import { storiesOf } from '@storybook/react' import { action } from '@storybook/addon-actions' -import ButtonGroup from './' +import ButtonGroup from '.' import Button from '../button' import { text, boolean } from '@storybook/addon-knobs/react' diff --git a/ui/app/components/button-group/index.js b/ui/app/components/ui/button-group/index.js index df470bd57..df470bd57 100644 --- a/ui/app/components/button-group/index.js +++ b/ui/app/components/ui/button-group/index.js diff --git a/ui/app/components/button-group/index.scss b/ui/app/components/ui/button-group/index.scss index 29713c75b..29713c75b 100644 --- a/ui/app/components/button-group/index.scss +++ b/ui/app/components/ui/button-group/index.scss diff --git a/ui/app/components/button-group/tests/button-group-component.test.js b/ui/app/components/ui/button-group/tests/button-group-component.test.js index f07bb97c8..0bece90d6 100644 --- a/ui/app/components/button-group/tests/button-group-component.test.js +++ b/ui/app/components/ui/button-group/tests/button-group-component.test.js @@ -35,6 +35,20 @@ describe('ButtonGroup Component', function () { ButtonGroup.prototype.renderButtons.resetHistory() }) + describe('componentDidUpdate', () => { + it('should set the activeButtonIndex to the updated newActiveButtonIndex', () => { + assert.equal(wrapper.state('activeButtonIndex'), 1) + wrapper.setProps({ newActiveButtonIndex: 2 }) + assert.equal(wrapper.state('activeButtonIndex'), 2) + }) + + it('should not set the activeButtonIndex to an updated newActiveButtonIndex that is not a number', () => { + assert.equal(wrapper.state('activeButtonIndex'), 1) + wrapper.setProps({ newActiveButtonIndex: null }) + assert.equal(wrapper.state('activeButtonIndex'), 1) + }) + }) + describe('handleButtonClick', () => { it('should set the activeButtonIndex', () => { assert.equal(wrapper.state('activeButtonIndex'), 1) diff --git a/ui/app/components/button/button.component.js b/ui/app/components/ui/button/button.component.js index 4a06333e7..5d19219b4 100644 --- a/ui/app/components/button/button.component.js +++ b/ui/app/components/ui/button/button.component.js @@ -8,6 +8,7 @@ const CLASSNAME_SECONDARY = 'btn-secondary' const CLASSNAME_CONFIRM = 'btn-confirm' const CLASSNAME_RAISED = 'btn-raised' const CLASSNAME_LARGE = 'btn--large' +const CLASSNAME_FIRST_TIME = 'btn--first-time' const typeHash = { default: CLASSNAME_DEFAULT, @@ -15,6 +16,7 @@ const typeHash = { secondary: CLASSNAME_SECONDARY, confirm: CLASSNAME_CONFIRM, raised: CLASSNAME_RAISED, + 'first-time': CLASSNAME_FIRST_TIME, } export default class Button extends Component { @@ -22,7 +24,11 @@ export default class Button extends Component { type: PropTypes.string, large: PropTypes.bool, className: PropTypes.string, - children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.array, + PropTypes.element, + ]), } render () { diff --git a/ui/app/components/button/button.stories.js b/ui/app/components/ui/button/button.stories.js index dec084a25..667824a47 100644 --- a/ui/app/components/button/button.stories.js +++ b/ui/app/components/ui/button/button.stories.js @@ -1,7 +1,7 @@ import React from 'react' import { storiesOf } from '@storybook/react' import { action } from '@storybook/addon-actions' -import Button from './' +import Button from '.' import { text } from '@storybook/addon-knobs/react' storiesOf('Button', module) diff --git a/ui/app/components/button/index.js b/ui/app/components/ui/button/index.js index 33ae95ae2..33ae95ae2 100644 --- a/ui/app/components/button/index.js +++ b/ui/app/components/ui/button/index.js diff --git a/ui/app/components/card/card.component.js b/ui/app/components/ui/card/card.component.js index bb7241da1..bb7241da1 100644 --- a/ui/app/components/card/card.component.js +++ b/ui/app/components/ui/card/card.component.js diff --git a/ui/app/components/card/index.js b/ui/app/components/ui/card/index.js index c3ca6e3f4..c3ca6e3f4 100644 --- a/ui/app/components/card/index.js +++ b/ui/app/components/ui/card/index.js diff --git a/ui/app/components/card/index.scss b/ui/app/components/ui/card/index.scss index bde54a15e..bde54a15e 100644 --- a/ui/app/components/card/index.scss +++ b/ui/app/components/ui/card/index.scss diff --git a/ui/app/components/card/tests/card.component.test.js b/ui/app/components/ui/card/tests/card.component.test.js index cea05033f..cea05033f 100644 --- a/ui/app/components/card/tests/card.component.test.js +++ b/ui/app/components/ui/card/tests/card.component.test.js diff --git a/ui/app/components/copyButton.js b/ui/app/components/ui/copyButton.js index a60d33523..a60d33523 100644 --- a/ui/app/components/copyButton.js +++ b/ui/app/components/ui/copyButton.js diff --git a/ui/app/components/currency-display/currency-display.component.js b/ui/app/components/ui/currency-display/currency-display.component.js index 2d7413b57..04dd89892 100644 --- a/ui/app/components/currency-display/currency-display.component.js +++ b/ui/app/components/ui/currency-display/currency-display.component.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { GWEI } from '../../constants/common' +import { GWEI } from '../../../helpers/constants/common' export default class CurrencyDisplay extends PureComponent { static propTypes = { @@ -17,10 +17,11 @@ export default class CurrencyDisplay extends PureComponent { value: PropTypes.string, numberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), hideLabel: PropTypes.bool, + hideTitle: PropTypes.bool, } render () { - const { className, displayValue, prefix, prefixComponent, style, suffix } = this.props + const { className, displayValue, prefix, prefixComponent, style, suffix, hideTitle } = this.props const text = `${prefix || ''}${displayValue}` const title = `${text} ${suffix}` @@ -28,9 +29,9 @@ export default class CurrencyDisplay extends PureComponent { <div className={classnames('currency-display-component', className)} style={style} - title={title} + title={!hideTitle && title || null} > - { prefixComponent} + { prefixComponent } <span className="currency-display-component__text">{ text }</span> { suffix && ( diff --git a/ui/app/components/currency-display/currency-display.container.js b/ui/app/components/ui/currency-display/currency-display.container.js index 6ddf07172..093d99c8e 100644 --- a/ui/app/components/currency-display/currency-display.container.js +++ b/ui/app/components/ui/currency-display/currency-display.container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux' import CurrencyDisplay from './currency-display.component' -import { getValueFromWeiHex, formatCurrency } from '../../helpers/confirm-transaction/util' +import { getValueFromWeiHex, formatCurrency } from '../../../helpers/utils/confirm-tx.util' const mapStateToProps = state => { const { metamask: { nativeCurrency, currentCurrency, conversionRate } } = state @@ -20,15 +20,24 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { currency, denomination, hideLabel, + displayValue: propsDisplayValue, + suffix: propsSuffix, ...restOwnProps } = ownProps const toCurrency = currency || currentCurrency - const convertedValue = getValueFromWeiHex({ - value, fromCurrency: nativeCurrency, toCurrency, conversionRate, numberOfDecimals, toDenomination: denomination, - }) - const displayValue = formatCurrency(convertedValue, toCurrency) - const suffix = hideLabel ? undefined : toCurrency.toUpperCase() + + const displayValue = propsDisplayValue || formatCurrency( + getValueFromWeiHex({ + value, + fromCurrency: nativeCurrency, + toCurrency, conversionRate, + numberOfDecimals, + toDenomination: denomination, + }), + toCurrency + ) + const suffix = propsSuffix || (hideLabel ? undefined : toCurrency.toUpperCase()) return { ...restStateProps, diff --git a/ui/app/components/currency-display/index.js b/ui/app/components/ui/currency-display/index.js index 38f08765f..38f08765f 100644 --- a/ui/app/components/currency-display/index.js +++ b/ui/app/components/ui/currency-display/index.js diff --git a/ui/app/components/currency-display/index.scss b/ui/app/components/ui/currency-display/index.scss index 313c932b8..313c932b8 100644 --- a/ui/app/components/currency-display/index.scss +++ b/ui/app/components/ui/currency-display/index.scss diff --git a/ui/app/components/currency-display/tests/currency-display.component.test.js b/ui/app/components/ui/currency-display/tests/currency-display.component.test.js index d9ef052f1..d9ef052f1 100644 --- a/ui/app/components/currency-display/tests/currency-display.component.test.js +++ b/ui/app/components/ui/currency-display/tests/currency-display.component.test.js diff --git a/ui/app/components/currency-display/tests/currency-display.container.test.js b/ui/app/components/ui/currency-display/tests/currency-display.container.test.js index 0c886af50..9888c366e 100644 --- a/ui/app/components/currency-display/tests/currency-display.container.test.js +++ b/ui/app/components/ui/currency-display/tests/currency-display.container.test.js @@ -131,7 +131,7 @@ describe('CurrencyDisplay container', () => { }, result: { nativeCurrency: 'ETH', - displayValue: '1e-9', + displayValue: '0.000000001', suffix: undefined, }, }, diff --git a/ui/app/components/currency-input/currency-input.component.js b/ui/app/components/ui/currency-input/currency-input.component.js index 0761a75c5..b5be0972b 100644 --- a/ui/app/components/currency-input/currency-input.component.js +++ b/ui/app/components/ui/currency-input/currency-input.component.js @@ -2,8 +2,8 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import UnitInput from '../unit-input' import CurrencyDisplay from '../currency-display' -import { getValueFromWeiHex, getWeiHexFromDecimalValue } from '../../helpers/conversions.util' -import { ETH } from '../../constants/common' +import { getValueFromWeiHex, getWeiHexFromDecimalValue } from '../../../helpers/utils/conversions.util' +import { ETH } from '../../../helpers/constants/common' /** * Component that allows user to enter currency values as a number, and props receive a converted @@ -11,15 +11,21 @@ import { ETH } from '../../constants/common' * gets converted into a decimal value depending on the currency (ETH or Fiat). */ export default class CurrencyInput extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + static propTypes = { conversionRate: PropTypes.number, currentCurrency: PropTypes.string, nativeCurrency: PropTypes.string, onChange: PropTypes.func, onBlur: PropTypes.func, - suffix: PropTypes.string, useFiat: PropTypes.bool, + hideFiat: PropTypes.bool, value: PropTypes.string, + fiatSuffix: PropTypes.string, + nativeSuffix: PropTypes.string, } constructor (props) { @@ -31,6 +37,7 @@ export default class CurrencyInput extends PureComponent { this.state = { decimalValue, hexValue, + isSwapped: false, } } @@ -46,8 +53,8 @@ export default class CurrencyInput extends PureComponent { } getDecimalValue (props) { - const { value: hexValue, useFiat, currentCurrency, conversionRate } = props - const decimalValueString = useFiat + const { value: hexValue, currentCurrency, conversionRate } = props + const decimalValueString = this.shouldUseFiat() ? getValueFromWeiHex({ value: hexValue, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, }) @@ -58,10 +65,28 @@ export default class CurrencyInput extends PureComponent { return Number(decimalValueString) || 0 } + shouldUseFiat = () => { + const { useFiat, hideFiat } = this.props + const { isSwapped } = this.state || {} + + if (hideFiat) { + return false + } + + return isSwapped ? !useFiat : useFiat + } + + swap = () => { + const { isSwapped, decimalValue } = this.state + this.setState({isSwapped: !isSwapped}, () => { + this.handleChange(decimalValue) + }) + } + handleChange = decimalValue => { - const { useFiat, currentCurrency: fromCurrency, conversionRate, onChange } = this.props + const { currentCurrency: fromCurrency, conversionRate, onChange } = this.props - const hexValue = useFiat + const hexValue = this.shouldUseFiat() ? getWeiHexFromDecimalValue({ value: decimalValue, fromCurrency, conversionRate, invertConversionRate: true, }) @@ -78,11 +103,19 @@ export default class CurrencyInput extends PureComponent { } renderConversionComponent () { - const { useFiat, currentCurrency, nativeCurrency } = this.props + const { currentCurrency, nativeCurrency, hideFiat } = this.props const { hexValue } = this.state let currency, numberOfDecimals - if (useFiat) { + if (hideFiat) { + return ( + <div className="currency-input__conversion-component"> + { this.context.t('noConversionRateAvailable') } + </div> + ) + } + + if (this.shouldUseFiat()) { // Display ETH currency = nativeCurrency || ETH numberOfDecimals = 6 @@ -103,19 +136,25 @@ export default class CurrencyInput extends PureComponent { } render () { - const { suffix, ...restProps } = this.props + const { fiatSuffix, nativeSuffix, ...restProps } = this.props const { decimalValue } = this.state return ( - <UnitInput - {...restProps} - suffix={suffix} - onChange={this.handleChange} - onBlur={this.handleBlur} - value={decimalValue} - > - { this.renderConversionComponent() } - </UnitInput> + <UnitInput + {...restProps} + suffix={this.shouldUseFiat() ? fiatSuffix : nativeSuffix} + onChange={this.handleChange} + onBlur={this.handleBlur} + value={decimalValue} + actionComponent={( + <div + className="currency-input__swap-component" + onClick={this.swap} + /> + )} + > + { this.renderConversionComponent() } + </UnitInput> ) } } diff --git a/ui/app/components/currency-input/currency-input.container.js b/ui/app/components/ui/currency-input/currency-input.container.js index 1d1ed7b41..b5d7dfe6d 100644 --- a/ui/app/components/currency-input/currency-input.container.js +++ b/ui/app/components/ui/currency-input/currency-input.container.js @@ -1,27 +1,30 @@ import { connect } from 'react-redux' import CurrencyInput from './currency-input.component' -import { ETH } from '../../constants/common' +import { ETH } from '../../../helpers/constants/common' +import {getIsMainnet, preferencesSelector} from '../../../selectors/selectors' const mapStateToProps = state => { const { metamask: { nativeCurrency, currentCurrency, conversionRate } } = state + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) return { nativeCurrency, currentCurrency, conversionRate, + hideFiat: (!isMainnet && !showFiatInTestnets), } } const mergeProps = (stateProps, dispatchProps, ownProps) => { const { nativeCurrency, currentCurrency } = stateProps - const { useFiat } = ownProps - const suffix = useFiat ? currentCurrency.toUpperCase() : nativeCurrency || ETH return { ...stateProps, ...dispatchProps, ...ownProps, - suffix, + nativeSuffix: nativeCurrency || ETH, + fiatSuffix: currentCurrency.toUpperCase(), } } diff --git a/ui/app/components/currency-input/index.js b/ui/app/components/ui/currency-input/index.js index d8069fb67..d8069fb67 100644 --- a/ui/app/components/currency-input/index.js +++ b/ui/app/components/ui/currency-input/index.js diff --git a/ui/app/components/ui/currency-input/index.scss b/ui/app/components/ui/currency-input/index.scss new file mode 100644 index 000000000..f659f5b35 --- /dev/null +++ b/ui/app/components/ui/currency-input/index.scss @@ -0,0 +1,26 @@ +.currency-input { + &__conversion-component { + font-size: 12px; + line-height: 12px; + padding-left: 1px; + } + + &__swap-component { + flex: 0 0 auto; + height: 24px; + width: 24px; + background-image: url("images/icons/swap.svg"); + background-size: contain; + background-repeat: no-repeat; + cursor: pointer; + opacity: .4; + + &:hover { + opacity: .3; + } + + &:active { + opacity: .5; + } + } +} diff --git a/ui/app/components/currency-input/tests/currency-input.component.test.js b/ui/app/components/ui/currency-input/tests/currency-input.component.test.js index a33889f94..6d4612e3c 100644 --- a/ui/app/components/currency-input/tests/currency-input.component.test.js +++ b/ui/app/components/ui/currency-input/tests/currency-input.component.test.js @@ -1,4 +1,5 @@ import React from 'react' +import PropTypes from 'prop-types' import assert from 'assert' import { shallow, mount } from 'enzyme' import sinon from 'sinon' @@ -32,7 +33,8 @@ describe('CurrencyInput Component', () => { const wrapper = mount( <Provider store={store}> <CurrencyInput - suffix="ETH" + nativeSuffix="ETH" + fiatSuffix="USD" nativeCurrency="ETH" /> </Provider> @@ -58,7 +60,8 @@ describe('CurrencyInput Component', () => { <Provider store={store}> <CurrencyInput value="de0b6b3a7640000" - suffix="ETH" + fiatSuffix="USD" + nativeSuffix="ETH" nativeCurrency="ETH" currentCurrency="usd" conversionRate={231.06} @@ -90,7 +93,8 @@ describe('CurrencyInput Component', () => { <Provider store={store}> <CurrencyInput value="f602f2234d0ea" - suffix="USD" + fiatSuffix="USD" + nativeSuffix="ETH" useFiat nativeCurrency="ETH" currentCurrency="usd" @@ -108,6 +112,45 @@ describe('CurrencyInput Component', () => { assert.equal(wrapper.find('.unit-input__input').props().value, '1') assert.equal(wrapper.find('.currency-display-component').text(), '0.004328ETH') }) + + it('should render properly with a native value when hideFiat is true', () => { + const mockStore = { + metamask: { + nativeCurrency: 'ETH', + currentCurrency: 'usd', + conversionRate: 231.06, + }, + } + const store = configureMockStore()(mockStore) + + const wrapper = mount( + <Provider store={store}> + <CurrencyInput + value="f602f2234d0ea" + fiatSuffix="USD" + nativeSuffix="ETH" + useFiat + hideFiat={true} + nativeCurrency="ETH" + currentCurrency="usd" + conversionRate={231.06} + /> + </Provider>, + { + context: { t: str => str + '_t' }, + childContextTypes: { t: PropTypes.func }, + } + ) + + assert.ok(wrapper) + const currencyInputInstance = wrapper.find(CurrencyInput).at(0).instance() + assert.equal(currencyInputInstance.state.decimalValue, 0.004328) + assert.equal(currencyInputInstance.state.hexValue, 'f602f2234d0ea') + assert.equal(wrapper.find('.unit-input__suffix').length, 1) + assert.equal(wrapper.find('.unit-input__suffix').text(), 'ETH') + assert.equal(wrapper.find('.unit-input__input').props().value, '0.004328') + assert.equal(wrapper.find('.currency-input__conversion-component').text(), 'noConversionRateAvailable_t') + }) }) describe('handling actions', () => { @@ -247,5 +290,56 @@ describe('CurrencyInput Component', () => { assert.equal(currencyInputInstance.state('hexValue'), '1ec05e43e72400') assert.equal(currencyInputInstance.find(UnitInput).props().value, 2) }) + + it('should swap selected currency when swap icon is clicked', () => { + const mockStore = { + metamask: { + nativeCurrency: 'ETH', + currentCurrency: 'usd', + conversionRate: 231.06, + }, + } + const store = configureMockStore()(mockStore) + const wrapper = mount( + <Provider store={store}> + <CurrencyInput + onChange={handleChangeSpy} + onBlur={handleBlurSpy} + nativeSuffix="ETH" + fiatSuffix="USD" + nativeCurrency="ETH" + currentCurrency="usd" + conversionRate={231.06} + /> + </Provider> + ) + + assert.ok(wrapper) + assert.equal(handleChangeSpy.callCount, 0) + assert.equal(handleBlurSpy.callCount, 0) + + const currencyInputInstance = wrapper.find(CurrencyInput).at(0).instance() + assert.equal(currencyInputInstance.state.decimalValue, 0) + assert.equal(currencyInputInstance.state.hexValue, undefined) + assert.equal(wrapper.find('.currency-display-component').text(), '$0.00USD') + const input = wrapper.find('input') + assert.equal(input.props().value, 0) + + input.simulate('change', { target: { value: 1 } }) + assert.equal(handleChangeSpy.callCount, 1) + assert.ok(handleChangeSpy.calledWith('de0b6b3a7640000')) + assert.equal(wrapper.find('.currency-display-component').text(), '$231.06USD') + assert.equal(currencyInputInstance.state.decimalValue, 1) + assert.equal(currencyInputInstance.state.hexValue, 'de0b6b3a7640000') + + assert.equal(handleBlurSpy.callCount, 0) + input.simulate('blur') + assert.equal(handleBlurSpy.callCount, 1) + assert.ok(handleBlurSpy.calledWith('de0b6b3a7640000')) + + const swap = wrapper.find('.currency-input__swap-component') + swap.simulate('click') + assert.equal(wrapper.find('.currency-display-component').text(), '0.004328ETH') + }) }) }) diff --git a/ui/app/components/ui/currency-input/tests/currency-input.container.test.js b/ui/app/components/ui/currency-input/tests/currency-input.container.test.js new file mode 100644 index 000000000..6109d29b6 --- /dev/null +++ b/ui/app/components/ui/currency-input/tests/currency-input.container.test.js @@ -0,0 +1,170 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps, mergeProps + +proxyquire('../currency-input.container.js', { + 'react-redux': { + connect: (ms, md, mp) => { + mapStateToProps = ms + mergeProps = mp + return () => ({}) + }, + }, +}) + +describe('CurrencyInput container', () => { + describe('mapStateToProps()', () => { + const tests = [ + // Test # 1 + { + comment: 'should return correct props in mainnet', + mockState: { + metamask: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + }, + expected: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + hideFiat: false, + }, + }, + // Test # 2 + { + comment: 'should return correct props when not in mainnet and showFiatInTestnets is false', + mockState: { + metamask: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'rinkeby', + }, + }, + }, + expected: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + hideFiat: true, + }, + }, + // Test # 3 + { + comment: 'should return correct props when not in mainnet and showFiatInTestnets is true', + mockState: { + metamask: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'rinkeby', + }, + }, + }, + expected: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + hideFiat: false, + }, + }, + // Test # 4 + { + comment: 'should return correct props when in mainnet and showFiatInTestnets is true', + mockState: { + metamask: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'mainnet', + }, + }, + }, + expected: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + hideFiat: false, + }, + }, + ] + + tests.forEach(({ mockState, expected, comment }) => { + it(comment, () => assert.deepEqual(mapStateToProps(mockState), expected)) + }) + }) + + describe('mergeProps()', () => { + const tests = [ + // Test # 1 + { + comment: 'should return the correct props', + mock: { + stateProps: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + }, + dispatchProps: {}, + ownProps: {}, + }, + expected: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + // useFiat: true, + nativeSuffix: 'ETH', + fiatSuffix: 'USD', + }, + }, + // Test # 1 + { + comment: 'should return the correct props when useFiat is true', + mock: { + stateProps: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + }, + dispatchProps: {}, + ownProps: { useFiat: true }, + }, + expected: { + conversionRate: 280.45, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + useFiat: true, + nativeSuffix: 'ETH', + fiatSuffix: 'USD', + }, + }, + ] + + tests.forEach(({ mock: { stateProps, dispatchProps, ownProps }, expected, comment }) => { + it(comment, () => { + assert.deepEqual(mergeProps(stateProps, dispatchProps, ownProps), expected) + }) + }) + }) +}) diff --git a/ui/app/components/editable-label.js b/ui/app/components/ui/editable-label.js index eb41ec50c..eb41ec50c 100644 --- a/ui/app/components/editable-label.js +++ b/ui/app/components/ui/editable-label.js diff --git a/ui/app/components/error-message/error-message.component.js b/ui/app/components/ui/error-message/error-message.component.js index b4464c33b..b4464c33b 100644 --- a/ui/app/components/error-message/error-message.component.js +++ b/ui/app/components/ui/error-message/error-message.component.js diff --git a/ui/app/components/error-message/index.js b/ui/app/components/ui/error-message/index.js index 1c97a9955..1c97a9955 100644 --- a/ui/app/components/error-message/index.js +++ b/ui/app/components/ui/error-message/index.js diff --git a/ui/app/components/error-message/index.scss b/ui/app/components/ui/error-message/index.scss index 5915e21cf..5915e21cf 100644 --- a/ui/app/components/error-message/index.scss +++ b/ui/app/components/ui/error-message/index.scss diff --git a/ui/app/components/error-message/tests/error-message.component.test.js b/ui/app/components/ui/error-message/tests/error-message.component.test.js index 8c5347173..8c5347173 100644 --- a/ui/app/components/error-message/tests/error-message.component.test.js +++ b/ui/app/components/ui/error-message/tests/error-message.component.test.js diff --git a/ui/app/components/eth-balance.js b/ui/app/components/ui/eth-balance.js index 2f6395a2d..7d577b716 100644 --- a/ui/app/components/eth-balance.js +++ b/ui/app/components/ui/eth-balance.js @@ -5,7 +5,7 @@ const { inherits } = require('util') const { formatBalance, generateBalanceObject, -} = require('../util') +} = require('../../helpers/utils/util') const Tooltip = require('./tooltip.js') const FiatValue = require('./fiat-value.js') diff --git a/ui/app/components/export-text-container/export-text-container.component.js b/ui/app/components/ui/export-text-container/export-text-container.component.js index c2546fa9b..c632e8f26 100644 --- a/ui/app/components/export-text-container/export-text-container.component.js +++ b/ui/app/components/ui/export-text-container/export-text-container.component.js @@ -2,7 +2,7 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') const copyToClipboard = require('copy-to-clipboard') -const { exportAsFile } = require('../../util') +const { exportAsFile } = require('../../../helpers/utils/util') class ExportTextContainer extends Component { render () { diff --git a/ui/app/components/export-text-container/index.js b/ui/app/components/ui/export-text-container/index.js index b2864a717..b2864a717 100644 --- a/ui/app/components/export-text-container/index.js +++ b/ui/app/components/ui/export-text-container/index.js diff --git a/ui/app/components/export-text-container/index.scss b/ui/app/components/ui/export-text-container/index.scss index 975d62f70..975d62f70 100644 --- a/ui/app/components/export-text-container/index.scss +++ b/ui/app/components/ui/export-text-container/index.scss diff --git a/ui/app/components/fiat-value.js b/ui/app/components/ui/fiat-value.js index 56465fc9d..02111ba49 100644 --- a/ui/app/components/fiat-value.js +++ b/ui/app/components/ui/fiat-value.js @@ -1,7 +1,7 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits -const formatBalance = require('../util').formatBalance +const formatBalance = require('../../helpers/utils/util').formatBalance module.exports = FiatValue diff --git a/ui/app/components/hex-to-decimal/hex-to-decimal.component.js b/ui/app/components/ui/hex-to-decimal/hex-to-decimal.component.js index 6847a6919..f03aaf255 100644 --- a/ui/app/components/hex-to-decimal/hex-to-decimal.component.js +++ b/ui/app/components/ui/hex-to-decimal/hex-to-decimal.component.js @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import { hexToDecimal } from '../../helpers/conversions.util' +import { hexToDecimal } from '../../../helpers/utils/conversions.util' export default class HexToDecimal extends PureComponent { static propTypes = { diff --git a/ui/app/components/hex-to-decimal/index.js b/ui/app/components/ui/hex-to-decimal/index.js index 6e8567ca9..6e8567ca9 100644 --- a/ui/app/components/hex-to-decimal/index.js +++ b/ui/app/components/ui/hex-to-decimal/index.js diff --git a/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js b/ui/app/components/ui/hex-to-decimal/tests/hex-to-decimal.component.test.js index c98da9ad4..c98da9ad4 100644 --- a/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js +++ b/ui/app/components/ui/hex-to-decimal/tests/hex-to-decimal.component.test.js diff --git a/ui/app/components/identicon/identicon.component.js b/ui/app/components/ui/identicon/identicon.component.js index b892e5ae5..88521247c 100644 --- a/ui/app/components/identicon/identicon.component.js +++ b/ui/app/components/ui/identicon/identicon.component.js @@ -1,9 +1,9 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { toDataUrl } from '../../../lib/blockies' +import { toDataUrl } from '../../../../lib/blockies' import contractMap from 'eth-contract-metadata' -import { checksumAddress } from '../../../app/util' +import { checksumAddress } from '../../../helpers/utils/util' import Jazzicon from '../jazzicon' const getStyles = diameter => ( diff --git a/ui/app/components/identicon/identicon.container.js b/ui/app/components/ui/identicon/identicon.container.js index bc49bc18e..bc49bc18e 100644 --- a/ui/app/components/identicon/identicon.container.js +++ b/ui/app/components/ui/identicon/identicon.container.js diff --git a/ui/app/components/identicon/index.js b/ui/app/components/ui/identicon/index.js index 799c886f2..799c886f2 100644 --- a/ui/app/components/identicon/index.js +++ b/ui/app/components/ui/identicon/index.js diff --git a/ui/app/components/identicon/index.scss b/ui/app/components/ui/identicon/index.scss index 657afc48f..657afc48f 100644 --- a/ui/app/components/identicon/index.scss +++ b/ui/app/components/ui/identicon/index.scss diff --git a/ui/app/components/identicon/tests/identicon.component.test.js b/ui/app/components/ui/identicon/tests/identicon.component.test.js index 2944818f5..2944818f5 100644 --- a/ui/app/components/identicon/tests/identicon.component.test.js +++ b/ui/app/components/ui/identicon/tests/identicon.component.test.js diff --git a/ui/app/components/jazzicon/index.js b/ui/app/components/ui/jazzicon/index.js index bea900ab9..bea900ab9 100644 --- a/ui/app/components/jazzicon/index.js +++ b/ui/app/components/ui/jazzicon/index.js diff --git a/ui/app/components/jazzicon/jazzicon.component.js b/ui/app/components/ui/jazzicon/jazzicon.component.js index fcb1c59b1..3a17e446f 100644 --- a/ui/app/components/jazzicon/jazzicon.component.js +++ b/ui/app/components/ui/jazzicon/jazzicon.component.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types' import isNode from 'detect-node' import { findDOMNode } from 'react-dom' import jazzicon from 'jazzicon' -import iconFactoryGenerator from '../../../lib/icon-factory' +import iconFactoryGenerator from '../../../../lib/icon-factory' const iconFactory = iconFactoryGenerator(jazzicon) /** diff --git a/ui/app/components/loading-screen/index.js b/ui/app/components/ui/loading-screen/index.js index 191d953f7..191d953f7 100644 --- a/ui/app/components/loading-screen/index.js +++ b/ui/app/components/ui/loading-screen/index.js diff --git a/ui/app/components/loading-screen/loading-screen.component.js b/ui/app/components/ui/loading-screen/loading-screen.component.js index 6b843cfee..6b843cfee 100644 --- a/ui/app/components/loading-screen/loading-screen.component.js +++ b/ui/app/components/ui/loading-screen/loading-screen.component.js diff --git a/ui/app/components/ui/lock-icon/index.js b/ui/app/components/ui/lock-icon/index.js new file mode 100644 index 000000000..6b4df0e58 --- /dev/null +++ b/ui/app/components/ui/lock-icon/index.js @@ -0,0 +1 @@ +export { default } from './lock-icon.component' diff --git a/ui/app/components/ui/lock-icon/lock-icon.component.js b/ui/app/components/ui/lock-icon/lock-icon.component.js new file mode 100644 index 000000000..d010cb6b2 --- /dev/null +++ b/ui/app/components/ui/lock-icon/lock-icon.component.js @@ -0,0 +1,32 @@ +import React from 'react' + +export default function LockIcon (props) { + return ( + <svg + version="1.1" + id="Capa_1" + xmlns="http://www.w3.org/2000/svg" + xmlnsXlink="http://www.w3.org/1999/xlink" + x="0px" + y="0px" + width="401.998px" + height="401.998px" + viewBox="0 0 401.998 401.998" + style={{enableBackground: 'new 0 0 401.998 401.998'}} + xmlSpace="preserve" + {...props} + > + <g> + <path + d="M357.45,190.721c-5.331-5.33-11.8-7.993-19.417-7.993h-9.131v-54.821c0-35.022-12.559-65.093-37.685-90.218 + C266.093,12.563,236.025,0,200.998,0c-35.026,0-65.1,12.563-90.222,37.688C85.65,62.814,73.091,92.884,73.091,127.907v54.821 + h-9.135c-7.611,0-14.084,2.663-19.414,7.993c-5.33,5.326-7.994,11.799-7.994,19.417V374.59c0,7.611,2.665,14.086,7.994,19.417 + c5.33,5.325,11.803,7.991,19.414,7.991H338.04c7.617,0,14.085-2.663,19.417-7.991c5.325-5.331,7.994-11.806,7.994-19.417V210.135 + C365.455,202.523,362.782,196.051,357.45,190.721z M274.087,182.728H127.909v-54.821c0-20.175,7.139-37.402,21.414-51.675 + c14.277-14.275,31.501-21.411,51.678-21.411c20.179,0,37.399,7.135,51.677,21.411c14.271,14.272,21.409,31.5,21.409,51.675V182.728 + z" + /> + </g> + </svg> + ) +} diff --git a/ui/app/components/mascot.js b/ui/app/components/ui/mascot.js index 3b0d3e31b..3b0d3e31b 100644 --- a/ui/app/components/mascot.js +++ b/ui/app/components/ui/mascot.js diff --git a/ui/app/components/page-container/index.js b/ui/app/components/ui/page-container/index.js index 913b8c9c6..913b8c9c6 100644 --- a/ui/app/components/page-container/index.js +++ b/ui/app/components/ui/page-container/index.js diff --git a/ui/app/components/page-container/index.scss b/ui/app/components/ui/page-container/index.scss index ba1215e84..b71a3cb9d 100644 --- a/ui/app/components/page-container/index.scss +++ b/ui/app/components/ui/page-container/index.scss @@ -6,6 +6,7 @@ display: flex; flex-flow: column; border-radius: 8px; + overflow-y: auto; &__header { display: flex; @@ -41,6 +42,12 @@ justify-content: space-between; } + &__bottom { + flex: 1; + display: flex; + flex-direction: column; + } + &__footer { display: flex; flex-flow: column; @@ -194,10 +201,10 @@ .page-container { height: 100%; width: 100%; - overflow-y: auto; background-color: $white; border-radius: 0; flex: 1; + overflow-y: auto; } } diff --git a/ui/app/components/page-container/page-container-content.component.js b/ui/app/components/ui/page-container/page-container-content.component.js index a1d6988cc..a1d6988cc 100644 --- a/ui/app/components/page-container/page-container-content.component.js +++ b/ui/app/components/ui/page-container/page-container-content.component.js diff --git a/ui/app/components/page-container/page-container-footer/index.js b/ui/app/components/ui/page-container/page-container-footer/index.js index 7825c4520..7825c4520 100644 --- a/ui/app/components/page-container/page-container-footer/index.js +++ b/ui/app/components/ui/page-container/page-container-footer/index.js diff --git a/ui/app/components/page-container/page-container-footer/page-container-footer.component.js b/ui/app/components/ui/page-container/page-container-footer/page-container-footer.component.js index 773fe1f56..85b16cefe 100644 --- a/ui/app/components/page-container/page-container-footer/page-container-footer.component.js +++ b/ui/app/components/ui/page-container/page-container-footer/page-container-footer.component.js @@ -12,6 +12,7 @@ export default class PageContainerFooter extends Component { submitText: PropTypes.string, disabled: PropTypes.bool, submitButtonType: PropTypes.string, + hideCancel: PropTypes.bool, } static contextTypes = { @@ -27,20 +28,21 @@ export default class PageContainerFooter extends Component { submitText, disabled, submitButtonType, + hideCancel, } = this.props return ( <div className="page-container__footer"> <header> - <Button + {!hideCancel && <Button type="default" large className="page-container__footer-button" onClick={e => onCancel(e)} > { cancelText || this.context.t('cancel') } - </Button> + </Button>} <Button type={submitButtonType || 'primary'} diff --git a/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js b/ui/app/components/ui/page-container/page-container-footer/tests/page-container-footer.component.test.js index 64efabab0..64efabab0 100644 --- a/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js +++ b/ui/app/components/ui/page-container/page-container-footer/tests/page-container-footer.component.test.js diff --git a/ui/app/components/page-container/page-container-header/index.js b/ui/app/components/ui/page-container/page-container-header/index.js index b194af057..b194af057 100644 --- a/ui/app/components/page-container/page-container-header/index.js +++ b/ui/app/components/ui/page-container/page-container-header/index.js diff --git a/ui/app/components/page-container/page-container-header/page-container-header.component.js b/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js index a8458604e..08f9c7544 100644 --- a/ui/app/components/page-container/page-container-header/page-container-header.component.js +++ b/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js @@ -12,6 +12,7 @@ export default class PageContainerHeader extends Component { backButtonStyles: PropTypes.object, backButtonString: PropTypes.string, tabs: PropTypes.node, + headerCloseText: PropTypes.string, } renderTabs () { @@ -41,7 +42,7 @@ export default class PageContainerHeader extends Component { } render () { - const { title, subtitle, onClose, tabs } = this.props + const { title, subtitle, onClose, tabs, headerCloseText } = this.props return ( <div className={ @@ -66,10 +67,12 @@ export default class PageContainerHeader extends Component { } { - onClose && <div - className="page-container__header-close" - onClick={() => onClose()} - /> + onClose && headerCloseText + ? <div className="page-container__header-close-text" onClick={() => onClose()}>{ headerCloseText }</div> + : onClose && <div + className="page-container__header-close" + onClick={() => onClose()} + /> } { this.renderTabs() } diff --git a/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js b/ui/app/components/ui/page-container/page-container-header/tests/page-container-header.component.test.js index 59304b2bd..59304b2bd 100644 --- a/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js +++ b/ui/app/components/ui/page-container/page-container-header/tests/page-container-header.component.test.js diff --git a/ui/app/components/page-container/page-container.component.js b/ui/app/components/ui/page-container/page-container.component.js index 3a2274a29..45dfff517 100644 --- a/ui/app/components/page-container/page-container.component.js +++ b/ui/app/components/ui/page-container/page-container.component.js @@ -9,6 +9,7 @@ export default class PageContainer extends PureComponent { // PageContainerHeader props backButtonString: PropTypes.string, backButtonStyles: PropTypes.object, + headerCloseText: PropTypes.string, onBackButtonClick: PropTypes.func, onClose: PropTypes.func, showBackButton: PropTypes.bool, @@ -22,6 +23,7 @@ export default class PageContainer extends PureComponent { // PageContainerFooter props cancelText: PropTypes.string, disabled: PropTypes.bool, + hideCancel: PropTypes.bool, onCancel: PropTypes.func, onSubmit: PropTypes.func, submitText: PropTypes.string, @@ -58,7 +60,8 @@ export default class PageContainer extends PureComponent { renderActiveTabContent () { const { tabsComponent } = this.props - const { children } = tabsComponent.props + let { children } = tabsComponent.props + children = children.filter(child => child) const { activeTabIndex } = this.state return children[activeTabIndex] @@ -92,6 +95,8 @@ export default class PageContainer extends PureComponent { onSubmit, submitText, disabled, + headerCloseText, + hideCancel, } = this.props return ( @@ -105,17 +110,21 @@ export default class PageContainer extends PureComponent { backButtonStyles={backButtonStyles} backButtonString={backButtonString} tabs={this.renderTabs()} + headerCloseText={headerCloseText} /> - <div className="page-container__content"> - { this.renderContent() } + <div className="page-container__bottom"> + <div className="page-container__content"> + { this.renderContent() } + </div> + <PageContainerFooter + onCancel={onCancel} + cancelText={cancelText} + hideCancel={hideCancel} + onSubmit={onSubmit} + submitText={submitText} + disabled={disabled} + /> </div> - <PageContainerFooter - onCancel={onCancel} - cancelText={cancelText} - onSubmit={onSubmit} - submitText={submitText} - disabled={disabled} - /> </div> ) } diff --git a/ui/app/components/send/account-list-item/account-list-item.scss b/ui/app/components/ui/page-container/tests/page-container.component.test.js index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send/account-list-item/account-list-item.scss +++ b/ui/app/components/ui/page-container/tests/page-container.component.test.js diff --git a/ui/app/components/qr-code.js b/ui/app/components/ui/qr-code.js index d3242ddf5..351e072e2 100644 --- a/ui/app/components/qr-code.js +++ b/ui/app/components/ui/qr-code.js @@ -1,11 +1,11 @@ const Component = require('react').Component const h = require('react-hyperscript') -const qrCode = require('qrcode-npm').qrcode +const qrCode = require('qrcode-generator') const inherits = require('util').inherits const connect = require('react-redux').connect const { isHexPrefixed } = require('ethereumjs-util') const ReadOnlyInput = require('./readonly-input') -const { checksumAddress } = require('../util') +const { checksumAddress } = require('../../helpers/utils/util') module.exports = connect(mapStateToProps)(QrCodeView) @@ -25,8 +25,8 @@ function QrCodeView () { QrCodeView.prototype.render = function () { const props = this.props - const { message, data } = props.Qr - const address = `${isHexPrefixed(data) ? 'ethereum:' : ''}${checksumAddress(data)}` + const { message, data, network } = props.Qr + const address = `${isHexPrefixed(data) ? 'ethereum:' : ''}${checksumAddress(data, network)}` const qrImage = qrCode(4, 'M') qrImage.addData(address) qrImage.make() @@ -51,7 +51,7 @@ QrCodeView.prototype.render = function () { h(ReadOnlyInput, { wrapperClass: 'ellip-address-wrapper', inputClass: 'qr-ellip-address', - value: checksumAddress(data), + value: checksumAddress(data, network), }), ]) } diff --git a/ui/app/components/readonly-input.js b/ui/app/components/ui/readonly-input.js index fcf05fb9e..fcf05fb9e 100644 --- a/ui/app/components/readonly-input.js +++ b/ui/app/components/ui/readonly-input.js diff --git a/ui/app/components/sender-to-recipient/index.js b/ui/app/components/ui/sender-to-recipient/index.js index f515c4ac4..f515c4ac4 100644 --- a/ui/app/components/sender-to-recipient/index.js +++ b/ui/app/components/ui/sender-to-recipient/index.js diff --git a/ui/app/components/sender-to-recipient/index.scss b/ui/app/components/ui/sender-to-recipient/index.scss index 0ab0413be..b21e4e1bb 100644 --- a/ui/app/components/sender-to-recipient/index.scss +++ b/ui/app/components/ui/sender-to-recipient/index.scss @@ -1,12 +1,13 @@ .sender-to-recipient { + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + position: relative; + flex: 0 0 auto; + &--default { - width: 100%; - display: flex; - flex-direction: row; - justify-content: center; border-bottom: 1px solid $geyser; - position: relative; - flex: 0 0 auto; height: 42px; .sender-to-recipient { @@ -74,13 +75,6 @@ } &--cards { - width: 100%; - display: flex; - flex-direction: row; - justify-content: center; - position: relative; - flex: 0 0 auto; - .sender-to-recipient { &__party { display: flex; @@ -117,4 +111,39 @@ } } } + + &--flat { + .sender-to-recipient { + &__party { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex: 1; + padding: 6px; + cursor: pointer; + min-width: 0; + color: $dusty-gray; + } + + &__tooltip-wrapper { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: .6875rem; + } + + &__arrow-container { + display: flex; + justify-content: center; + align-items: center; + } + } + } } diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js index e71bd7406..57b595d48 100644 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js +++ b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js @@ -4,12 +4,13 @@ import classnames from 'classnames' import Identicon from '../identicon' import Tooltip from '../tooltip-v2' import copyToClipboard from 'copy-to-clipboard' -import { DEFAULT_VARIANT, CARDS_VARIANT } from './sender-to-recipient.constants' -import { checksumAddress } from '../../util' +import { DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT } from './sender-to-recipient.constants' +import { checksumAddress } from '../../../helpers/utils/util' const variantHash = { [DEFAULT_VARIANT]: 'sender-to-recipient--default', [CARDS_VARIANT]: 'sender-to-recipient--cards', + [FLAT_VARIANT]: 'sender-to-recipient--flat', } export default class SenderToRecipient extends PureComponent { @@ -19,9 +20,11 @@ export default class SenderToRecipient extends PureComponent { recipientName: PropTypes.string, recipientAddress: PropTypes.string, t: PropTypes.func, - variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT]), + variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT]), addressOnly: PropTypes.bool, assetImage: PropTypes.string, + onRecipientClick: PropTypes.func, + onSenderClick: PropTypes.func, } static defaultProps = { @@ -85,7 +88,7 @@ export default class SenderToRecipient extends PureComponent { renderRecipientWithAddress () { const { t } = this.context - const { recipientName, recipientAddress, addressOnly } = this.props + const { recipientName, recipientAddress, addressOnly, onRecipientClick } = this.props const checksummedRecipientAddress = checksumAddress(recipientAddress) return ( @@ -94,6 +97,9 @@ export default class SenderToRecipient extends PureComponent { onClick={() => { this.setState({ recipientAddressCopied: true }) copyToClipboard(checksummedRecipientAddress) + if (onRecipientClick) { + onRecipientClick() + } }} > { this.renderRecipientIdenticon() } @@ -128,16 +134,9 @@ export default class SenderToRecipient extends PureComponent { } renderArrow () { - return this.props.variant === CARDS_VARIANT + return this.props.variant === DEFAULT_VARIANT ? ( <div className="sender-to-recipient__arrow-container"> - <img - height={20} - src="./images/caret-right.svg" - /> - </div> - ) : ( - <div className="sender-to-recipient__arrow-container"> <div className="sender-to-recipient__arrow-circle"> <img height={15} @@ -146,20 +145,30 @@ export default class SenderToRecipient extends PureComponent { /> </div> </div> + ) : ( + <div className="sender-to-recipient__arrow-container"> + <img + height={20} + src="./images/caret-right.svg" + /> + </div> ) } render () { - const { senderAddress, recipientAddress, variant } = this.props + const { senderAddress, recipientAddress, variant, onSenderClick } = this.props const checksummedSenderAddress = checksumAddress(senderAddress) return ( - <div className={classnames(variantHash[variant])}> + <div className={classnames('sender-to-recipient', variantHash[variant])}> <div className={classnames('sender-to-recipient__party sender-to-recipient__party--sender')} onClick={() => { this.setState({ senderAddressCopied: true }) copyToClipboard(checksummedSenderAddress) + if (onSenderClick) { + onSenderClick() + } }} > { this.renderSenderIdenticon() } diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.constants.js index 166228932..f53a5115d 100644 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js +++ b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.constants.js @@ -1,3 +1,4 @@ // Component design variants export const DEFAULT_VARIANT = 'DEFAULT_VARIANT' export const CARDS_VARIANT = 'CARDS_VARIANT' +export const FLAT_VARIANT = 'FLAT_VARIANT' diff --git a/ui/app/components/spinner/index.js b/ui/app/components/ui/spinner/index.js index 9589efcf0..9589efcf0 100644 --- a/ui/app/components/spinner/index.js +++ b/ui/app/components/ui/spinner/index.js diff --git a/ui/app/components/spinner/spinner.component.js b/ui/app/components/ui/spinner/spinner.component.js index b9a2eb52a..b9a2eb52a 100644 --- a/ui/app/components/spinner/spinner.component.js +++ b/ui/app/components/ui/spinner/spinner.component.js diff --git a/ui/app/components/tabs/index.js b/ui/app/components/ui/tabs/index.js index 3a8d18248..3a8d18248 100644 --- a/ui/app/components/tabs/index.js +++ b/ui/app/components/ui/tabs/index.js diff --git a/ui/app/components/tabs/index.scss b/ui/app/components/ui/tabs/index.scss index a3b42f8e3..25143ff35 100644 --- a/ui/app/components/tabs/index.scss +++ b/ui/app/components/ui/tabs/index.scss @@ -1,4 +1,4 @@ -@import './tab/index'; +@import 'tab/index'; .tabs { &__list { diff --git a/ui/app/components/tabs/tab/index.js b/ui/app/components/ui/tabs/tab/index.js index fbc309e8e..fbc309e8e 100644 --- a/ui/app/components/tabs/tab/index.js +++ b/ui/app/components/ui/tabs/tab/index.js diff --git a/ui/app/components/tabs/tab/index.scss b/ui/app/components/ui/tabs/tab/index.scss index 1de6ffa0e..1de6ffa0e 100644 --- a/ui/app/components/tabs/tab/index.scss +++ b/ui/app/components/ui/tabs/tab/index.scss diff --git a/ui/app/components/tabs/tab/tab.component.js b/ui/app/components/ui/tabs/tab/tab.component.js index 9e590391c..9e590391c 100644 --- a/ui/app/components/tabs/tab/tab.component.js +++ b/ui/app/components/ui/tabs/tab/tab.component.js diff --git a/ui/app/components/tabs/tabs.component.js b/ui/app/components/ui/tabs/tabs.component.js index d26dcff2f..d26dcff2f 100644 --- a/ui/app/components/tabs/tabs.component.js +++ b/ui/app/components/ui/tabs/tabs.component.js diff --git a/ui/app/components/text-field/index.js b/ui/app/components/ui/text-field/index.js index 171caf7a4..171caf7a4 100644 --- a/ui/app/components/text-field/index.js +++ b/ui/app/components/ui/text-field/index.js diff --git a/ui/app/components/text-field/text-field.component.js b/ui/app/components/ui/text-field/text-field.component.js index 2c72d8124..2c72d8124 100644 --- a/ui/app/components/text-field/text-field.component.js +++ b/ui/app/components/ui/text-field/text-field.component.js diff --git a/ui/app/components/text-field/text-field.stories.js b/ui/app/components/ui/text-field/text-field.stories.js index c00873b8a..337f78ecf 100644 --- a/ui/app/components/text-field/text-field.stories.js +++ b/ui/app/components/ui/text-field/text-field.stories.js @@ -1,6 +1,6 @@ import React from 'react' import { storiesOf } from '@storybook/react' -import TextField from './' +import TextField from '.' storiesOf('TextField', module) .add('text', () => diff --git a/ui/app/components/token-balance/index.js b/ui/app/components/ui/token-balance/index.js index f7da15cf8..f7da15cf8 100644 --- a/ui/app/components/token-balance/index.js +++ b/ui/app/components/ui/token-balance/index.js diff --git a/ui/app/components/ui/token-balance/index.scss b/ui/app/components/ui/token-balance/index.scss new file mode 100644 index 000000000..2ff6dfbc8 --- /dev/null +++ b/ui/app/components/ui/token-balance/index.scss @@ -0,0 +1,14 @@ +.token-balance-component { + display: flex; + align-items: center; + + &__text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__suffix { + padding-left: 4px; + } +} diff --git a/ui/app/components/token-balance/token-balance.component.js b/ui/app/components/ui/token-balance/token-balance.component.js index 2b4f73980..af1a32578 100644 --- a/ui/app/components/token-balance/token-balance.component.js +++ b/ui/app/components/ui/token-balance/token-balance.component.js @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import classnames from 'classnames' +import CurrencyDisplay from '../currency-display' export default class TokenBalance extends PureComponent { static propTypes = { @@ -12,12 +12,14 @@ export default class TokenBalance extends PureComponent { } render () { - const { className, string, withSymbol, symbol } = this.props + const { className, string, symbol } = this.props return ( - <div className={classnames('hide-text-overflow', className)}> - { string + (withSymbol ? ` ${symbol}` : '') } - </div> + <CurrencyDisplay + className={className} + displayValue={string} + suffix={symbol} + /> ) } } diff --git a/ui/app/components/token-balance/token-balance.container.js b/ui/app/components/ui/token-balance/token-balance.container.js index adc001f83..a0f1efc20 100644 --- a/ui/app/components/token-balance/token-balance.container.js +++ b/ui/app/components/ui/token-balance/token-balance.container.js @@ -1,8 +1,8 @@ import { connect } from 'react-redux' import { compose } from 'recompose' -import withTokenTracker from '../../higher-order-components/with-token-tracker' +import withTokenTracker from '../../../helpers/higher-order-components/with-token-tracker' import TokenBalance from './token-balance.component' -import selectors from '../../selectors' +import selectors from '../../../selectors/selectors' const mapStateToProps = state => { return { diff --git a/ui/app/components/token-currency-display/index.js b/ui/app/components/ui/token-currency-display/index.js index 6065cae1f..6065cae1f 100644 --- a/ui/app/components/token-currency-display/index.js +++ b/ui/app/components/ui/token-currency-display/index.js diff --git a/ui/app/components/token-currency-display/token-currency-display.component.js b/ui/app/components/ui/token-currency-display/token-currency-display.component.js index 4bb09a4b6..3c2722b36 100644 --- a/ui/app/components/token-currency-display/token-currency-display.component.js +++ b/ui/app/components/ui/token-currency-display/token-currency-display.component.js @@ -1,8 +1,8 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import CurrencyDisplay from '../currency-display/currency-display.component' -import { getTokenData } from '../../helpers/transactions.util' -import { getTokenValue, calcTokenAmount } from '../../token-util' +import CurrencyDisplay from '../currency-display' +import { getTokenData } from '../../../helpers/utils/transactions.util' +import { getTokenValue, calcTokenAmount } from '../../../helpers/utils/token-util' export default class TokenCurrencyDisplay extends PureComponent { static propTypes = { @@ -12,6 +12,7 @@ export default class TokenCurrencyDisplay extends PureComponent { state = { displayValue: '', + suffix: '', } componentDidMount () { @@ -29,25 +30,27 @@ export default class TokenCurrencyDisplay extends PureComponent { setDisplayValue () { const { transactionData: data, token } = this.props - const { decimals = '', symbol = '' } = token + const { decimals = '', symbol: suffix = '' } = token const tokenData = getTokenData(data) let displayValue - if (tokenData.params && tokenData.params.length) { + if (tokenData && tokenData.params && tokenData.params.length) { const tokenValue = getTokenValue(tokenData.params) - const tokenAmount = calcTokenAmount(tokenValue, decimals) - displayValue = `${tokenAmount} ${symbol}` + displayValue = calcTokenAmount(tokenValue, decimals).toString() } - this.setState({ displayValue }) + this.setState({ displayValue, suffix }) } render () { + const { displayValue, suffix } = this.state + return ( <CurrencyDisplay {...this.props} - displayValue={this.state.displayValue} + displayValue={displayValue} + suffix={suffix} /> ) } diff --git a/ui/app/components/token-input/index.js b/ui/app/components/ui/token-input/index.js index 22c06111e..22c06111e 100644 --- a/ui/app/components/token-input/index.js +++ b/ui/app/components/ui/token-input/index.js diff --git a/ui/app/components/token-input/tests/token-input.component.test.js b/ui/app/components/ui/token-input/tests/token-input.component.test.js index 2dacb9bc4..881101880 100644 --- a/ui/app/components/token-input/tests/token-input.component.test.js +++ b/ui/app/components/ui/token-input/tests/token-input.component.test.js @@ -159,6 +159,48 @@ describe('TokenInput Component', () => { assert.equal(wrapper.find('.unit-input__input').props().value, '1') assert.equal(wrapper.find('.currency-display-component').text(), '$462.12USD') }) + + it('should render properly with a token value for fiat, but hideConversion is true', () => { + const mockStore = { + metamask: { + currentCurrency: 'usd', + conversionRate: 231.06, + }, + } + const store = configureMockStore()(mockStore) + + const wrapper = mount( + <Provider store={store}> + <TokenInput + value="2710" + selectedToken={{ + address: '0x1', + decimals: '4', + symbol: 'ABC', + }} + suffix="ABC" + selectedTokenExchangeRate={2} + showFiat + hideConversion + /> + </Provider>, + { + context: { t }, + childContextTypes: { + t: PropTypes.func, + }, + }, + ) + + assert.ok(wrapper) + const tokenInputInstance = wrapper.find(TokenInput).at(0).instance() + assert.equal(tokenInputInstance.state.decimalValue, 1) + assert.equal(tokenInputInstance.state.hexValue, '2710') + assert.equal(wrapper.find('.unit-input__suffix').length, 1) + assert.equal(wrapper.find('.unit-input__suffix').text(), 'ABC') + assert.equal(wrapper.find('.unit-input__input').props().value, '1') + assert.equal(wrapper.find('.currency-input__conversion-component').text(), 'translate noConversionRateAvailable') + }) }) describe('handling actions', () => { diff --git a/ui/app/components/ui/token-input/tests/token-input.container.test.js b/ui/app/components/ui/token-input/tests/token-input.container.test.js new file mode 100644 index 000000000..2b1c102c8 --- /dev/null +++ b/ui/app/components/ui/token-input/tests/token-input.container.test.js @@ -0,0 +1,255 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps, mergeProps + +proxyquire('../token-input.container.js', { + 'react-redux': { + connect: (ms, md, mp) => { + mapStateToProps = ms + mergeProps = mp + return () => ({}) + }, + }, +}) + +describe('TokenInput container', () => { + describe('mapStateToProps()', () => { + it('should return the correct props when send is empty', () => { + const mockState = { + metamask: { + currentCurrency: 'usd', + tokens: [ + { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + ], + selectedTokenAddress: '0x1', + contractExchangeRates: {}, + send: {}, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { + currentCurrency: 'usd', + selectedToken: { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + selectedTokenExchangeRate: 0, + hideConversion: false, + }) + }) + + it('should return the correct props when selectedTokenAddress is not found and send is populated', () => { + const mockState = { + metamask: { + currentCurrency: 'usd', + tokens: [ + { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + ], + selectedTokenAddress: '0x2', + contractExchangeRates: {}, + send: { + token: { address: 'test' }, + }, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { + currentCurrency: 'usd', + selectedToken: { + address: 'test', + }, + selectedTokenExchangeRate: 0, + hideConversion: false, + }) + }) + + it('should return the correct props when contractExchangeRates is populated', () => { + const mockState = { + metamask: { + currentCurrency: 'usd', + tokens: [ + { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + ], + selectedTokenAddress: '0x1', + contractExchangeRates: { + '0x1': 5, + }, + send: {}, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { + currentCurrency: 'usd', + selectedToken: { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + selectedTokenExchangeRate: 5, + hideConversion: false, + }) + }) + + it('should return the correct props when not in mainnet and showFiatInTestnets is false', () => { + const mockState = { + metamask: { + currentCurrency: 'usd', + tokens: [ + { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + ], + selectedTokenAddress: '0x1', + contractExchangeRates: {}, + send: {}, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'rinkeby', + }, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { + currentCurrency: 'usd', + selectedToken: { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + selectedTokenExchangeRate: 0, + hideConversion: true, + }) + }) + + it('should return the correct props when not in mainnet and showFiatInTestnets is true', () => { + const mockState = { + metamask: { + currentCurrency: 'usd', + tokens: [ + { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + ], + selectedTokenAddress: '0x1', + contractExchangeRates: {}, + send: {}, + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'rinkeby', + }, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { + currentCurrency: 'usd', + selectedToken: { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + selectedTokenExchangeRate: 0, + hideConversion: false, + }) + }) + + it('should return the correct props when in mainnet and showFiatInTestnets is true', () => { + const mockState = { + metamask: { + currentCurrency: 'usd', + tokens: [ + { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + ], + selectedTokenAddress: '0x1', + contractExchangeRates: {}, + send: {}, + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'mainnet', + }, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { + currentCurrency: 'usd', + selectedToken: { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + selectedTokenExchangeRate: 0, + hideConversion: false, + }) + }) + }) + + describe('mergeProps()', () => { + it('should return the correct props', () => { + const mockStateProps = { + currentCurrency: 'usd', + selectedToken: { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + selectedTokenExchangeRate: 5, + } + + assert.deepEqual(mergeProps(mockStateProps, {}, {}), { + currentCurrency: 'usd', + selectedToken: { + address: '0x1', + decimals: '4', + symbol: 'ABC', + }, + selectedTokenExchangeRate: 5, + suffix: 'ABC', + }) + }) + }) +}) diff --git a/ui/app/components/token-input/token-input.component.js b/ui/app/components/ui/token-input/token-input.component.js index d1388945b..c28af5fde 100644 --- a/ui/app/components/token-input/token-input.component.js +++ b/ui/app/components/ui/token-input/token-input.component.js @@ -2,10 +2,10 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import UnitInput from '../unit-input' import CurrencyDisplay from '../currency-display' -import { getWeiHexFromDecimalValue } from '../../helpers/conversions.util' +import { getWeiHexFromDecimalValue } from '../../../helpers/utils/conversions.util' import ethUtil from 'ethereumjs-util' -import { conversionUtil, multiplyCurrencies } from '../../conversion-util' -import { ETH } from '../../constants/common' +import { conversionUtil, multiplyCurrencies } from '../../../helpers/utils/conversion-util' +import { ETH } from '../../../helpers/constants/common' /** * Component that allows user to enter token values as a number, and props receive a converted @@ -24,6 +24,7 @@ export default class TokenInput extends PureComponent { value: PropTypes.string, suffix: PropTypes.string, showFiat: PropTypes.bool, + hideConversion: PropTypes.bool, selectedToken: PropTypes.object, selectedTokenExchangeRate: PropTypes.number, } @@ -32,7 +33,7 @@ export default class TokenInput extends PureComponent { super(props) const { value: hexValue } = props - const decimalValue = hexValue ? this.getDecimalValue(props) : 0 + const decimalValue = hexValue ? this.getValue(props) : 0 this.state = { decimalValue, @@ -46,12 +47,12 @@ export default class TokenInput extends PureComponent { const { hexValue: stateHexValue } = this.state if (prevPropsHexValue !== propsHexValue && propsHexValue !== stateHexValue) { - const decimalValue = this.getDecimalValue(this.props) + const decimalValue = this.getValue(this.props) this.setState({ hexValue: propsHexValue, decimalValue }) } } - getDecimalValue (props) { + getValue (props) { const { value: hexValue, selectedToken: { decimals, symbol } = {} } = props const multiplier = Math.pow(10, Number(decimals || 0)) @@ -63,7 +64,7 @@ export default class TokenInput extends PureComponent { invertConversionRate: true, }) - return Number(decimalValueString) || 0 + return Number(decimalValueString) ? decimalValueString : '' } handleChange = decimalValue => { @@ -81,10 +82,18 @@ export default class TokenInput extends PureComponent { } renderConversionComponent () { - const { selectedTokenExchangeRate, showFiat, currentCurrency } = this.props + const { selectedTokenExchangeRate, showFiat, currentCurrency, hideConversion } = this.props const { decimalValue } = this.state let currency, numberOfDecimals + if (hideConversion) { + return ( + <div className="currency-input__conversion-component"> + { this.context.t('noConversionRateAvailable') } + </div> + ) + } + if (showFiat) { // Display Fiat currency = currentCurrency diff --git a/ui/app/components/token-input/token-input.container.js b/ui/app/components/ui/token-input/token-input.container.js index ec233b1b8..981cb3598 100644 --- a/ui/app/components/token-input/token-input.container.js +++ b/ui/app/components/ui/token-input/token-input.container.js @@ -1,14 +1,17 @@ import { connect } from 'react-redux' import TokenInput from './token-input.component' -import { getSelectedToken, getSelectedTokenExchangeRate } from '../../selectors' +import {getIsMainnet, getSelectedToken, getSelectedTokenExchangeRate, preferencesSelector} from '../../../selectors/selectors' const mapStateToProps = state => { const { metamask: { currentCurrency } } = state + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) return { currentCurrency, selectedToken: getSelectedToken(state), selectedTokenExchangeRate: getSelectedTokenExchangeRate(state), + hideConversion: (!isMainnet && !showFiatInTestnets), } } diff --git a/ui/app/components/tooltip-v2.js b/ui/app/components/ui/tooltip-v2.js index 054782203..b54026794 100644 --- a/ui/app/components/tooltip-v2.js +++ b/ui/app/components/ui/tooltip-v2.js @@ -20,6 +20,7 @@ export default class Tooltip extends PureComponent { arrow: PropTypes.bool, children: PropTypes.node, containerClassName: PropTypes.string, + disabled: PropTypes.bool, onHidden: PropTypes.func, position: PropTypes.oneOf([ 'top', @@ -33,10 +34,11 @@ export default class Tooltip extends PureComponent { title: PropTypes.string, trigger: PropTypes.any, wrapperClassName: PropTypes.string, + style: PropTypes.object, } render () { - const {arrow, children, containerClassName, position, size, title, trigger, onHidden, wrapperClassName } = this.props + const {arrow, children, containerClassName, disabled, position, size, title, trigger, onHidden, wrapperClassName, style } = this.props if (!title) { return ( @@ -50,6 +52,7 @@ export default class Tooltip extends PureComponent { <div className={wrapperClassName}> <ReactTippy className={containerClassName} + disabled={disabled} title={title} position={position} trigger={trigger} @@ -57,6 +60,7 @@ export default class Tooltip extends PureComponent { size={size} arrow={arrow} onHidden={onHidden} + style={style} > {children} </ReactTippy> diff --git a/ui/app/components/tooltip.js b/ui/app/components/ui/tooltip.js index efab2c497..efab2c497 100644 --- a/ui/app/components/tooltip.js +++ b/ui/app/components/ui/tooltip.js diff --git a/ui/app/components/unit-input/index.js b/ui/app/components/ui/unit-input/index.js index 7c33c9e5c..7c33c9e5c 100644 --- a/ui/app/components/unit-input/index.js +++ b/ui/app/components/ui/unit-input/index.js diff --git a/ui/app/components/unit-input/index.scss b/ui/app/components/ui/unit-input/index.scss index 28c5bf6f0..e4075d225 100644 --- a/ui/app/components/unit-input/index.scss +++ b/ui/app/components/ui/unit-input/index.scss @@ -1,4 +1,7 @@ .unit-input { + display: flex; + flex-flow: row nowrap; + align-items: center; min-height: 54px; border: 1px solid #dedede; border-radius: 4px; @@ -24,6 +27,10 @@ display: none; } + &__inputs { + flex: 1 0 auto; + } + &__input { color: #4d4d4d; font-size: 1rem; @@ -38,6 +45,10 @@ align-items: center; } + &__suffix { + margin-left: 3px; + } + &--error { border-color: $red; } diff --git a/ui/app/components/unit-input/tests/unit-input.component.test.js b/ui/app/components/ui/unit-input/tests/unit-input.component.test.js index 97d987bc7..97d987bc7 100644 --- a/ui/app/components/unit-input/tests/unit-input.component.test.js +++ b/ui/app/components/ui/unit-input/tests/unit-input.component.test.js diff --git a/ui/app/components/unit-input/unit-input.component.js b/ui/app/components/ui/unit-input/unit-input.component.js index f1ebf4d77..7b414f177 100644 --- a/ui/app/components/unit-input/unit-input.component.js +++ b/ui/app/components/ui/unit-input/unit-input.component.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { removeLeadingZeroes } from '../send/send.utils' +import { removeLeadingZeroes } from '../../app/send/send.utils' /** * Component that attaches a suffix or unit of measurement trailing user input, ex. 'ETH'. Also @@ -11,6 +11,7 @@ import { removeLeadingZeroes } from '../send/send.utils' export default class UnitInput extends PureComponent { static propTypes = { children: PropTypes.node, + actionComponent: PropTypes.node, error: PropTypes.bool, onBlur: PropTypes.func, onChange: PropTypes.func, @@ -66,11 +67,11 @@ export default class UnitInput extends PureComponent { const valueString = String(value) const valueLength = valueString.length || 1 const decimalPointDeficit = valueString.match(/\./) ? -0.5 : 0 - return (valueLength + decimalPointDeficit + 0.75) + 'ch' + return (valueLength + decimalPointDeficit + 0.5) + 'ch' } render () { - const { error, placeholder, suffix, children } = this.props + const { error, placeholder, suffix, actionComponent, children } = this.props const { value } = this.state return ( @@ -78,26 +79,29 @@ export default class UnitInput extends PureComponent { className={classnames('unit-input', { 'unit-input--error': error })} onClick={this.handleFocus} > - <div className="unit-input__input-container"> - <input - type="number" - className="unit-input__input" - value={value} - placeholder={placeholder} - onChange={this.handleChange} - onBlur={this.handleBlur} - style={{ width: this.getInputWidth(value) }} - ref={ref => { this.unitInput = ref }} - /> - { - suffix && ( - <div className="unit-input__suffix"> - { suffix } - </div> - ) - } + <div className="unit-input__inputs"> + <div className="unit-input__input-container"> + <input + type="number" + className="unit-input__input" + value={value} + placeholder={placeholder} + onChange={this.handleChange} + onBlur={this.handleBlur} + style={{ width: this.getInputWidth(value) }} + ref={ref => { this.unitInput = ref }} + /> + { + suffix && ( + <div className="unit-input__suffix"> + { suffix } + </div> + ) + } + </div> + { children } </div> - { children } + {actionComponent} </div> ) } diff --git a/ui/app/css/index.scss b/ui/app/css/index.scss index c068028f8..ffccbd64f 100644 --- a/ui/app/css/index.scss +++ b/ui/app/css/index.scss @@ -11,8 +11,6 @@ @import './itcss/generic/index.scss'; -@import './itcss/base/index.scss'; - @import './itcss/objects/index.scss'; @import './itcss/components/index.scss'; diff --git a/ui/app/css/itcss/base/index.scss b/ui/app/css/itcss/base/index.scss deleted file mode 100644 index 1475e8bb5..000000000 --- a/ui/app/css/itcss/base/index.scss +++ /dev/null @@ -1,7 +0,0 @@ -// Base - -.mouse-user-styles { - button:focus { - outline: 0; - } -} diff --git a/ui/app/css/itcss/components/account-dropdown.scss b/ui/app/css/itcss/components/account-dropdown.scss index b29afdc8c..716404cc3 100644 --- a/ui/app/css/itcss/components/account-dropdown.scss +++ b/ui/app/css/itcss/components/account-dropdown.scss @@ -24,6 +24,10 @@ position: relative; } + &__tooltip-wrapper { + left: -10px; + } + &__account-balances { height: auto; border: none; @@ -34,6 +38,24 @@ position: relative; } + &__primary-cached-container { + display: flex; + } + + &__cached-star { + margin-left: 4px; + } + + &__cached-balances { + div:first-of-type { + color: $web-orange; + } + + div:last-of-type { + color: rgba(220, 153, 18, 0.6901960784313725) + } + } + &__account-name { font-size: 16px; margin-left: 8px; @@ -52,6 +74,13 @@ font-size: 12px; } + &__balance-flag { + position: absolute; + top: 3px; + left: -8px; + color: $curious-blue; + } + &__account-primary-balance { color: $scorpion; border: none; diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss index 5826a8b49..3e99d0ac6 100644 --- a/ui/app/css/itcss/components/buttons.scss +++ b/ui/app/css/itcss/components/buttons.scss @@ -87,6 +87,18 @@ min-width: initial; } +.btn--first-time { + height: 54px; + width: 198px; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .14); + color: $white; + font-size: 1.25rem; + font-weight: 500; + transition: 200ms ease-in-out; + background-color: rgba(247, 134, 28, .9); + border-radius: 0; +} + .btn--large { min-height: 54px; } diff --git a/ui/app/css/itcss/components/gas-slider.scss b/ui/app/css/itcss/components/gas-slider.scss index c27a560bd..e69de29bb 100644 --- a/ui/app/css/itcss/components/gas-slider.scss +++ b/ui/app/css/itcss/components/gas-slider.scss @@ -1,51 +0,0 @@ -.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/index.scss b/ui/app/css/itcss/components/index.scss index 63aa62eb3..f2f37bfa3 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -30,8 +30,6 @@ @import './currency-display.scss'; -@import './account-menu.scss'; - @import './menu.scss'; @import './gas-slider.scss'; @@ -54,6 +52,4 @@ @import './tooltip.scss'; -@import './welcome-screen.scss'; - -@import '../../../components/index'; +@import '../../../components/app/index'; diff --git a/ui/app/css/itcss/components/loading-overlay.scss b/ui/app/css/itcss/components/loading-overlay.scss index b023c8423..a99c58a23 100644 --- a/ui/app/css/itcss/components/loading-overlay.scss +++ b/ui/app/css/itcss/components/loading-overlay.scss @@ -1,7 +1,7 @@ .loading-overlay { left: 0; z-index: 51; - position: absolute; + position: fixed; flex-direction: column; display: flex; justify-content: center; @@ -11,6 +11,12 @@ height: 100%; background: rgba(255, 255, 255, .8); + &__screen-content { + display: flex; + flex-direction: column; + align-items: center; + } + &__container { position: absolute; top: 33%; @@ -26,6 +32,27 @@ font-size: 20px; color: $manatee; } + + &__error-screen { + display: flex; + flex-direction: column; + align-items: center; + height: 160px; + justify-content: space-evenly; + } + + &__error-buttons { + display: flex; + flex-direction: row; + + button { + margin: 5px; + } + } + + &__emoji { + font-size: 32px; + } } .spinner { diff --git a/ui/app/css/itcss/components/network.scss b/ui/app/css/itcss/components/network.scss index 833a91f12..c828a2b26 100644 --- a/ui/app/css/itcss/components/network.scss +++ b/ui/app/css/itcss/components/network.scss @@ -1,6 +1,6 @@ .network-component--disabled { // border-color: transparent !important; - cursor: default; + cursor: not-allowed; .fa-caret-down { opacity: 0; diff --git a/ui/app/css/itcss/components/new-account.scss b/ui/app/css/itcss/components/new-account.scss index e4c7a4e0d..7bfa2d443 100644 --- a/ui/app/css/itcss/components/new-account.scss +++ b/ui/app/css/itcss/components/new-account.scss @@ -3,6 +3,7 @@ background-color: #FFFFFF; box-shadow: 0 0 7px 0 rgba(0,0,0,0.08); z-index: 25; + height: 100%; &__header { display: flex; @@ -186,22 +187,8 @@ } &__connect-btn { - background-color: #259De5; - color: #fff; - border: none; width: 315px; - min-height: 54px; - font-weight: 300; - font-size: 14px; - margin-bottom: 20px; - margin-top: 20px; - border-radius: 5px; - display: flex; - flex: 1; - margin-left: 20px; - margin-right: 20px; - justify-content: center; - text-transform: uppercase; + margin: 20px; } &__connect-btn.disabled { @@ -449,6 +436,7 @@ margin-top: 10px; &__button { + background: #fff; height: 19px; display: flex; color: #33a4e7; @@ -489,29 +477,8 @@ justify-content: space-between; } - &__button { - width: 150px; - min-width: initial; - } - - .btn-primary { - background-color: #259DE5; - color: #FFFFFF; - border: none; - width: 100%; - min-height: 54px; - font-weight: 300; - font-size: 14px; - margin-bottom: 20px - } - - &__button.unlock { - width: 50%; - } - - &__button.btn-primary--disabled { - cursor: not-allowed; - opacity: .5; + &__button:not(:last-child) { + margin-right: 16px; } } diff --git a/ui/app/css/itcss/components/newui-sections.scss b/ui/app/css/itcss/components/newui-sections.scss index 233e781ef..9a0b81aed 100644 --- a/ui/app/css/itcss/components/newui-sections.scss +++ b/ui/app/css/itcss/components/newui-sections.scss @@ -8,6 +8,15 @@ $sub-mid-size-breakpoint-range: "screen and (min-width: #{$break-large}) and (ma // Component Colors $wallet-view-bg: $alabaster; +.app { + display: flex; + flex-direction: column; + height: 100%; + overflow-x: hidden; + position: relative; + align-items: center; +} + // Main container .main-container { // position: absolute; @@ -24,8 +33,10 @@ $wallet-view-bg: $alabaster; .main-container-wrapper { display: flex; - width: 100vw; justify-content: center; + flex: 1 0 auto; + min-height: 0; + width: 100%; } //Account and transaction details @@ -207,15 +218,13 @@ $wallet-view-bg: $alabaster; } .main-container { - // margin-top: 41px; - height: 100%; width: 100%; overflow-y: auto; background-color: $white; } .main-container-wrapper { - height: 100%; + flex: 1; } } diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss index a57653b45..07ab04613 100644 --- a/ui/app/css/itcss/components/send.scss +++ b/ui/app/css/itcss/components/send.scss @@ -520,6 +520,13 @@ color: $red; } + &__warning { + font-size: 12px; + line-height: 12px; + left: 8px; + color: $orange; + } + &__error-border { color: $red; } @@ -552,6 +559,8 @@ &__form-field { flex: 1 1 auto; + min-width: 0; + max-width: 277px; .currency-display { color: $tundora; @@ -578,8 +587,9 @@ font-family: Roboto; font-size: 16px; line-height: 22px; - width: 88px; + width: 95px; font-weight: 400; + flex: 0 0 auto; } &__from-dropdown { @@ -682,6 +692,7 @@ display: flex; align-items: center; } + } &__sliders-icon-container { @@ -915,6 +926,16 @@ display: none; } } + +} + +.advanced-gas-options-btn { + display: flex; + justify-content: flex-end; + font-size: 14px; + color: #2f9ae0; + cursor: pointer; + margin-top: 16px; } .sliders-icon-container { @@ -933,6 +954,23 @@ font-size: 1em; } +.gas-fee-reset { + display: flex; + align-items: center; + justify-content: center; + height: 24px; + border-radius: 4px; + background-color: #fff; + position: absolute; + right: 12px; + top: 14px; + cursor: pointer; + font-size: 1em; + font-size: 14px; + color: #2f9ae0; + font-family: Roboto; +} + .sliders-icon { color: $curious-blue; } diff --git a/ui/app/css/itcss/components/tab-bar.scss b/ui/app/css/itcss/components/tab-bar.scss index 4f3077974..bb9f8f261 100644 --- a/ui/app/css/itcss/components/tab-bar.scss +++ b/ui/app/css/itcss/components/tab-bar.scss @@ -1,21 +1,73 @@ .tab-bar { display: flex; - flex-direction: row; + flex-direction: column; justify-content: flex-start; - align-items: flex-end; } .tab-bar__tab { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; min-width: 0; flex: 0 0 auto; - padding: 15px 25px; - border-bottom: 1px solid $alto; box-sizing: border-box; - font-size: 18px; -} + font-size: 16px; + padding: 16px 24px; + opacity: .5; + transition: opacity 200ms ease-in-out; + + @media screen and (min-width: 576px) { + &:hover { + opacity: .4; + } + + &:active { + opacity: .6; + } + } + + @media screen and (max-width: 575px) { + font-size: 18px; + padding: 24px; + border-bottom: 1px solid $alto; + opacity: 1; + } + + &__content { + flex: 1 1 auto; + width: 0; + + &__description { + display: none; + + @media screen and (max-width: 575px) { + display: block; + font-size: 14px; + font-weight: 300; + margin-top: 8px; + min-height: 14px; + } + } + } + + &__caret { + display: none; + + @media screen and (max-width: 575px) { + display: block; + background-image: url('/images/caret-right.svg'); + width: 36px; + height: 36px; + opacity: .5; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + } + } -.tab-bar__tab--active { - border-color: $black; + &--active { + opacity: 1 !important; + } } .tab-bar__grow-tab { diff --git a/ui/app/css/itcss/components/welcome-screen.scss b/ui/app/css/itcss/components/welcome-screen.scss deleted file mode 100644 index af1d67398..000000000 --- a/ui/app/css/itcss/components/welcome-screen.scss +++ /dev/null @@ -1,60 +0,0 @@ -.welcome-screen { - display: flex; - flex-flow: column; - justify-content: center; - align-items: center; - font-family: Roboto; - font-weight: 400; - width: 100%; - flex: 1 0 auto; - padding: 70px 0; - background: $white; - - @media screen and (max-width: 575px) { - padding: 0; - } - - &__info { - display: flex; - flex-flow: column; - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - - &__header { - font-size: 1.65em; - margin-bottom: 14px; - - @media screen and (max-width: 575px) { - font-size: 1.5em; - } - } - - &__copy { - font-size: 1em; - width: 400px; - max-width: 90vw; - text-align: center; - - @media screen and (max-width: 575px) { - font-size: .9em; - } - } - } - - &__button { - height: 54px; - width: 198px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .14); - color: #fff; - font-size: 20px; - font-weight: 500; - line-height: 26px; - text-align: center; - text-transform: uppercase; - margin: 35px 0 14px; - transition: 200ms ease-in-out; - background-color: rgba(247, 134, 28, .9); - } -} diff --git a/ui/app/css/itcss/generic/index.scss b/ui/app/css/itcss/generic/index.scss index d1c65afed..d8e62c97a 100644 --- a/ui/app/css/itcss/generic/index.scss +++ b/ui/app/css/itcss/generic/index.scss @@ -18,6 +18,10 @@ body { height: 100%; margin: 0; padding: 0; + + @media screen and (max-width: $break-small) { + overflow-y: overlay; + } } html { diff --git a/ui/app/css/itcss/settings/variables.scss b/ui/app/css/itcss/settings/variables.scss index f90c8edc3..89bd8b96a 100644 --- a/ui/app/css/itcss/settings/variables.scss +++ b/ui/app/css/itcss/settings/variables.scss @@ -56,6 +56,10 @@ $zumthor: #edf7ff; $ecstasy: #f7861c; $linen: #fdf4f4; $oslo-gray: #8C8E94; +$polar: #fafcfe; +$blizzard-blue: #bfdef3; +$mischka: #dddee9; +$web-orange: #f2a202; /* Z-Indicies diff --git a/ui/app/reducers/app.js b/ui/app/ducks/app/app.js index 5c86d397d..acbb5c989 100644 --- a/ui/app/reducers/app.js +++ b/ui/app/ducks/app/app.js @@ -1,6 +1,6 @@ const extend = require('xtend') -const actions = require('../actions') -const txHelper = require('../../lib/tx-helper') +const actions = require('../../store/actions') +const txHelper = require('../../../lib/tx-helper') const log = require('loglevel') module.exports = reduceApp @@ -52,6 +52,7 @@ function reduceApp (state, action) { isOpen: false, transitionName: '', type: '', + props: {}, }, alertOpen: false, alertMessage: null, @@ -75,6 +76,7 @@ function reduceApp (state, action) { trezor: `m/44'/60'/0'/0`, ledger: `m/44'/60'/0'/0/0`, }, + lastSelectedProvider: null, }, state.appState) switch (action.type) { @@ -747,6 +749,14 @@ function reduceApp (state, action) { networkNonce: action.value, }) + case actions.SET_PREVIOUS_PROVIDER: + if (action.value === 'loading') { + return appState + } + return extend(appState, { + lastSelectedProvider: action.value, + }) + default: return appState } diff --git a/ui/app/ducks/confirm-transaction.duck.js b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.js index 275eb1551..4edf8a70c 100644 --- a/ui/app/ducks/confirm-transaction.duck.js +++ b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.js @@ -3,7 +3,7 @@ import { currentCurrencySelector, unconfirmedTransactionsHashSelector, getNativeCurrency, -} from '../selectors/confirm-transaction' +} from '../../selectors/confirm-transaction' import { getValueFromWeiHex, @@ -13,17 +13,18 @@ import { addEth, increaseLastGasPrice, hexGreaterThan, -} from '../helpers/confirm-transaction/util' +} from '../../helpers/utils/confirm-tx.util' import { getTokenData, getMethodData, isSmartContractAddress, sumHexes, -} from '../helpers/transactions.util' +} from '../../helpers/utils/transactions.util' -import { getSymbolAndDecimals } from '../token-util' -import { conversionUtil } from '../conversion-util' +import { getSymbolAndDecimals } from '../../helpers/utils/token-util' +import { conversionUtil } from '../../helpers/utils/conversion-util' +import { addHexPrefix } from 'ethereumjs-util' // Actions const createActionType = action => `metamask/confirm-transaction/${action}` @@ -256,6 +257,8 @@ export function setFetchingData (isFetching) { } export function updateGasAndCalculate ({ gasLimit, gasPrice }) { + gasLimit = addHexPrefix(gasLimit) + gasPrice = addHexPrefix(gasPrice) return (dispatch, getState) => { const { confirmTransaction: { txData } } = getState() const newTxData = { @@ -369,12 +372,18 @@ export function setTransactionToConfirm (transactionId) { try { dispatch(setFetchingData(true)) const methodData = await getMethodData(data) + dispatch(updateMethodData(methodData)) + } catch (error) { + dispatch(updateMethodData({})) + dispatch(setFetchingData(false)) + } + + try { const toSmartContract = await isSmartContractAddress(to) dispatch(updateToSmartContract(toSmartContract)) dispatch(setFetchingData(false)) } catch (error) { - dispatch(updateMethodData({})) dispatch(setFetchingData(false)) } diff --git a/ui/app/ducks/tests/confirm-transaction.duck.test.js b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.test.js index eceacd0bd..483f2f56d 100644 --- a/ui/app/ducks/tests/confirm-transaction.duck.test.js +++ b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.test.js @@ -3,7 +3,7 @@ import configureMockStore from 'redux-mock-store' import thunk from 'redux-thunk' import sinon from 'sinon' -import ConfirmTransactionReducer, * as actions from '../confirm-transaction.duck.js' +import ConfirmTransactionReducer, * as actions from './confirm-transaction.duck.js' const initialState = { txData: {}, diff --git a/ui/app/ducks/gas/gas-duck.test.js b/ui/app/ducks/gas/gas-duck.test.js new file mode 100644 index 000000000..c0152c74f --- /dev/null +++ b/ui/app/ducks/gas/gas-duck.test.js @@ -0,0 +1,606 @@ +import assert from 'assert' +import sinon from 'sinon' +import proxyquire from 'proxyquire' + + +const GasDuck = proxyquire('./gas.duck.js', { + '../../../lib/local-storage-helpers': { + loadLocalStorageData: sinon.spy(), + saveLocalStorageData: sinon.spy(), + }, +}) + +const { + basicGasEstimatesLoadingStarted, + basicGasEstimatesLoadingFinished, + setBasicGasEstimateData, + setCustomGasPrice, + setCustomGasLimit, + setCustomGasTotal, + setCustomGasErrors, + resetCustomGasState, + fetchBasicGasAndTimeEstimates, + fetchBasicGasEstimates, + gasEstimatesLoadingStarted, + gasEstimatesLoadingFinished, + setPricesAndTimeEstimates, + fetchGasEstimates, + setApiEstimatesLastRetrieved, +} = GasDuck +const GasReducer = GasDuck.default + +describe('Gas Duck', () => { + let tempFetch + let tempDateNow + const mockEthGasApiResponse = { + average: 20, + avgWait: 'mockAvgWait', + block_time: 'mockBlock_time', + blockNum: 'mockBlockNum', + fast: 30, + fastest: 40, + fastestWait: 'mockFastestWait', + fastWait: 'mockFastWait', + safeLow: 10, + safeLowWait: 'mockSafeLowWait', + speed: 'mockSpeed', + standard: 20, + } + const mockPredictTableResponse = [ + { expectedTime: 400, expectedWait: 40, gasprice: 0.25, somethingElse: 'foobar' }, + { expectedTime: 200, expectedWait: 20, gasprice: 0.5, somethingElse: 'foobar' }, + { expectedTime: 100, expectedWait: 10, gasprice: 1, somethingElse: 'foobar' }, + { expectedTime: 75, expectedWait: 7.5, gasprice: 1.5, somethingElse: 'foobar' }, + { expectedTime: 50, expectedWait: 5, gasprice: 2, somethingElse: 'foobar' }, + { expectedTime: 35, expectedWait: 4.5, gasprice: 3, somethingElse: 'foobar' }, + { expectedTime: 34, expectedWait: 4.4, gasprice: 3.1, somethingElse: 'foobar' }, + { expectedTime: 25, expectedWait: 4.2, gasprice: 3.5, somethingElse: 'foobar' }, + { expectedTime: 20, expectedWait: 4, gasprice: 4, somethingElse: 'foobar' }, + { expectedTime: 19, expectedWait: 3.9, gasprice: 4.1, somethingElse: 'foobar' }, + { expectedTime: 15, expectedWait: 3, gasprice: 7, somethingElse: 'foobar' }, + { expectedTime: 14, expectedWait: 2.9, gasprice: 7.1, somethingElse: 'foobar' }, + { expectedTime: 12, expectedWait: 2.5, gasprice: 8, somethingElse: 'foobar' }, + { expectedTime: 10, expectedWait: 2, gasprice: 10, somethingElse: 'foobar' }, + { expectedTime: 9, expectedWait: 1.9, gasprice: 10.1, somethingElse: 'foobar' }, + { expectedTime: 5, expectedWait: 1, gasprice: 15, somethingElse: 'foobar' }, + { expectedTime: 4, expectedWait: 0.9, gasprice: 15.1, somethingElse: 'foobar' }, + { expectedTime: 2, expectedWait: 0.8, gasprice: 17, somethingElse: 'foobar' }, + { expectedTime: 1.1, expectedWait: 0.6, gasprice: 19.9, somethingElse: 'foobar' }, + { expectedTime: 1, expectedWait: 0.5, gasprice: 20, somethingElse: 'foobar' }, + ] + const fetchStub = sinon.stub().callsFake((url) => new Promise(resolve => { + const dataToResolve = url.match(/ethgasAPI|gasexpress/) + ? mockEthGasApiResponse + : mockPredictTableResponse + resolve({ + json: () => new Promise(resolve => resolve(dataToResolve)), + }) + })) + + beforeEach(() => { + tempFetch = global.fetch + tempDateNow = global.Date.now + global.fetch = fetchStub + global.Date.now = () => 2000000 + }) + + afterEach(() => { + fetchStub.resetHistory() + global.fetch = tempFetch + global.Date.now = tempDateNow + }) + + const mockState = { + gas: { + mockProp: 123, + }, + } + const initState = { + customData: { + price: null, + limit: null, + }, + basicEstimates: { + average: null, + fastestWait: null, + fastWait: null, + fast: null, + safeLowWait: null, + blockNum: null, + avgWait: null, + blockTime: null, + speed: null, + fastest: null, + safeLow: null, + }, + basicEstimateIsLoading: true, + errors: {}, + gasEstimatesLoading: true, + priceAndTimeEstimates: [], + priceAndTimeEstimatesLastRetrieved: 0, + basicPriceAndTimeEstimates: [], + basicPriceAndTimeEstimatesLastRetrieved: 0, + basicPriceEstimatesLastRetrieved: 0, + } + const BASIC_GAS_ESTIMATE_LOADING_FINISHED = 'metamask/gas/BASIC_GAS_ESTIMATE_LOADING_FINISHED' + const BASIC_GAS_ESTIMATE_LOADING_STARTED = 'metamask/gas/BASIC_GAS_ESTIMATE_LOADING_STARTED' + const GAS_ESTIMATE_LOADING_FINISHED = 'metamask/gas/GAS_ESTIMATE_LOADING_FINISHED' + const GAS_ESTIMATE_LOADING_STARTED = 'metamask/gas/GAS_ESTIMATE_LOADING_STARTED' + const RESET_CUSTOM_GAS_STATE = 'metamask/gas/RESET_CUSTOM_GAS_STATE' + const SET_BASIC_GAS_ESTIMATE_DATA = 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA' + const SET_CUSTOM_GAS_ERRORS = 'metamask/gas/SET_CUSTOM_GAS_ERRORS' + const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT' + const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE' + const SET_CUSTOM_GAS_TOTAL = 'metamask/gas/SET_CUSTOM_GAS_TOTAL' + const SET_PRICE_AND_TIME_ESTIMATES = 'metamask/gas/SET_PRICE_AND_TIME_ESTIMATES' + const SET_API_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_API_ESTIMATES_LAST_RETRIEVED' + const SET_BASIC_API_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_BASIC_API_ESTIMATES_LAST_RETRIEVED' + const SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED' + + describe('GasReducer()', () => { + it('should initialize state', () => { + assert.deepEqual( + GasReducer({}), + initState + ) + }) + + it('should return state unchanged if it does not match a dispatched actions type', () => { + assert.deepEqual( + GasReducer(mockState, { + type: 'someOtherAction', + value: 'someValue', + }), + Object.assign({}, mockState.gas) + ) + }) + + it('should set basicEstimateIsLoading to true when receiving a BASIC_GAS_ESTIMATE_LOADING_STARTED action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: BASIC_GAS_ESTIMATE_LOADING_STARTED, + }), + Object.assign({basicEstimateIsLoading: true}, mockState.gas) + ) + }) + + it('should set basicEstimateIsLoading to false when receiving a BASIC_GAS_ESTIMATE_LOADING_FINISHED action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: BASIC_GAS_ESTIMATE_LOADING_FINISHED, + }), + Object.assign({basicEstimateIsLoading: false}, mockState.gas) + ) + }) + + it('should set gasEstimatesLoading to true when receiving a GAS_ESTIMATE_LOADING_STARTED action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: GAS_ESTIMATE_LOADING_STARTED, + }), + Object.assign({gasEstimatesLoading: true}, mockState.gas) + ) + }) + + it('should set gasEstimatesLoading to false when receiving a GAS_ESTIMATE_LOADING_FINISHED action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: GAS_ESTIMATE_LOADING_FINISHED, + }), + Object.assign({gasEstimatesLoading: false}, mockState.gas) + ) + }) + + it('should return a new object (and not just modify the existing state object)', () => { + assert.deepEqual(GasReducer(mockState), mockState.gas) + assert.notEqual(GasReducer(mockState), mockState.gas) + }) + + it('should set basicEstimates when receiving a SET_BASIC_GAS_ESTIMATE_DATA action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_BASIC_GAS_ESTIMATE_DATA, + value: { someProp: 'someData123' }, + }), + Object.assign({basicEstimates: {someProp: 'someData123'} }, mockState.gas) + ) + }) + + it('should set priceAndTimeEstimates when receiving a SET_PRICE_AND_TIME_ESTIMATES action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_PRICE_AND_TIME_ESTIMATES, + value: { someProp: 'someData123' }, + }), + Object.assign({priceAndTimeEstimates: {someProp: 'someData123'} }, mockState.gas) + ) + }) + + it('should set customData.price when receiving a SET_CUSTOM_GAS_PRICE action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_CUSTOM_GAS_PRICE, + value: 4321, + }), + Object.assign({customData: {price: 4321} }, mockState.gas) + ) + }) + + it('should set customData.limit when receiving a SET_CUSTOM_GAS_LIMIT action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_CUSTOM_GAS_LIMIT, + value: 9876, + }), + Object.assign({customData: {limit: 9876} }, mockState.gas) + ) + }) + + it('should set customData.total when receiving a SET_CUSTOM_GAS_TOTAL action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_CUSTOM_GAS_TOTAL, + value: 10000, + }), + Object.assign({customData: {total: 10000} }, mockState.gas) + ) + }) + + it('should set priceAndTimeEstimatesLastRetrieved when receiving a SET_API_ESTIMATES_LAST_RETRIEVED action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_API_ESTIMATES_LAST_RETRIEVED, + value: 1500000000000, + }), + Object.assign({ priceAndTimeEstimatesLastRetrieved: 1500000000000 }, mockState.gas) + ) + }) + + it('should set priceAndTimeEstimatesLastRetrieved when receiving a SET_BASIC_API_ESTIMATES_LAST_RETRIEVED action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_BASIC_API_ESTIMATES_LAST_RETRIEVED, + value: 1700000000000, + }), + Object.assign({ basicPriceAndTimeEstimatesLastRetrieved: 1700000000000 }, mockState.gas) + ) + }) + + it('should set errors when receiving a SET_CUSTOM_GAS_ERRORS action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: SET_CUSTOM_GAS_ERRORS, + value: { someError: 'error_error' }, + }), + Object.assign({errors: {someError: 'error_error'} }, mockState.gas) + ) + }) + + it('should return the initial state in response to a RESET_CUSTOM_GAS_STATE action', () => { + assert.deepEqual( + GasReducer(mockState, { + type: RESET_CUSTOM_GAS_STATE, + }), + Object.assign({}, initState) + ) + }) + }) + + describe('basicGasEstimatesLoadingStarted', () => { + it('should create the correct action', () => { + assert.deepEqual( + basicGasEstimatesLoadingStarted(), + { type: BASIC_GAS_ESTIMATE_LOADING_STARTED } + ) + }) + }) + + describe('basicGasEstimatesLoadingFinished', () => { + it('should create the correct action', () => { + assert.deepEqual( + basicGasEstimatesLoadingFinished(), + { type: BASIC_GAS_ESTIMATE_LOADING_FINISHED } + ) + }) + }) + + describe('fetchBasicGasEstimates', () => { + const mockDistpatch = sinon.spy() + it('should call fetch with the expected params', async () => { + await fetchBasicGasEstimates()(mockDistpatch, () => ({ gas: Object.assign( + {}, + initState, + { basicPriceAEstimatesLastRetrieved: 1000000 } + ) })) + assert.deepEqual( + mockDistpatch.getCall(0).args, + [{ type: BASIC_GAS_ESTIMATE_LOADING_STARTED} ] + ) + assert.deepEqual( + global.fetch.getCall(0).args, + [ + 'https://dev.blockscale.net/api/gasexpress.json', + { + 'headers': {}, + 'referrer': 'https://dev.blockscale.net/api/', + 'referrerPolicy': 'no-referrer-when-downgrade', + 'body': null, + 'method': 'GET', + 'mode': 'cors', + }, + ] + ) + + assert.deepEqual( + mockDistpatch.getCall(1).args, + [{ type: SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED, value: 2000000 } ] + ) + + assert.deepEqual( + mockDistpatch.getCall(2).args, + [{ + type: SET_BASIC_GAS_ESTIMATE_DATA, + value: { + average: 20, + blockTime: 'mockBlock_time', + blockNum: 'mockBlockNum', + fast: 30, + fastest: 40, + safeLow: 10, + }, + }] + ) + assert.deepEqual( + mockDistpatch.getCall(3).args, + [{ type: BASIC_GAS_ESTIMATE_LOADING_FINISHED }] + ) + }) + }) + + describe('fetchBasicGasAndTimeEstimates', () => { + const mockDistpatch = sinon.spy() + it('should call fetch with the expected params', async () => { + await fetchBasicGasAndTimeEstimates()(mockDistpatch, () => ({ gas: Object.assign( + {}, + initState, + { basicPriceAndTimeEstimatesLastRetrieved: 1000000 } + ), + metamask: { provider: { type: 'ropsten' } }, + })) + assert.deepEqual( + mockDistpatch.getCall(0).args, + [{ type: BASIC_GAS_ESTIMATE_LOADING_STARTED} ] + ) + assert.deepEqual( + global.fetch.getCall(0).args, + [ + 'https://ethgasstation.info/json/ethgasAPI.json', + { + 'headers': {}, + 'referrer': 'http://ethgasstation.info/json/', + 'referrerPolicy': 'no-referrer-when-downgrade', + 'body': null, + 'method': 'GET', + 'mode': 'cors', + }, + ] + ) + + assert.deepEqual( + mockDistpatch.getCall(1).args, + [{ type: SET_BASIC_API_ESTIMATES_LAST_RETRIEVED, value: 2000000 } ] + ) + + assert.deepEqual( + mockDistpatch.getCall(2).args, + [{ + type: SET_BASIC_GAS_ESTIMATE_DATA, + value: { + average: 2, + avgWait: 'mockAvgWait', + blockTime: 'mockBlock_time', + blockNum: 'mockBlockNum', + fast: 3, + fastest: 4, + fastestWait: 'mockFastestWait', + fastWait: 'mockFastWait', + safeLow: 1, + safeLowWait: 'mockSafeLowWait', + speed: 'mockSpeed', + }, + }] + ) + assert.deepEqual( + mockDistpatch.getCall(3).args, + [{ type: BASIC_GAS_ESTIMATE_LOADING_FINISHED }] + ) + }) + }) + + describe('fetchGasEstimates', () => { + const mockDistpatch = sinon.spy() + + beforeEach(() => { + mockDistpatch.resetHistory() + }) + + it('should call fetch with the expected params', async () => { + global.fetch.resetHistory() + await fetchGasEstimates(5)(mockDistpatch, () => ({ gas: Object.assign( + {}, + initState, + { priceAndTimeEstimatesLastRetrieved: 1000000 } + ), + metamask: { provider: { type: 'ropsten' } }, + })) + assert.deepEqual( + mockDistpatch.getCall(0).args, + [{ type: GAS_ESTIMATE_LOADING_STARTED} ] + ) + assert.deepEqual( + global.fetch.getCall(0).args, + [ + 'https://ethgasstation.info/json/predictTable.json', + { + 'headers': {}, + 'referrer': 'http://ethgasstation.info/json/', + 'referrerPolicy': 'no-referrer-when-downgrade', + 'body': null, + 'method': 'GET', + 'mode': 'cors', + }, + ] + ) + + assert.deepEqual( + mockDistpatch.getCall(1).args, + [{ type: SET_API_ESTIMATES_LAST_RETRIEVED, value: 2000000 }] + ) + + const { type: thirdDispatchCallType, value: priceAndTimeEstimateResult } = mockDistpatch.getCall(2).args[0] + assert.equal(thirdDispatchCallType, SET_PRICE_AND_TIME_ESTIMATES) + assert(priceAndTimeEstimateResult.length < mockPredictTableResponse.length * 3 - 2) + assert(!priceAndTimeEstimateResult.find(d => d.expectedTime > 100)) + assert(!priceAndTimeEstimateResult.find((d, i, a) => a[a + 1] && d.expectedTime > a[a + 1].expectedTime)) + assert(!priceAndTimeEstimateResult.find((d, i, a) => a[a + 1] && d.gasprice > a[a + 1].gasprice)) + + assert.deepEqual( + mockDistpatch.getCall(3).args, + [{ type: GAS_ESTIMATE_LOADING_FINISHED }] + ) + }) + + it('should not call fetch if the estimates were retrieved < 75000 ms ago', async () => { + global.fetch.resetHistory() + await fetchGasEstimates(5)(mockDistpatch, () => ({ gas: Object.assign( + {}, + initState, + { + priceAndTimeEstimatesLastRetrieved: Date.now(), + priceAndTimeEstimates: [{ + expectedTime: '10', + expectedWait: 2, + gasprice: 50, + }], + } + ), + metamask: { provider: { type: 'ropsten' } }, + })) + assert.deepEqual( + mockDistpatch.getCall(0).args, + [{ type: GAS_ESTIMATE_LOADING_STARTED} ] + ) + assert.equal(global.fetch.callCount, 0) + + assert.deepEqual( + mockDistpatch.getCall(1).args, + [{ + type: SET_PRICE_AND_TIME_ESTIMATES, + value: [ + { + expectedTime: '10', + expectedWait: 2, + gasprice: 50, + }, + ], + + }] + ) + assert.deepEqual( + mockDistpatch.getCall(2).args, + [{ type: GAS_ESTIMATE_LOADING_FINISHED }] + ) + }) + }) + + describe('gasEstimatesLoadingStarted', () => { + it('should create the correct action', () => { + assert.deepEqual( + gasEstimatesLoadingStarted(), + { type: GAS_ESTIMATE_LOADING_STARTED } + ) + }) + }) + + describe('gasEstimatesLoadingFinished', () => { + it('should create the correct action', () => { + assert.deepEqual( + gasEstimatesLoadingFinished(), + { type: GAS_ESTIMATE_LOADING_FINISHED } + ) + }) + }) + + describe('setPricesAndTimeEstimates', () => { + it('should create the correct action', () => { + assert.deepEqual( + setPricesAndTimeEstimates('mockPricesAndTimeEstimates'), + { type: SET_PRICE_AND_TIME_ESTIMATES, value: 'mockPricesAndTimeEstimates' } + ) + }) + }) + + describe('setBasicGasEstimateData', () => { + it('should create the correct action', () => { + assert.deepEqual( + setBasicGasEstimateData('mockBasicEstimatData'), + { type: SET_BASIC_GAS_ESTIMATE_DATA, value: 'mockBasicEstimatData' } + ) + }) + }) + + describe('setCustomGasPrice', () => { + it('should create the correct action', () => { + assert.deepEqual( + setCustomGasPrice('mockCustomGasPrice'), + { type: SET_CUSTOM_GAS_PRICE, value: 'mockCustomGasPrice' } + ) + }) + }) + + describe('setCustomGasLimit', () => { + it('should create the correct action', () => { + assert.deepEqual( + setCustomGasLimit('mockCustomGasLimit'), + { type: SET_CUSTOM_GAS_LIMIT, value: 'mockCustomGasLimit' } + ) + }) + }) + + describe('setCustomGasTotal', () => { + it('should create the correct action', () => { + assert.deepEqual( + setCustomGasTotal('mockCustomGasTotal'), + { type: SET_CUSTOM_GAS_TOTAL, value: 'mockCustomGasTotal' } + ) + }) + }) + + describe('setCustomGasErrors', () => { + it('should create the correct action', () => { + assert.deepEqual( + setCustomGasErrors('mockErrorObject'), + { type: SET_CUSTOM_GAS_ERRORS, value: 'mockErrorObject' } + ) + }) + }) + + describe('setApiEstimatesLastRetrieved', () => { + it('should create the correct action', () => { + assert.deepEqual( + setApiEstimatesLastRetrieved(1234), + { type: SET_API_ESTIMATES_LAST_RETRIEVED, value: 1234 } + ) + }) + }) + + describe('resetCustomGasState', () => { + it('should create the correct action', () => { + assert.deepEqual( + resetCustomGasState(), + { type: RESET_CUSTOM_GAS_STATE } + ) + }) + }) + +}) diff --git a/ui/app/ducks/gas/gas.duck.js b/ui/app/ducks/gas/gas.duck.js new file mode 100644 index 000000000..8eb68f846 --- /dev/null +++ b/ui/app/ducks/gas/gas.duck.js @@ -0,0 +1,526 @@ +import { clone, uniqBy, flatten } from 'ramda' +import BigNumber from 'bignumber.js' +import { + loadLocalStorageData, + saveLocalStorageData, +} from '../../../lib/local-storage-helpers' +import { + decGWEIToHexWEI, +} from '../../helpers/utils/conversions.util' +import { + isEthereumNetwork, +} from '../../selectors/selectors' + +// Actions +const BASIC_GAS_ESTIMATE_LOADING_FINISHED = 'metamask/gas/BASIC_GAS_ESTIMATE_LOADING_FINISHED' +const BASIC_GAS_ESTIMATE_LOADING_STARTED = 'metamask/gas/BASIC_GAS_ESTIMATE_LOADING_STARTED' +const GAS_ESTIMATE_LOADING_FINISHED = 'metamask/gas/GAS_ESTIMATE_LOADING_FINISHED' +const GAS_ESTIMATE_LOADING_STARTED = 'metamask/gas/GAS_ESTIMATE_LOADING_STARTED' +const RESET_CUSTOM_GAS_STATE = 'metamask/gas/RESET_CUSTOM_GAS_STATE' +const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA' +const SET_BASIC_GAS_ESTIMATE_DATA = 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA' +const SET_CUSTOM_GAS_ERRORS = 'metamask/gas/SET_CUSTOM_GAS_ERRORS' +const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT' +const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE' +const SET_CUSTOM_GAS_TOTAL = 'metamask/gas/SET_CUSTOM_GAS_TOTAL' +const SET_PRICE_AND_TIME_ESTIMATES = 'metamask/gas/SET_PRICE_AND_TIME_ESTIMATES' +const SET_API_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_API_ESTIMATES_LAST_RETRIEVED' +const SET_BASIC_API_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_BASIC_API_ESTIMATES_LAST_RETRIEVED' +const SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED' + +// TODO: determine if this approach to initState is consistent with conventional ducks pattern +const initState = { + customData: { + price: null, + limit: null, + }, + basicEstimates: { + average: null, + fastestWait: null, + fastWait: null, + fast: null, + safeLowWait: null, + blockNum: null, + avgWait: null, + blockTime: null, + speed: null, + fastest: null, + safeLow: null, + }, + basicEstimateIsLoading: true, + gasEstimatesLoading: true, + priceAndTimeEstimates: [], + basicPriceAndTimeEstimates: [], + priceAndTimeEstimatesLastRetrieved: 0, + basicPriceAndTimeEstimatesLastRetrieved: 0, + basicPriceEstimatesLastRetrieved: 0, + errors: {}, +} + +// Reducer +export default function reducer ({ gas: gasState = initState }, action = {}) { + const newState = clone(gasState) + + switch (action.type) { + case BASIC_GAS_ESTIMATE_LOADING_STARTED: + return { + ...newState, + basicEstimateIsLoading: true, + } + case BASIC_GAS_ESTIMATE_LOADING_FINISHED: + return { + ...newState, + basicEstimateIsLoading: false, + } + case GAS_ESTIMATE_LOADING_STARTED: + return { + ...newState, + gasEstimatesLoading: true, + } + case GAS_ESTIMATE_LOADING_FINISHED: + return { + ...newState, + gasEstimatesLoading: false, + } + case SET_BASIC_GAS_ESTIMATE_DATA: + return { + ...newState, + basicEstimates: action.value, + } + case SET_CUSTOM_GAS_PRICE: + return { + ...newState, + customData: { + ...newState.customData, + price: action.value, + }, + } + case SET_CUSTOM_GAS_LIMIT: + return { + ...newState, + customData: { + ...newState.customData, + limit: action.value, + }, + } + case SET_CUSTOM_GAS_TOTAL: + return { + ...newState, + customData: { + ...newState.customData, + total: action.value, + }, + } + case SET_PRICE_AND_TIME_ESTIMATES: + return { + ...newState, + priceAndTimeEstimates: action.value, + } + case SET_CUSTOM_GAS_ERRORS: + return { + ...newState, + errors: { + ...newState.errors, + ...action.value, + }, + } + case SET_API_ESTIMATES_LAST_RETRIEVED: + return { + ...newState, + priceAndTimeEstimatesLastRetrieved: action.value, + } + case SET_BASIC_API_ESTIMATES_LAST_RETRIEVED: + return { + ...newState, + basicPriceAndTimeEstimatesLastRetrieved: action.value, + } + case SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED: + return { + ...newState, + basicPriceEstimatesLastRetrieved: action.value, + } + case RESET_CUSTOM_DATA: + return { + ...newState, + customData: clone(initState.customData), + } + case RESET_CUSTOM_GAS_STATE: + return clone(initState) + default: + return newState + } +} + +// Action Creators +export function basicGasEstimatesLoadingStarted () { + return { + type: BASIC_GAS_ESTIMATE_LOADING_STARTED, + } +} + +export function basicGasEstimatesLoadingFinished () { + return { + type: BASIC_GAS_ESTIMATE_LOADING_FINISHED, + } +} + +export function gasEstimatesLoadingStarted () { + return { + type: GAS_ESTIMATE_LOADING_STARTED, + } +} + +export function gasEstimatesLoadingFinished () { + return { + type: GAS_ESTIMATE_LOADING_FINISHED, + } +} + +export function fetchBasicGasEstimates () { + return (dispatch, getState) => { + const { + basicPriceEstimatesLastRetrieved, + basicPriceAndTimeEstimates, + } = getState().gas + const timeLastRetrieved = basicPriceEstimatesLastRetrieved || loadLocalStorageData('BASIC_PRICE_ESTIMATES_LAST_RETRIEVED') || 0 + + dispatch(basicGasEstimatesLoadingStarted()) + + const promiseToFetch = Date.now() - timeLastRetrieved > 75000 + ? fetch('https://dev.blockscale.net/api/gasexpress.json', { + 'headers': {}, + 'referrer': 'https://dev.blockscale.net/api/', + 'referrerPolicy': 'no-referrer-when-downgrade', + 'body': null, + 'method': 'GET', + 'mode': 'cors'} + ) + .then(r => r.json()) + .then(({ + safeLow, + standard: average, + fast, + fastest, + block_time: blockTime, + blockNum, + }) => { + const basicEstimates = { + safeLow, + average, + fast, + fastest, + blockTime, + blockNum, + } + + const timeRetrieved = Date.now() + dispatch(setBasicPriceEstimatesLastRetrieved(timeRetrieved)) + saveLocalStorageData(timeRetrieved, 'BASIC_PRICE_ESTIMATES_LAST_RETRIEVED') + saveLocalStorageData(basicEstimates, 'BASIC_PRICE_ESTIMATES') + + return basicEstimates + }) + : Promise.resolve(basicPriceAndTimeEstimates.length + ? basicPriceAndTimeEstimates + : loadLocalStorageData('BASIC_PRICE_ESTIMATES') + ) + + return promiseToFetch.then(basicEstimates => { + dispatch(setBasicGasEstimateData(basicEstimates)) + dispatch(basicGasEstimatesLoadingFinished()) + return basicEstimates + }) + } +} + +export function fetchBasicGasAndTimeEstimates () { + return (dispatch, getState) => { + const { + basicPriceAndTimeEstimatesLastRetrieved, + basicPriceAndTimeEstimates, + } = getState().gas + const timeLastRetrieved = basicPriceAndTimeEstimatesLastRetrieved || loadLocalStorageData('BASIC_GAS_AND_TIME_API_ESTIMATES_LAST_RETRIEVED') || 0 + + dispatch(basicGasEstimatesLoadingStarted()) + + const promiseToFetch = Date.now() - timeLastRetrieved > 75000 + ? fetch('https://ethgasstation.info/json/ethgasAPI.json', { + 'headers': {}, + 'referrer': 'http://ethgasstation.info/json/', + 'referrerPolicy': 'no-referrer-when-downgrade', + 'body': null, + 'method': 'GET', + 'mode': 'cors'} + ) + .then(r => r.json()) + .then(({ + average: averageTimes10, + avgWait, + block_time: blockTime, + blockNum, + fast: fastTimes10, + fastest: fastestTimes10, + fastestWait, + fastWait, + safeLow: safeLowTimes10, + safeLowWait, + speed, + }) => { + const [average, fast, fastest, safeLow] = [ + averageTimes10, + fastTimes10, + fastestTimes10, + safeLowTimes10, + ].map(price => (new BigNumber(price)).div(10).toNumber()) + + const basicEstimates = { + average, + avgWait, + blockTime, + blockNum, + fast, + fastest, + fastestWait, + fastWait, + safeLow, + safeLowWait, + speed, + } + + const timeRetrieved = Date.now() + dispatch(setBasicApiEstimatesLastRetrieved(timeRetrieved)) + saveLocalStorageData(timeRetrieved, 'BASIC_GAS_AND_TIME_API_ESTIMATES_LAST_RETRIEVED') + saveLocalStorageData(basicEstimates, 'BASIC_GAS_AND_TIME_API_ESTIMATES') + + return basicEstimates + }) + : Promise.resolve(basicPriceAndTimeEstimates.length + ? basicPriceAndTimeEstimates + : loadLocalStorageData('BASIC_GAS_AND_TIME_API_ESTIMATES') + ) + + return promiseToFetch.then(basicEstimates => { + dispatch(setBasicGasEstimateData(basicEstimates)) + dispatch(basicGasEstimatesLoadingFinished()) + return basicEstimates + }) + } +} + +function extrapolateY ({ higherY, lowerY, higherX, lowerX, xForExtrapolation }) { + higherY = new BigNumber(higherY, 10) + lowerY = new BigNumber(lowerY, 10) + higherX = new BigNumber(higherX, 10) + lowerX = new BigNumber(lowerX, 10) + xForExtrapolation = new BigNumber(xForExtrapolation, 10) + const slope = (higherY.minus(lowerY)).div(higherX.minus(lowerX)) + const newTimeEstimate = slope.times(higherX.minus(xForExtrapolation)).minus(higherY).negated() + + return Number(newTimeEstimate.toPrecision(10)) +} + +function getRandomArbitrary (min, max) { + min = new BigNumber(min, 10) + max = new BigNumber(max, 10) + const random = new BigNumber(String(Math.random()), 10) + return new BigNumber(random.times(max.minus(min)).plus(min)).toPrecision(10) +} + +function calcMedian (list) { + const medianPos = (Math.floor(list.length / 2) + Math.ceil(list.length / 2)) / 2 + return medianPos === Math.floor(medianPos) + ? (list[medianPos - 1] + list[medianPos]) / 2 + : list[Math.floor(medianPos)] +} + +function quartiles (data) { + const lowerHalf = data.slice(0, Math.floor(data.length / 2)) + const upperHalf = data.slice(Math.floor(data.length / 2) + (data.length % 2 === 0 ? 0 : 1)) + const median = calcMedian(data) + const lowerQuartile = calcMedian(lowerHalf) + const upperQuartile = calcMedian(upperHalf) + return { + median, + lowerQuartile, + upperQuartile, + } +} + +function inliersByIQR (data, prop) { + const { lowerQuartile, upperQuartile } = quartiles(data.map(d => prop ? d[prop] : d)) + const IQR = upperQuartile - lowerQuartile + const lowerBound = lowerQuartile - 1.5 * IQR + const upperBound = upperQuartile + 1.5 * IQR + return data.filter(d => { + const value = prop ? d[prop] : d + return value >= lowerBound && value <= upperBound + }) +} + +export function fetchGasEstimates (blockTime) { + return (dispatch, getState) => { + const state = getState() + + if (isEthereumNetwork(state)) { + return Promise.resolve(null) + } + + const { + priceAndTimeEstimatesLastRetrieved, + priceAndTimeEstimates, + } = state.gas + const timeLastRetrieved = priceAndTimeEstimatesLastRetrieved || loadLocalStorageData('GAS_API_ESTIMATES_LAST_RETRIEVED') || 0 + + dispatch(gasEstimatesLoadingStarted()) + + const promiseToFetch = Date.now() - timeLastRetrieved > 75000 + ? fetch('https://ethgasstation.info/json/predictTable.json', { + 'headers': {}, + 'referrer': 'http://ethgasstation.info/json/', + 'referrerPolicy': 'no-referrer-when-downgrade', + 'body': null, + 'method': 'GET', + 'mode': 'cors'} + ) + .then(r => r.json()) + .then(r => { + const estimatedPricesAndTimes = r.map(({ expectedTime, expectedWait, gasprice }) => ({ expectedTime, expectedWait, gasprice })) + const estimatedTimeWithUniquePrices = uniqBy(({ expectedTime }) => expectedTime, estimatedPricesAndTimes) + + const withSupplementalTimeEstimates = flatten(estimatedTimeWithUniquePrices.map(({ expectedWait, gasprice }, i, arr) => { + const next = arr[i + 1] + if (!next) { + return [{ expectedWait, gasprice }] + } else { + const supplementalPrice = getRandomArbitrary(gasprice, next.gasprice) + const supplementalTime = extrapolateY({ + higherY: next.expectedWait, + lowerY: expectedWait, + higherX: next.gasprice, + lowerX: gasprice, + xForExtrapolation: supplementalPrice, + }) + const supplementalPrice2 = getRandomArbitrary(supplementalPrice, next.gasprice) + const supplementalTime2 = extrapolateY({ + higherY: next.expectedWait, + lowerY: supplementalTime, + higherX: next.gasprice, + lowerX: supplementalPrice, + xForExtrapolation: supplementalPrice2, + }) + return [ + { expectedWait, gasprice }, + { expectedWait: supplementalTime, gasprice: supplementalPrice }, + { expectedWait: supplementalTime2, gasprice: supplementalPrice2 }, + ] + } + })) + const withOutliersRemoved = inliersByIQR(withSupplementalTimeEstimates.slice(0).reverse(), 'expectedWait').reverse() + const timeMappedToSeconds = withOutliersRemoved.map(({ expectedWait, gasprice }) => { + const expectedTime = (new BigNumber(expectedWait)).times(Number(blockTime), 10).toNumber() + return { + expectedTime, + gasprice: (new BigNumber(gasprice, 10).toNumber()), + } + }) + + const timeRetrieved = Date.now() + dispatch(setApiEstimatesLastRetrieved(timeRetrieved)) + saveLocalStorageData(timeRetrieved, 'GAS_API_ESTIMATES_LAST_RETRIEVED') + saveLocalStorageData(timeMappedToSeconds, 'GAS_API_ESTIMATES') + + return timeMappedToSeconds + }) + : Promise.resolve(priceAndTimeEstimates.length + ? priceAndTimeEstimates + : loadLocalStorageData('GAS_API_ESTIMATES') + ) + + return promiseToFetch.then(estimates => { + dispatch(setPricesAndTimeEstimates(estimates)) + dispatch(gasEstimatesLoadingFinished()) + }) + } +} + +export function setCustomGasPriceForRetry (newPrice) { + return (dispatch) => { + if (newPrice !== '0x0') { + dispatch(setCustomGasPrice(newPrice)) + } else { + const { fast } = loadLocalStorageData('BASIC_PRICE_ESTIMATES') + dispatch(setCustomGasPrice(decGWEIToHexWEI(fast))) + } + } +} + +export function setBasicGasEstimateData (basicGasEstimateData) { + return { + type: SET_BASIC_GAS_ESTIMATE_DATA, + value: basicGasEstimateData, + } +} + +export function setPricesAndTimeEstimates (estimatedPricesAndTimes) { + return { + type: SET_PRICE_AND_TIME_ESTIMATES, + value: estimatedPricesAndTimes, + } +} + +export function setCustomGasPrice (newPrice) { + return { + type: SET_CUSTOM_GAS_PRICE, + value: newPrice, + } +} + +export function setCustomGasLimit (newLimit) { + return { + type: SET_CUSTOM_GAS_LIMIT, + value: newLimit, + } +} + +export function setCustomGasTotal (newTotal) { + return { + type: SET_CUSTOM_GAS_TOTAL, + value: newTotal, + } +} + +export function setCustomGasErrors (newErrors) { + return { + type: SET_CUSTOM_GAS_ERRORS, + value: newErrors, + } +} + +export function setApiEstimatesLastRetrieved (retrievalTime) { + return { + type: SET_API_ESTIMATES_LAST_RETRIEVED, + value: retrievalTime, + } +} + +export function setBasicApiEstimatesLastRetrieved (retrievalTime) { + return { + type: SET_BASIC_API_ESTIMATES_LAST_RETRIEVED, + value: retrievalTime, + } +} + +export function setBasicPriceEstimatesLastRetrieved (retrievalTime) { + return { + type: SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED, + value: retrievalTime, + } +} + +export function resetCustomGasState () { + return { type: RESET_CUSTOM_GAS_STATE } +} + +export function resetCustomData () { + return { type: RESET_CUSTOM_DATA } +} diff --git a/ui/app/reducers.js b/ui/app/ducks/index.js index e1a982f93..2d33edcfa 100644 --- a/ui/app/reducers.js +++ b/ui/app/ducks/index.js @@ -5,11 +5,12 @@ const copyToClipboard = require('copy-to-clipboard') // // Sub-Reducers take in the complete state and return their sub-state // -const reduceMetamask = require('./reducers/metamask') -const reduceApp = require('./reducers/app') -const reduceLocale = require('./reducers/locale') -const reduceSend = require('./ducks/send.duck').default -import reduceConfirmTransaction from './ducks/confirm-transaction.duck' +const reduceMetamask = require('./metamask/metamask') +const reduceApp = require('./app/app') +const reduceLocale = require('./locale/locale') +const reduceSend = require('./send/send.duck').default +import reduceConfirmTransaction from './confirm-transaction/confirm-transaction.duck' +import reduceGas from './gas/gas.duck' window.METAMASK_CACHED_LOG_STATE = null @@ -49,6 +50,8 @@ function rootReducer (state, action) { state.confirmTransaction = reduceConfirmTransaction(state, action) + state.gas = reduceGas(state, action) + window.METAMASK_CACHED_LOG_STATE = state return state } diff --git a/ui/app/reducers/locale.js b/ui/app/ducks/locale/locale.js index bdd97acb4..bb8e1b08e 100644 --- a/ui/app/reducers/locale.js +++ b/ui/app/ducks/locale/locale.js @@ -1,5 +1,5 @@ const extend = require('xtend') -const actions = require('../actions') +const actions = require('../../store/actions') module.exports = reduceMetamask diff --git a/ui/app/reducers/metamask.js b/ui/app/ducks/metamask/metamask.js index dfa2f3656..864229e83 100644 --- a/ui/app/reducers/metamask.js +++ b/ui/app/ducks/metamask/metamask.js @@ -1,9 +1,8 @@ const extend = require('xtend') -const actions = require('../actions') -const MetamascaraPlatform = require('../../../app/scripts/platforms/window') -const { getEnvironmentType } = require('../../../app/scripts/lib/util') -const { ENVIRONMENT_TYPE_POPUP } = require('../../../app/scripts/lib/enums') -const { OLD_UI_NETWORK_TYPE } = require('../../../app/scripts/controllers/network/enums') +const actions = require('../../store/actions') +const { getEnvironmentType } = require('../../../../app/scripts/lib/util') +const { ENVIRONMENT_TYPE_POPUP } = require('../../../../app/scripts/lib/enums') +const { OLD_UI_NETWORK_TYPE } = require('../../../../app/scripts/controllers/network/enums') module.exports = reduceMetamask @@ -15,7 +14,6 @@ function reduceMetamask (state, action) { isInitialized: false, isUnlocked: false, isAccountMenuOpen: false, - isMascara: window.platform instanceof MetamascaraPlatform, isPopup: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP, rpcTarget: 'https://rawtestrpc.metamask.io/', identities: {}, @@ -33,10 +31,10 @@ function reduceMetamask (state, action) { gasLimit: null, gasPrice: null, gasTotal: null, - tokenBalance: null, + tokenBalance: '0x0', from: '', to: '', - amount: '0x0', + amount: '0', memo: '', errors: {}, maxModeOn: false, @@ -53,7 +51,13 @@ function reduceMetamask (state, action) { currentLocale: '', preferences: { useNativeCurrencyAsPrimaryCurrency: true, + showFiatInTestnets: false, }, + firstTimeFlowType: null, + completedOnboarding: false, + knownMethodData: {}, + participateInMetaMetrics: null, + metaMetricsSendCount: 0, }, state.metamask) switch (action.type) { @@ -335,6 +339,16 @@ function reduceMetamask (state, action) { coinOptions, }) + case actions.SET_PARTICIPATE_IN_METAMETRICS: + return extend(metamaskState, { + participateInMetaMetrics: action.value, + }) + + case actions.SET_METAMETRICS_SEND_COUNT: + return extend(metamaskState, { + metaMetricsSendCount: action.value, + }) + case actions.SET_USE_BLOCKIE: return extend(metamaskState, { useBlockie: action.value, @@ -373,7 +387,28 @@ function reduceMetamask (state, action) { case actions.UPDATE_PREFERENCES: { return extend(metamaskState, { - preferences: { ...action.payload }, + preferences: { + ...metamaskState.preferences, + ...action.payload, + }, + }) + } + + case actions.COMPLETE_ONBOARDING: { + return extend(metamaskState, { + completedOnboarding: true, + }) + } + + case actions.COMPLETE_UI_MIGRATION: { + return extend(metamaskState, { + completedUiMigration: true, + }) + } + + case actions.SET_FIRST_TIME_FLOW_TYPE: { + return extend(metamaskState, { + firstTimeFlowType: action.value, }) } diff --git a/ui/app/ducks/tests/send-duck.test.js b/ui/app/ducks/send/send-duck.test.js index c101132d9..92c8dffd8 100644 --- a/ui/app/ducks/tests/send-duck.test.js +++ b/ui/app/ducks/send/send-duck.test.js @@ -1,12 +1,13 @@ import assert from 'assert' import SendReducer, { - openFromDropdown, - closeFromDropdown, openToDropdown, closeToDropdown, updateSendErrors, -} from '../send.duck.js' + showGasButtonGroup, + hideGasButtonGroup, + updateSendWarnings, +} from './send.duck.js' describe('Send Duck', () => { const mockState = { @@ -18,13 +19,18 @@ describe('Send Duck', () => { fromDropdownOpen: false, toDropdownOpen: false, errors: {}, + gasButtonGroupShown: true, + warnings: {}, } const OPEN_FROM_DROPDOWN = 'metamask/send/OPEN_FROM_DROPDOWN' const CLOSE_FROM_DROPDOWN = 'metamask/send/CLOSE_FROM_DROPDOWN' const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN' const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN' const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS' + const UPDATE_SEND_WARNINGS = 'metamask/send/UPDATE_SEND_WARNINGS' const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE' + const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP' + const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP' describe('SendReducer()', () => { it('should initialize state', () => { @@ -85,6 +91,24 @@ describe('Send Duck', () => { ) }) + it('should set gasButtonGroupShown to true when receiving a SHOW_GAS_BUTTON_GROUP action', () => { + assert.deepEqual( + SendReducer(Object.assign({}, mockState, { gasButtonGroupShown: false }), { + type: SHOW_GAS_BUTTON_GROUP, + }), + Object.assign({gasButtonGroupShown: true}, mockState.send) + ) + }) + + it('should set gasButtonGroupShown to false when receiving a HIDE_GAS_BUTTON_GROUP action', () => { + assert.deepEqual( + SendReducer(mockState, { + type: HIDE_GAS_BUTTON_GROUP, + }), + Object.assign({gasButtonGroupShown: false}, mockState.send) + ) + }) + it('should extend send.errors with the value of a UPDATE_SEND_ERRORS action', () => { const modifiedMockState = Object.assign({}, mockState, { send: { @@ -117,31 +141,31 @@ describe('Send Duck', () => { }) }) - describe('openFromDropdown', () => { + describe('openToDropdown', () => { assert.deepEqual( - openFromDropdown(), - { type: OPEN_FROM_DROPDOWN } + openToDropdown(), + { type: OPEN_TO_DROPDOWN } ) }) - describe('closeFromDropdown', () => { + describe('closeToDropdown', () => { assert.deepEqual( - closeFromDropdown(), - { type: CLOSE_FROM_DROPDOWN } + closeToDropdown(), + { type: CLOSE_TO_DROPDOWN } ) }) - describe('openToDropdown', () => { + describe('showGasButtonGroup', () => { assert.deepEqual( - openToDropdown(), - { type: OPEN_TO_DROPDOWN } + showGasButtonGroup(), + { type: SHOW_GAS_BUTTON_GROUP } ) }) - describe('closeToDropdown', () => { + describe('hideGasButtonGroup', () => { assert.deepEqual( - closeToDropdown(), - { type: CLOSE_TO_DROPDOWN } + hideGasButtonGroup(), + { type: HIDE_GAS_BUTTON_GROUP } ) }) @@ -152,4 +176,11 @@ describe('Send Duck', () => { ) }) + describe('updateSendWarnings', () => { + assert.deepEqual( + updateSendWarnings('mockWarningObject'), + { type: UPDATE_SEND_WARNINGS, value: 'mockWarningObject' } + ) + }) + }) diff --git a/ui/app/ducks/send.duck.js b/ui/app/ducks/send/send.duck.js index db01bbaa9..90e92140b 100644 --- a/ui/app/ducks/send.duck.js +++ b/ui/app/ducks/send/send.duck.js @@ -6,13 +6,18 @@ const CLOSE_FROM_DROPDOWN = 'metamask/send/CLOSE_FROM_DROPDOWN' const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN' const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN' const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS' +const UPDATE_SEND_WARNINGS = 'metamask/send/UPDATE_SEND_WARNINGS' const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE' +const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP' +const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP' // TODO: determine if this approach to initState is consistent with conventional ducks pattern const initState = { fromDropdownOpen: false, toDropdownOpen: false, + gasButtonGroupShown: true, errors: {}, + warnings: {}, } // Reducer @@ -43,6 +48,21 @@ export default function reducer ({ send: sendState = initState }, action = {}) { ...action.value, }, }) + case UPDATE_SEND_WARNINGS: + return extend(newState, { + warnings: { + ...newState.warnings, + ...action.value, + }, + }) + case SHOW_GAS_BUTTON_GROUP: + return extend(newState, { + gasButtonGroupShown: true, + }) + case HIDE_GAS_BUTTON_GROUP: + return extend(newState, { + gasButtonGroupShown: false, + }) case RESET_SEND_STATE: return extend({}, initState) default: @@ -51,14 +71,6 @@ export default function reducer ({ send: sendState = initState }, action = {}) { } // Action Creators -export function openFromDropdown () { - return { type: OPEN_FROM_DROPDOWN } -} - -export function closeFromDropdown () { - return { type: CLOSE_FROM_DROPDOWN } -} - export function openToDropdown () { return { type: OPEN_TO_DROPDOWN } } @@ -67,6 +79,14 @@ export function closeToDropdown () { return { type: CLOSE_TO_DROPDOWN } } +export function showGasButtonGroup () { + return { type: SHOW_GAS_BUTTON_GROUP } +} + +export function hideGasButtonGroup () { + return { type: HIDE_GAS_BUTTON_GROUP } +} + export function updateSendErrors (errorObject) { return { type: UPDATE_SEND_ERRORS, @@ -74,6 +94,13 @@ export function updateSendErrors (errorObject) { } } +export function updateSendWarnings (warningObject) { + return { + type: UPDATE_SEND_WARNINGS, + value: warningObject, + } +} + export function resetSendState () { return { type: RESET_SEND_STATE } } diff --git a/ui/app/first-time/init-menu.js b/ui/app/first-time/init-menu.js deleted file mode 100644 index e7bbfb225..000000000 --- a/ui/app/first-time/init-menu.js +++ /dev/null @@ -1,224 +0,0 @@ -const { EventEmitter } = require('events') -const { Component } = require('react') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const Mascot = require('../components/mascot') -const actions = require('../actions') -const Tooltip = require('../components/tooltip') -const getCaretCoordinates = require('textarea-caret') -const { RESTORE_VAULT_ROUTE, DEFAULT_ROUTE } = require('../routes') -const { getEnvironmentType } = require('../../../app/scripts/lib/util') -const { ENVIRONMENT_TYPE_POPUP } = require('../../../app/scripts/lib/enums') - -class InitializeMenuScreen extends Component { - constructor (props) { - super(props) - - this.animationEventEmitter = new EventEmitter() - this.state = { - warning: null, - } - } - - componentWillMount () { - const { isInitialized, isUnlocked, history } = this.props - if (isInitialized || isUnlocked) { - history.push(DEFAULT_ROUTE) - } - } - - componentDidMount () { - document.getElementById('password-box').focus() - } - - render () { - const { warning } = this.state - - return ( - h('.initialize-screen.flex-column.flex-center', [ - - h(Mascot, { - animationEventEmitter: this.animationEventEmitter, - }), - - h('h1', { - style: { - fontSize: '1.3em', - textTransform: 'uppercase', - color: '#7F8082', - marginBottom: 10, - }, - }, this.context.t('appName')), - - h('div', [ - h('h3', { - style: { - fontSize: '0.8em', - color: '#7F8082', - display: 'inline', - }, - }, this.context.t('encryptNewDen')), - - h(Tooltip, { - title: this.context.t('denExplainer'), - }, [ - h('i.fa.fa-question-circle.pointer', { - style: { - fontSize: '18px', - position: 'relative', - color: 'rgb(247, 134, 28)', - top: '2px', - marginLeft: '4px', - }, - }), - ]), - ]), - - h('span.error.in-progress-notification', warning), - - // password - h('input.large-input.letter-spacey', { - type: 'password', - id: 'password-box', - placeholder: this.context.t('newPassword'), - onInput: this.inputChanged.bind(this), - style: { - width: 260, - marginTop: 12, - }, - }), - - // confirm password - h('input.large-input.letter-spacey', { - type: 'password', - id: 'password-box-confirm', - placeholder: this.context.t('confirmPassword'), - onKeyPress: this.createVaultOnEnter.bind(this), - onInput: this.inputChanged.bind(this), - style: { - width: 260, - marginTop: 16, - }, - }), - - - h('button.primary', { - onClick: this.createNewVaultAndKeychain.bind(this), - style: { - margin: 12, - }, - }, this.context.t('createDen')), - - h('.flex-row.flex-center.flex-grow', [ - h('p.pointer', { - onClick: () => this.showRestoreVault(), - style: { - fontSize: '0.8em', - color: 'rgb(247, 134, 28)', - textDecoration: 'underline', - }, - }, this.context.t('importDen')), - ]), - - h('.flex-row.flex-center.flex-grow', [ - h('p.pointer', { - onClick: this.showOldUI.bind(this), - style: { - fontSize: '0.8em', - color: '#aeaeae', - textDecoration: 'underline', - marginTop: '32px', - }, - }, this.context.t('classicInterface')), - ]), - - ]) - ) - } - - createVaultOnEnter (event) { - if (event.key === 'Enter') { - event.preventDefault() - this.createNewVaultAndKeychain() - } - } - - createNewVaultAndKeychain () { - const { history } = this.props - var passwordBox = document.getElementById('password-box') - var password = passwordBox.value - var passwordConfirmBox = document.getElementById('password-box-confirm') - var passwordConfirm = passwordConfirmBox.value - - this.setState({ warning: null }) - - if (password.length < 8) { - this.setState({ warning: this.context.t('passwordShort') }) - return - } - - if (password !== passwordConfirm) { - this.setState({ warning: this.context.t('passwordMismatch') }) - return - } - - this.props.createNewVaultAndKeychain(password) - .then(() => history.push(DEFAULT_ROUTE)) - } - - inputChanged (event) { - // tell mascot to look at page action - var element = event.target - var boundingRect = element.getBoundingClientRect() - var coordinates = getCaretCoordinates(element, element.selectionEnd) - this.animationEventEmitter.emit('point', { - x: boundingRect.left + coordinates.left - element.scrollLeft, - y: boundingRect.top + coordinates.top - element.scrollTop, - }) - } - - showRestoreVault () { - this.props.markPasswordForgotten() - if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { - global.platform.openExtensionInBrowser() - } - - this.props.history.push(RESTORE_VAULT_ROUTE) - } - - showOldUI () { - this.props.dispatch(actions.setFeatureFlag('betaUI', false, 'OLD_UI_NOTIFICATION_MODAL')) - } -} - -InitializeMenuScreen.propTypes = { - history: PropTypes.object, - isInitialized: PropTypes.bool, - isUnlocked: PropTypes.bool, - createNewVaultAndKeychain: PropTypes.func, - markPasswordForgotten: PropTypes.func, - dispatch: PropTypes.func, -} - -InitializeMenuScreen.contextTypes = { - t: PropTypes.func, -} - -const mapStateToProps = state => { - const { metamask: { isInitialized, isUnlocked } } = state - - return { - isInitialized, - isUnlocked, - } -} - -const mapDispatchToProps = dispatch => { - return { - createNewVaultAndKeychain: password => dispatch(actions.createNewVaultAndKeychain(password)), - markPasswordForgotten: () => dispatch(actions.markPasswordForgotten()), - } -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(InitializeMenuScreen) diff --git a/ui/app/constants/common.js b/ui/app/helpers/constants/common.js index 4ff4dc837..58fae5e5f 100644 --- a/ui/app/constants/common.js +++ b/ui/app/helpers/constants/common.js @@ -4,3 +4,10 @@ export const WEI = 'WEI' export const PRIMARY = 'PRIMARY' export const SECONDARY = 'SECONDARY' + +export const NETWORK_TYPES = { + KOVAN: 'kovan', + MAINNET: 'mainnet', + RINKEBY: 'rinkeby', + ROPSTEN: 'ropsten', +} diff --git a/ui/app/constants/error-keys.js b/ui/app/helpers/constants/error-keys.js index 704064c96..704064c96 100644 --- a/ui/app/constants/error-keys.js +++ b/ui/app/helpers/constants/error-keys.js diff --git a/ui/app/infura-conversion.json b/ui/app/helpers/constants/infura-conversion.json index 9a96fe069..9a96fe069 100644 --- a/ui/app/infura-conversion.json +++ b/ui/app/helpers/constants/infura-conversion.json diff --git a/ui/app/routes.js b/ui/app/helpers/constants/routes.js index 76afed5db..c15027ff4 100644 --- a/ui/app/routes.js +++ b/ui/app/helpers/constants/routes.js @@ -1,8 +1,15 @@ const DEFAULT_ROUTE = '/' const UNLOCK_ROUTE = '/unlock' +const LOCK_ROUTE = '/lock' const SETTINGS_ROUTE = '/settings' +const GENERAL_ROUTE = '/settings/general' const INFO_ROUTE = '/settings/info' +const ADVANCED_ROUTE = '/settings/advanced' +const SECURITY_ROUTE = '/settings/security' +const COMPANY_ROUTE = '/settings/company' +const ABOUT_US_ROUTE = '/settings/about-us' const REVEAL_SEED_ROUTE = '/seed' +const MOBILE_SYNC_ROUTE = '/mobile-sync' const CONFIRM_SEED_ROUTE = '/confirm-seed' const RESTORE_VAULT_ROUTE = '/restore-vault' const ADD_TOKEN_ROUTE = '/add-token' @@ -14,14 +21,20 @@ const CONNECT_HARDWARE_ROUTE = '/new-account/connect' const SEND_ROUTE = '/send' const NOTICE_ROUTE = '/notice' const WELCOME_ROUTE = '/welcome' + const INITIALIZE_ROUTE = '/initialize' +const INITIALIZE_WELCOME_ROUTE = '/initialize/welcome' +const INITIALIZE_UNLOCK_ROUTE = '/initialize/unlock' const INITIALIZE_CREATE_PASSWORD_ROUTE = '/initialize/create-password' -const INITIALIZE_IMPORT_ACCOUNT_ROUTE = '/initialize/import-account' -const INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE = '/initialize/import-with-seed-phrase' -const INITIALIZE_UNIQUE_IMAGE_ROUTE = '/initialize/unique-image' +const INITIALIZE_IMPORT_ACCOUNT_ROUTE = '/initialize/create-password/import-account' +const INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE = '/initialize/create-password/import-with-seed-phrase' +const INITIALIZE_UNIQUE_IMAGE_ROUTE = '/initialize/create-password/unique-image' const INITIALIZE_NOTICE_ROUTE = '/initialize/notice' -const INITIALIZE_BACKUP_PHRASE_ROUTE = '/initialize/backup-phrase' -const INITIALIZE_CONFIRM_SEED_ROUTE = '/initialize/confirm-phrase' +const INITIALIZE_SELECT_ACTION_ROUTE = '/initialize/select-action' +const INITIALIZE_SEED_PHRASE_ROUTE = '/initialize/seed-phrase' +const INITIALIZE_END_OF_FLOW_ROUTE = '/initialize/end-of-flow' +const INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE = '/initialize/seed-phrase/confirm' +const INITIALIZE_METAMETRICS_OPT_IN_ROUTE = '/initialize/metametrics-opt-in' const CONFIRM_TRANSACTION_ROUTE = '/confirm-transaction' const CONFIRM_SEND_ETHER_PATH = '/send-ether' @@ -35,9 +48,11 @@ const SIGNATURE_REQUEST_PATH = '/signature-request' module.exports = { DEFAULT_ROUTE, UNLOCK_ROUTE, + LOCK_ROUTE, SETTINGS_ROUTE, INFO_ROUTE, REVEAL_SEED_ROUTE, + MOBILE_SYNC_ROUTE, CONFIRM_SEED_ROUTE, RESTORE_VAULT_ROUTE, ADD_TOKEN_ROUTE, @@ -50,13 +65,17 @@ module.exports = { NOTICE_ROUTE, WELCOME_ROUTE, INITIALIZE_ROUTE, + INITIALIZE_WELCOME_ROUTE, + INITIALIZE_UNLOCK_ROUTE, INITIALIZE_CREATE_PASSWORD_ROUTE, INITIALIZE_IMPORT_ACCOUNT_ROUTE, INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, INITIALIZE_UNIQUE_IMAGE_ROUTE, INITIALIZE_NOTICE_ROUTE, - INITIALIZE_BACKUP_PHRASE_ROUTE, - INITIALIZE_CONFIRM_SEED_ROUTE, + INITIALIZE_SELECT_ACTION_ROUTE, + INITIALIZE_SEED_PHRASE_ROUTE, + INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE, + INITIALIZE_END_OF_FLOW_ROUTE, CONFIRM_TRANSACTION_ROUTE, CONFIRM_SEND_ETHER_PATH, CONFIRM_SEND_TOKEN_PATH, @@ -65,4 +84,10 @@ module.exports = { CONFIRM_TRANSFER_FROM_PATH, CONFIRM_TOKEN_METHOD_PATH, SIGNATURE_REQUEST_PATH, + INITIALIZE_METAMETRICS_OPT_IN_ROUTE, + ADVANCED_ROUTE, + SECURITY_ROUTE, + COMPANY_ROUTE, + GENERAL_ROUTE, + ABOUT_US_ROUTE, } diff --git a/ui/app/constants/transactions.js b/ui/app/helpers/constants/transactions.js index 2dc061091..d0a819b9b 100644 --- a/ui/app/constants/transactions.js +++ b/ui/app/helpers/constants/transactions.js @@ -6,6 +6,7 @@ export const SUBMITTED_STATUS = 'submitted' export const CONFIRMED_STATUS = 'confirmed' export const FAILED_STATUS = 'failed' export const DROPPED_STATUS = 'dropped' +export const CANCELLED_STATUS = 'cancelled' export const TOKEN_METHOD_TRANSFER = 'transfer' export const TOKEN_METHOD_APPROVE = 'approve' @@ -17,7 +18,7 @@ export const APPROVE_ACTION_KEY = 'approve' export const SEND_TOKEN_ACTION_KEY = 'sentTokens' export const TRANSFER_FROM_ACTION_KEY = 'transferFrom' export const SIGNATURE_REQUEST_KEY = 'signatureRequest' -export const UNKNOWN_FUNCTION_KEY = 'unknownFunction' +export const CONTRACT_INTERACTION_KEY = 'contractInteraction' export const CANCEL_ATTEMPT_ACTION_KEY = 'cancelAttempt' export const TRANSACTION_TYPE_SHAPESHIFT = 'shapeshift' diff --git a/ui/app/helpers/higher-order-components/authenticated/authenticated.component.js b/ui/app/helpers/higher-order-components/authenticated/authenticated.component.js new file mode 100644 index 000000000..c195d0e21 --- /dev/null +++ b/ui/app/helpers/higher-order-components/authenticated/authenticated.component.js @@ -0,0 +1,22 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Redirect, Route } from 'react-router-dom' +import { UNLOCK_ROUTE, INITIALIZE_ROUTE } from '../../constants/routes' + +export default function Authenticated (props) { + const { isUnlocked, completedOnboarding } = props + + switch (true) { + case isUnlocked && completedOnboarding: + return <Route { ...props } /> + case !completedOnboarding: + return <Redirect to={{ pathname: INITIALIZE_ROUTE }} /> + default: + return <Redirect to={{ pathname: UNLOCK_ROUTE }} /> + } +} + +Authenticated.propTypes = { + isUnlocked: PropTypes.bool, + completedOnboarding: PropTypes.bool, +} diff --git a/ui/app/helpers/higher-order-components/authenticated/authenticated.container.js b/ui/app/helpers/higher-order-components/authenticated/authenticated.container.js new file mode 100644 index 000000000..6124b0fcd --- /dev/null +++ b/ui/app/helpers/higher-order-components/authenticated/authenticated.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import Authenticated from './authenticated.component' + +const mapStateToProps = state => { + const { metamask: { isUnlocked, completedOnboarding } } = state + return { + isUnlocked, + completedOnboarding, + } +} + +export default connect(mapStateToProps)(Authenticated) diff --git a/ui/app/helpers/higher-order-components/authenticated/index.js b/ui/app/helpers/higher-order-components/authenticated/index.js new file mode 100644 index 000000000..05632ed21 --- /dev/null +++ b/ui/app/helpers/higher-order-components/authenticated/index.js @@ -0,0 +1 @@ +export { default } from './authenticated.container' diff --git a/ui/app/i18n-provider.js b/ui/app/helpers/higher-order-components/i18n-provider.js index 3419474c4..0e34e17e0 100644 --- a/ui/app/i18n-provider.js +++ b/ui/app/helpers/higher-order-components/i18n-provider.js @@ -3,7 +3,7 @@ const connect = require('react-redux').connect const PropTypes = require('prop-types') const { withRouter } = require('react-router-dom') const { compose } = require('recompose') -const t = require('../i18n-helper').getMessage +const t = require('../utils/i18n-helper').getMessage class I18nProvider extends Component { tOrDefault = (key, defaultValue, ...args) => { diff --git a/ui/app/helpers/higher-order-components/initialized/index.js b/ui/app/helpers/higher-order-components/initialized/index.js new file mode 100644 index 000000000..863fcb389 --- /dev/null +++ b/ui/app/helpers/higher-order-components/initialized/index.js @@ -0,0 +1 @@ +export { default } from './initialized.container.js' diff --git a/ui/app/helpers/higher-order-components/initialized/initialized.component.js b/ui/app/helpers/higher-order-components/initialized/initialized.component.js new file mode 100644 index 000000000..2042c0046 --- /dev/null +++ b/ui/app/helpers/higher-order-components/initialized/initialized.component.js @@ -0,0 +1,14 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Redirect, Route } from 'react-router-dom' +import { INITIALIZE_ROUTE } from '../../constants/routes' + +export default function Initialized (props) { + return props.completedOnboarding + ? <Route { ...props } /> + : <Redirect to={{ pathname: INITIALIZE_ROUTE }} /> +} + +Initialized.propTypes = { + completedOnboarding: PropTypes.bool, +} diff --git a/ui/app/helpers/higher-order-components/initialized/initialized.container.js b/ui/app/helpers/higher-order-components/initialized/initialized.container.js new file mode 100644 index 000000000..0e7f72bcb --- /dev/null +++ b/ui/app/helpers/higher-order-components/initialized/initialized.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import Initialized from './initialized.component' + +const mapStateToProps = state => { + const { metamask: { completedOnboarding } } = state + + return { + completedOnboarding, + } +} + +export default connect(mapStateToProps)(Initialized) diff --git a/ui/app/helpers/higher-order-components/metametrics/metametrics.provider.js b/ui/app/helpers/higher-order-components/metametrics/metametrics.provider.js new file mode 100644 index 000000000..6086e03fb --- /dev/null +++ b/ui/app/helpers/higher-order-components/metametrics/metametrics.provider.js @@ -0,0 +1,106 @@ +import { Component } from 'react' +import { connect } from 'react-redux' +import PropTypes from 'prop-types' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import { + getCurrentNetworkId, + getSelectedAsset, + getAccountType, + getNumberOfAccounts, + getNumberOfTokens, +} from '../../../selectors/selectors' +import { + txDataSelector, +} from '../../../selectors/confirm-transaction' +import { getEnvironmentType } from '../../../../../app/scripts/lib/util' +import { + sendMetaMetricsEvent, + sendCountIsTrackable, +} from '../../utils/metametrics.util' + +class MetaMetricsProvider extends Component { + static propTypes = { + network: PropTypes.string.isRequired, + environmentType: PropTypes.string.isRequired, + activeCurrency: PropTypes.string.isRequired, + accountType: PropTypes.string.isRequired, + metaMetricsSendCount: PropTypes.number.isRequired, + children: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + } + + static childContextTypes = { + metricsEvent: PropTypes.func, + } + + constructor (props) { + super(props) + + this.state = { + previousPath: '', + currentPath: window.location.href, + } + + props.history.listen(locationObj => { + this.setState({ + previousPath: this.state.currentPath, + currentPath: window.location.href, + }) + }) + } + + getChildContext () { + const props = this.props + const { pathname } = location + const { previousPath, currentPath } = this.state + + return { + metricsEvent: (config = {}, overrides = {}) => { + const { eventOpts = {} } = config + const { name = '' } = eventOpts + const { pathname: overRidePathName = '' } = overrides + const isSendFlow = Boolean(name.match(/^send|^confirm/) || overRidePathName.match(/send|confirm/)) + + if (props.participateInMetaMetrics || config.isOptIn) { + return sendMetaMetricsEvent({ + ...props, + ...config, + previousPath, + currentPath, + pathname, + excludeMetaMetricsId: isSendFlow && !sendCountIsTrackable(props.metaMetricsSendCount + 1), + ...overrides, + }) + } + }, + } + } + + render () { + return this.props.children + } +} + +const mapStateToProps = state => { + const txData = txDataSelector(state) || {} + + return { + network: getCurrentNetworkId(state), + environmentType: getEnvironmentType(), + activeCurrency: getSelectedAsset(state), + accountType: getAccountType(state), + confirmTransactionOrigin: txData.origin, + metaMetricsId: state.metamask.metaMetricsId, + participateInMetaMetrics: state.metamask.participateInMetaMetrics, + metaMetricsSendCount: state.metamask.metaMetricsSendCount, + numberOfTokens: getNumberOfTokens(state), + numberOfAccounts: getNumberOfAccounts(state), + } +} + +module.exports = compose( + withRouter, + connect(mapStateToProps) +)(MetaMetricsProvider) + diff --git a/ui/app/higher-order-components/with-method-data/index.js b/ui/app/helpers/higher-order-components/with-method-data/index.js index f511e1ae7..f511e1ae7 100644 --- a/ui/app/higher-order-components/with-method-data/index.js +++ b/ui/app/helpers/higher-order-components/with-method-data/index.js diff --git a/ui/app/higher-order-components/with-method-data/with-method-data.component.js b/ui/app/helpers/higher-order-components/with-method-data/with-method-data.component.js index fed7d9865..efa9ad0a2 100644 --- a/ui/app/higher-order-components/with-method-data/with-method-data.component.js +++ b/ui/app/helpers/higher-order-components/with-method-data/with-method-data.component.js @@ -1,15 +1,18 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import { getMethodData } from '../../helpers/transactions.util' +import { getMethodData, getFourBytePrefix } from '../../utils/transactions.util' export default function withMethodData (WrappedComponent) { return class MethodDataWrappedComponent extends PureComponent { static propTypes = { transaction: PropTypes.object, + knownMethodData: PropTypes.object, + addKnownMethodData: PropTypes.func, } static defaultProps = { transaction: {}, + knownMethodData: {}, } state = { @@ -23,12 +26,22 @@ export default function withMethodData (WrappedComponent) { } async fetchMethodData () { - const { transaction } = this.props + const { transaction, knownMethodData, addKnownMethodData } = this.props const { txParams: { data = '' } = {} } = transaction if (data) { try { - const methodData = await getMethodData(data) + let methodData + const fourBytePrefix = getFourBytePrefix(data) + if (fourBytePrefix in knownMethodData) { + methodData = knownMethodData[fourBytePrefix] + } else { + methodData = await getMethodData(data) + if (!Object.entries(methodData).length === 0) { + addKnownMethodData(fourBytePrefix, methodData) + } + } + this.setState({ methodData, done: true }) } catch (error) { this.setState({ done: true, error }) diff --git a/ui/app/higher-order-components/with-modal-props/index.js b/ui/app/helpers/higher-order-components/with-modal-props/index.js index e476b51d2..e476b51d2 100644 --- a/ui/app/higher-order-components/with-modal-props/index.js +++ b/ui/app/helpers/higher-order-components/with-modal-props/index.js diff --git a/ui/app/higher-order-components/with-modal-props/tests/with-modal-props.test.js b/ui/app/helpers/higher-order-components/with-modal-props/tests/with-modal-props.test.js index 654e7062a..654e7062a 100644 --- a/ui/app/higher-order-components/with-modal-props/tests/with-modal-props.test.js +++ b/ui/app/helpers/higher-order-components/with-modal-props/tests/with-modal-props.test.js diff --git a/ui/app/higher-order-components/with-modal-props/with-modal-props.js b/ui/app/helpers/higher-order-components/with-modal-props/with-modal-props.js index 02f3855af..aac6b5a61 100644 --- a/ui/app/higher-order-components/with-modal-props/with-modal-props.js +++ b/ui/app/helpers/higher-order-components/with-modal-props/with-modal-props.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux' -import { hideModal } from '../../actions' +import { hideModal } from '../../../store/actions' const mapStateToProps = state => { const { appState } = state diff --git a/ui/app/higher-order-components/with-token-tracker/index.js b/ui/app/helpers/higher-order-components/with-token-tracker/index.js index d401e81f1..d401e81f1 100644 --- a/ui/app/higher-order-components/with-token-tracker/index.js +++ b/ui/app/helpers/higher-order-components/with-token-tracker/index.js diff --git a/ui/app/higher-order-components/with-token-tracker/with-token-tracker.component.js b/ui/app/helpers/higher-order-components/with-token-tracker/with-token-tracker.component.js index 36f6a6efd..36f6a6efd 100644 --- a/ui/app/higher-order-components/with-token-tracker/with-token-tracker.component.js +++ b/ui/app/helpers/higher-order-components/with-token-tracker/with-token-tracker.component.js diff --git a/ui/app/helpers/common.util.js b/ui/app/helpers/utils/common.util.js index 0c02481e6..0c02481e6 100644 --- a/ui/app/helpers/common.util.js +++ b/ui/app/helpers/utils/common.util.js diff --git a/ui/app/helpers/tests/common.util.test.js b/ui/app/helpers/utils/common.util.test.js index a52b91a10..6259f4a89 100644 --- a/ui/app/helpers/tests/common.util.test.js +++ b/ui/app/helpers/utils/common.util.test.js @@ -1,4 +1,4 @@ -import * as utils from '../common.util' +import * as utils from './common.util' import assert from 'assert' describe('Common utils', () => { diff --git a/ui/app/helpers/confirm-transaction/util.js b/ui/app/helpers/utils/confirm-tx.util.js index eb334a4b8..224560f5a 100644 --- a/ui/app/helpers/confirm-transaction/util.js +++ b/ui/app/helpers/utils/confirm-tx.util.js @@ -8,11 +8,11 @@ import { addCurrencies, multiplyCurrencies, conversionGreaterThan, -} from '../../conversion-util' +} from './conversion-util' import { unconfirmedTransactionsCountSelector } from '../../selectors/confirm-transaction' -export function increaseLastGasPrice (lastGasPrice) { +export function increaseLastGasPrice (lastGasPrice = '0x0') { return ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { multiplicandBase: 16, multiplierBase: 10, @@ -27,7 +27,7 @@ export function hexGreaterThan (a, b) { ) } -export function getHexGasTotal ({ gasLimit, gasPrice }) { +export function getHexGasTotal ({ gasLimit = '0x0', gasPrice = '0x0' }) { return ethUtil.addHexPrefix(multiplyCurrencies(gasLimit, gasPrice, { toNumericBase: 'hex', multiplicandBase: 16, @@ -95,7 +95,7 @@ export function formatCurrency (value, currencyCode) { const upperCaseCurrencyCode = currencyCode.toUpperCase() return currencies.find(currency => currency.code === upperCaseCurrencyCode) - ? currencyFormatter.format(Number(value), { code: upperCaseCurrencyCode }) + ? currencyFormatter.format(Number(value), { code: upperCaseCurrencyCode, style: 'currency' }) : value } diff --git a/ui/app/helpers/confirm-transaction/util.test.js b/ui/app/helpers/utils/confirm-tx.util.test.js index 4c1a3e16b..e818601ca 100644 --- a/ui/app/helpers/confirm-transaction/util.test.js +++ b/ui/app/helpers/utils/confirm-tx.util.test.js @@ -1,4 +1,4 @@ -import * as utils from './util' +import * as utils from './confirm-tx.util' import assert from 'assert' describe('Confirm Transaction utils', () => { diff --git a/ui/app/conversion-util.js b/ui/app/helpers/utils/conversion-util.js index f271b5683..8cc531773 100644 --- a/ui/app/conversion-util.js +++ b/ui/app/helpers/utils/conversion-util.js @@ -62,7 +62,7 @@ const toSpecifiedDenomination = { } const baseChange = { hex: n => n.toString(16), - dec: n => Number(n).toString(10), + dec: n => (new BigNumber(n)).toString(10), BN: n => new BN(n.toString(16)), } diff --git a/ui/app/conversion-util.test.js b/ui/app/helpers/utils/conversion-util.test.js index 368ce3bba..368ce3bba 100644 --- a/ui/app/conversion-util.test.js +++ b/ui/app/helpers/utils/conversion-util.test.js diff --git a/ui/app/helpers/conversions.util.js b/ui/app/helpers/utils/conversions.util.js index cb5e1b90b..b4ec50626 100644 --- a/ui/app/helpers/conversions.util.js +++ b/ui/app/helpers/utils/conversions.util.js @@ -1,6 +1,6 @@ import ethUtil from 'ethereumjs-util' -import { conversionUtil } from '../conversion-util' import { ETH, GWEI, WEI } from '../constants/common' +import { conversionUtil, addCurrencies } from './conversion-util' export function bnToHex (inputBn) { return ethUtil.addHexPrefix(inputBn.toString(16)) @@ -82,3 +82,41 @@ export function getWeiHexFromDecimalValue ({ toDenomination: WEI, }) } + +export function addHexWEIsToDec (aHexWEI, bHexWEI) { + return addCurrencies(aHexWEI, bHexWEI, { + aBase: 16, + bBase: 16, + fromDenomination: 'WEI', + numberOfDecimals: 6, + }) +} + +export function decEthToConvertedCurrency (ethTotal, convertedCurrency, conversionRate) { + return conversionUtil(ethTotal, { + fromNumericBase: 'dec', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency: convertedCurrency, + numberOfDecimals: 2, + conversionRate, + }) +} + +export function decGWEIToHexWEI (decGWEI) { + return conversionUtil(decGWEI, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + }) +} + +export function hexWEIToDecGWEI (decGWEI) { + return conversionUtil(decGWEI, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + toDenomination: 'GWEI', + }) +} diff --git a/ui/app/helpers/utils/formatters.js b/ui/app/helpers/utils/formatters.js new file mode 100644 index 000000000..106a2520d --- /dev/null +++ b/ui/app/helpers/utils/formatters.js @@ -0,0 +1,3 @@ +export function formatETHFee (ethFee) { + return ethFee + ' ETH' +} diff --git a/ui/app/helpers/utils/i18n-helper.js b/ui/app/helpers/utils/i18n-helper.js new file mode 100644 index 000000000..db07049e1 --- /dev/null +++ b/ui/app/helpers/utils/i18n-helper.js @@ -0,0 +1,44 @@ +// cross-browser connection to extension i18n API +const log = require('loglevel') + +/** + * Returns a localized message for the given key + * @param {object} locale The locale + * @param {string} key The message key + * @param {string[]} substitutions A list of message substitution replacements + * @return {null|string} The localized message + */ +const getMessage = (locale, key, substitutions) => { + if (!locale) { + return null + } + if (!locale[key]) { + log.warn(`Translator - Unable to find value for key "${key}"`) + return null + } + const entry = locale[key] + let phrase = entry.message + // perform substitutions + if (substitutions && substitutions.length) { + substitutions.forEach((substitution, index) => { + const regex = new RegExp(`\\$${index + 1}`, 'g') + phrase = phrase.replace(regex, substitution) + }) + } + return phrase +} + +async function fetchLocale (localeName) { + try { + const response = await fetch(`./_locales/${localeName}/messages.json`) + return await response.json() + } catch (error) { + log.error(`failed to fetch ${localeName} locale because of ${error}`) + return {} + } +} + +module.exports = { + getMessage, + fetchLocale, +} diff --git a/ui/app/helpers/utils/metametrics.util.js b/ui/app/helpers/utils/metametrics.util.js new file mode 100644 index 000000000..01984bd5e --- /dev/null +++ b/ui/app/helpers/utils/metametrics.util.js @@ -0,0 +1,184 @@ +/* eslint camelcase: 0 */ + +const ethUtil = require('ethereumjs-util') + +const inDevelopment = process.env.NODE_ENV === 'development' + +const METAMETRICS_BASE_URL = 'https://chromeextensionmm.innocraft.cloud/piwik.php' +const METAMETRICS_REQUIRED_PARAMS = `?idsite=${inDevelopment ? 1 : 2}&rec=1&apiv=1` +const METAMETRICS_BASE_FULL = METAMETRICS_BASE_URL + METAMETRICS_REQUIRED_PARAMS + +const METAMETRICS_TRACKING_URL = inDevelopment + ? 'http://www.metamask.io/metametrics' + : 'http://www.metamask.io/metametrics-prod' + +const METAMETRICS_CUSTOM_GAS_LIMIT_CHANGE = 'gasLimitChange' +const METAMETRICS_CUSTOM_GAS_PRICE_CHANGE = 'gasPriceChange' +const METAMETRICS_CUSTOM_FUNCTION_TYPE = 'functionType' +const METAMETRICS_CUSTOM_RECIPIENT_KNOWN = 'recipientKnown' +const METAMETRICS_CUSTOM_CONFIRM_SCREEN_ORIGIN = 'origin' +const METAMETRICS_CUSTOM_FROM_NETWORK = 'fromNetwork' +const METAMETRICS_CUSTOM_TO_NETWORK = 'toNetwork' +const METAMETRICS_CUSTOM_ERROR_FIELD = 'errorField' +const METAMETRICS_CUSTOM_ERROR_MESSAGE = 'errorMessage' +const METAMETRICS_CUSTOM_RPC_NETWORK_ID = 'networkId' +const METAMETRICS_CUSTOM_RPC_CHAIN_ID = 'chainId' + +const METAMETRICS_CUSTOM_NETWORK = 'network' +const METAMETRICS_CUSTOM_ENVIRONMENT_TYPE = 'environmentType' +const METAMETRICS_CUSTOM_ACTIVE_CURRENCY = 'activeCurrency' +const METAMETRICS_CUSTOM_ACCOUNT_TYPE = 'accountType' +const METAMETRICS_CUSTOM_NUMBER_OF_TOKENS = 'numberOfTokens' +const METAMETRICS_CUSTOM_NUMBER_OF_ACCOUNTS = 'numberOfAccounts' + +const customVariableNameIdMap = { + [METAMETRICS_CUSTOM_FUNCTION_TYPE]: 1, + [METAMETRICS_CUSTOM_RECIPIENT_KNOWN]: 2, + [METAMETRICS_CUSTOM_CONFIRM_SCREEN_ORIGIN]: 3, + [METAMETRICS_CUSTOM_GAS_LIMIT_CHANGE]: 4, + [METAMETRICS_CUSTOM_GAS_PRICE_CHANGE]: 5, + [METAMETRICS_CUSTOM_FROM_NETWORK]: 1, + [METAMETRICS_CUSTOM_TO_NETWORK]: 2, + [METAMETRICS_CUSTOM_RPC_NETWORK_ID]: 1, + [METAMETRICS_CUSTOM_RPC_CHAIN_ID]: 2, + [METAMETRICS_CUSTOM_ERROR_FIELD]: 1, + [METAMETRICS_CUSTOM_ERROR_MESSAGE]: 2, +} + +const customDimensionsNameIdMap = { + [METAMETRICS_CUSTOM_NETWORK]: 5, + [METAMETRICS_CUSTOM_ENVIRONMENT_TYPE]: 6, + [METAMETRICS_CUSTOM_ACTIVE_CURRENCY]: 7, + [METAMETRICS_CUSTOM_ACCOUNT_TYPE]: 8, + [METAMETRICS_CUSTOM_NUMBER_OF_TOKENS]: 9, + [METAMETRICS_CUSTOM_NUMBER_OF_ACCOUNTS]: 10, +} + +function composeUrlRefParamAddition (previousPath, confirmTransactionOrigin) { + const externalOrigin = confirmTransactionOrigin && confirmTransactionOrigin !== 'MetaMask' + return `&urlref=${externalOrigin ? 'EXTERNAL' : encodeURIComponent(previousPath.replace(/chrome-extension:\/\/\w+/, METAMETRICS_TRACKING_URL))}` +} + +function composeCustomDimensionParamAddition (customDimensions) { + const customDimensionParamStrings = Object.keys(customDimensions).reduce((acc, name) => { + return [...acc, `dimension${customDimensionsNameIdMap[name]}=${customDimensions[name]}`] + }, []) + return `&${customDimensionParamStrings.join('&')}` +} + +function composeCustomVarParamAddition (customVariables) { + const customVariableIdValuePairs = Object.keys(customVariables).reduce((acc, name) => { + return { + [customVariableNameIdMap[name]]: [name, customVariables[name]], + ...acc, + } + }, {}) + return `&cvar=${encodeURIComponent(JSON.stringify(customVariableIdValuePairs))}` +} + +function composeParamAddition (paramValue, paramName) { + return paramValue !== 0 && !paramValue + ? '' + : `&${paramName}=${paramValue}` +} + +function composeUrl (config, permissionPreferences = {}) { + const { + eventOpts = {}, + customVariables = '', + pageOpts = '', + network, + environmentType, + activeCurrency, + accountType, + numberOfTokens, + numberOfAccounts, + previousPath = '', + currentPath, + metaMetricsId, + confirmTransactionOrigin, + url: configUrl, + excludeMetaMetricsId, + isNewVisit, + } = config + const base = METAMETRICS_BASE_FULL + + const e_c = composeParamAddition(eventOpts.category, 'e_c') + const e_a = composeParamAddition(eventOpts.action, 'e_a') + const e_n = composeParamAddition(eventOpts.name, 'e_n') + const new_visit = isNewVisit ? `&new_visit=1` : '' + + const cvar = customVariables && composeCustomVarParamAddition(customVariables) || '' + + const action_name = '' + + const urlref = previousPath && composeUrlRefParamAddition(previousPath, confirmTransactionOrigin) + + const dimensions = !pageOpts.hideDimensions ? composeCustomDimensionParamAddition({ + network, + environmentType, + activeCurrency, + accountType, + numberOfTokens: customVariables && customVariables.numberOfTokens || numberOfTokens, + numberOfAccounts: customVariables && customVariables.numberOfAccounts || numberOfAccounts, + }) : '' + const url = configUrl || `&url=${encodeURIComponent(currentPath.replace(/chrome-extension:\/\/\w+/, METAMETRICS_TRACKING_URL))}` + const _id = metaMetricsId && !excludeMetaMetricsId ? `&_id=${metaMetricsId.slice(2, 18)}` : '' + const rand = `&rand=${String(Math.random()).slice(2)}` + const pv_id = `&pv_id=${ethUtil.bufferToHex(ethUtil.sha3(url || currentPath.match(/chrome-extension:\/\/\w+\/(.+)/)[0])).slice(2, 8)}` + const uid = metaMetricsId && !excludeMetaMetricsId + ? `&uid=${metaMetricsId.slice(2, 18)}` + : excludeMetaMetricsId + ? '&uid=0000000000000000' + : '' + + return [ base, e_c, e_a, e_n, cvar, action_name, urlref, dimensions, url, _id, rand, pv_id, uid, new_visit ].join('') +} + +export function sendMetaMetricsEvent (config, permissionPreferences) { + return fetch(composeUrl(config, permissionPreferences), { + 'headers': {}, + 'method': 'GET', + }) +} + +export function verifyUserPermission (config, props) { + const { + eventOpts = {}, + } = config + const { userPermissionPreferences } = props + const { + allowAll, + allowNone, + allowSendMetrics, + } = userPermissionPreferences + + if (allowNone) { + return false + } else if (allowAll) { + return true + } else if (allowSendMetrics && eventOpts.name === 'send') { + return true + } else { + return false + } +} + +const trackableSendCounts = { + 1: true, + 10: true, + 30: true, + 50: true, + 100: true, + 250: true, + 500: true, + 1000: true, + 2500: true, + 5000: true, + 10000: true, + 25000: true, +} + +export function sendCountIsTrackable (sendCount) { + return Boolean(trackableSendCounts[sendCount]) +} diff --git a/ui/app/token-util.js b/ui/app/helpers/utils/token-util.js index 6e4992763..35a19a69f 100644 --- a/ui/app/token-util.js +++ b/ui/app/helpers/utils/token-util.js @@ -109,7 +109,7 @@ export function tokenInfoGetter () { export function calcTokenAmount (value, decimals) { const multiplier = Math.pow(10, Number(decimals || 0)) - return new BigNumber(String(value)).div(multiplier).toNumber() + return new BigNumber(String(value)).div(multiplier) } export function getTokenValue (tokenParams = []) { diff --git a/ui/app/helpers/transactions.util.js b/ui/app/helpers/utils/transactions.util.js index 2f4b1d095..edf2e24f6 100644 --- a/ui/app/helpers/transactions.util.js +++ b/ui/app/helpers/utils/transactions.util.js @@ -2,6 +2,10 @@ import ethUtil from 'ethereumjs-util' import MethodRegistry from 'eth-method-registry' import abi from 'human-standard-token-abi' import abiDecoder from 'abi-decoder' +import { + TRANSACTION_TYPE_CANCEL, + TRANSACTION_STATUS_CONFIRMED, +} from '../../../../app/scripts/controllers/transactions/enums' import { TOKEN_METHOD_TRANSFER, @@ -13,11 +17,11 @@ import { SEND_TOKEN_ACTION_KEY, TRANSFER_FROM_ACTION_KEY, SIGNATURE_REQUEST_KEY, - UNKNOWN_FUNCTION_KEY, + CONTRACT_INTERACTION_KEY, CANCEL_ATTEMPT_ACTION_KEY, } from '../constants/transactions' -import { addCurrencies } from '../conversion-util' +import { addCurrencies } from './conversion-util' abiDecoder.addABI(abi) @@ -56,6 +60,18 @@ export function isConfirmDeployContract (txData = {}) { } /** + * Returns four-byte method signature from data + * + * @param {string} data - The hex data (@code txParams.data) of a transaction + * @returns {string} - The four-byte method signature + */ +export function getFourBytePrefix (data = '') { + const prefixedData = ethUtil.addHexPrefix(data) + const fourBytePrefix = prefixedData.slice(0, 10) + return fourBytePrefix +} + +/** * Returns the action of a transaction as a key to be passed into the translator. * @param {Object} transaction - txData object * @param {Object} methodData - Data returned from eth-method-registry @@ -87,7 +103,7 @@ export async function getTransactionActionKey (transaction, methodData) { const methodName = name && name.toLowerCase() if (!methodName) { - return UNKNOWN_FUNCTION_KEY + return CONTRACT_INTERACTION_KEY } switch (methodName) { @@ -148,12 +164,16 @@ export function sumHexes (...args) { * @returns {string} */ export function getStatusKey (transaction) { - const { txReceipt: { status } = {} } = transaction + const { txReceipt: { status: receiptStatus } = {}, type, status } = transaction // There was an on-chain failure - if (status === '0x0') { + if (receiptStatus === '0x0') { return 'failed' } + if (status === TRANSACTION_STATUS_CONFIRMED && type === TRANSACTION_TYPE_CANCEL) { + return 'cancelled' + } + return transaction.status } diff --git a/ui/app/helpers/tests/transactions.util.test.js b/ui/app/helpers/utils/transactions.util.test.js index 838522e35..4a8ca5c9d 100644 --- a/ui/app/helpers/tests/transactions.util.test.js +++ b/ui/app/helpers/utils/transactions.util.test.js @@ -1,4 +1,4 @@ -import * as utils from '../transactions.util' +import * as utils from './transactions.util' import assert from 'assert' describe('Transactions utils', () => { diff --git a/ui/app/util.js b/ui/app/helpers/utils/util.js index b19a028cc..c50d7cbe5 100644 --- a/ui/app/util.js +++ b/ui/app/helpers/utils/util.js @@ -1,15 +1,15 @@ 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 hexToBn = require('../../../../app/scripts/lib/hex-to-bn') +import { DateTime } from 'luxon' 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), '3/16/2014 at 14:30') +function formatDate (date, format = 'M/d/y \'at\' T') { + return DateTime.fromMillis(date).toFormat(format) } var valueTable = { @@ -60,6 +60,15 @@ module.exports = { getTokenAddressFromTokenObject, checksumAddress, addressSlicer, + isEthNetwork, +} + +function isEthNetwork (netId) { + if (!netId || netId === '1' || netId === '3' || netId === '4' || netId === '42' || netId === '5777') { + return true + } + + return false } function valuesFor (obj) { @@ -83,7 +92,7 @@ function miniAddressSummary (address) { return checked ? checked.slice(0, 4) + '...' + checked.slice(-4) : '...' } -function isValidAddress (address) { +function isValidAddress (address, network) { var prefixed = ethUtil.addHexPrefix(address) if (address === '0x0000000000000000000000000000000000000000') return false return (isAllOneCase(prefixed) && ethUtil.isValidAddress(prefixed)) || ethUtil.isValidChecksumAddress(prefixed) @@ -299,10 +308,13 @@ function getTokenAddressFromTokenObject (token) { * Safely checksumms a potentially-null address * * @param {String} [address] - address to checksum + * @param {String} [network] - network id * @returns {String} - checksummed address + * */ -function checksumAddress (address) { - return address ? ethUtil.toChecksumAddress(address) : '' +function checksumAddress (address, network) { + const checksummed = address ? ethUtil.toChecksumAddress(address) : '' + return checksummed } function addressSlicer (address = '') { diff --git a/ui/app/img/identicon-tardigrade.png b/ui/app/img/identicon-tardigrade.png Binary files differdeleted file mode 100644 index 1742a32b8..000000000 --- a/ui/app/img/identicon-tardigrade.png +++ /dev/null diff --git a/ui/app/img/identicon-walrus.png b/ui/app/img/identicon-walrus.png Binary files differdeleted file mode 100644 index d58fae912..000000000 --- a/ui/app/img/identicon-walrus.png +++ /dev/null diff --git a/ui/app/keychains/hd/create-vault-complete.js b/ui/app/keychains/hd/create-vault-complete.js deleted file mode 100644 index 5ab5d4c33..000000000 --- a/ui/app/keychains/hd/create-vault-complete.js +++ /dev/null @@ -1,91 +0,0 @@ -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 exportAsFile = require('../../util').exportAsFile - -module.exports = connect(mapStateToProps)(CreateVaultCompleteScreen) - -inherits(CreateVaultCompleteScreen, Component) -function CreateVaultCompleteScreen () { - Component.call(this) -} - -function mapStateToProps (state) { - return { - seed: state.appState.currentView.seedWords, - cachedSeed: state.metamask.seedWords, - } -} - -CreateVaultCompleteScreen.prototype.render = function () { - var state = this.props - var seed = state.seed || state.cachedSeed || '' - - return ( - - h('.initialize-screen.flex-column.flex-center.flex-grow', [ - - // // subtitle and nav - // h('.section-title.flex-row.flex-center', [ - // h('h2.page-subtitle', 'Vault Created'), - // ]), - - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginTop: 36, - marginBottom: 8, - width: '100%', - fontSize: '20px', - padding: 6, - }, - }, [ - 'Vault Created', - ]), - - h('div', { - style: { - fontSize: '1em', - marginTop: '10px', - textAlign: 'center', - }, - }, [ - h('span.error', 'These 12 words are the only way to restore your MetaMask accounts.\nSave them somewhere safe and secret.'), - ]), - - h('textarea.twelve-word-phrase', { - readOnly: true, - value: seed, - }), - - h('button.primary', { - onClick: () => this.confirmSeedWords() - .then(account => this.showAccountDetail(account)), - style: { - margin: '24px', - fontSize: '0.9em', - marginBottom: '10px', - }, - }, 'I\'ve copied it somewhere safe'), - - h('button.primary', { - onClick: () => exportAsFile(`MetaMask Seed Words`, seed), - style: { - margin: '10px', - fontSize: '0.9em', - }, - }, 'Save Seed Words As File'), - ]) - ) -} - -CreateVaultCompleteScreen.prototype.confirmSeedWords = function () { - return this.props.dispatch(actions.confirmSeedWords()) -} - -CreateVaultCompleteScreen.prototype.showAccountDetail = function (account) { - return this.props.dispatch(actions.showAccountDetail(account)) -} diff --git a/ui/app/keychains/hd/restore-vault.js b/ui/app/keychains/hd/restore-vault.js deleted file mode 100644 index 913d20505..000000000 --- a/ui/app/keychains/hd/restore-vault.js +++ /dev/null @@ -1,181 +0,0 @@ -const inherits = require('util').inherits -const PropTypes = require('prop-types') -const PersistentForm = require('../../../lib/persistent-form') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const actions = require('../../actions') -const log = require('loglevel') - -RestoreVaultScreen.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(RestoreVaultScreen) - - -inherits(RestoreVaultScreen, PersistentForm) -function RestoreVaultScreen () { - PersistentForm.call(this) -} - -function mapStateToProps (state) { - return { - warning: state.appState.warning, - forgottenPassword: state.appState.forgottenPassword, - } -} - -RestoreVaultScreen.prototype.render = function () { - var state = this.props - this.persistentFormParentId = 'restore-vault-form' - - return ( - - h('.initialize-screen.flex-column.flex-center.flex-grow', [ - - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginBottom: 24, - width: '100%', - fontSize: '20px', - padding: 6, - }, - }, [ - this.context.t('restoreVault'), - ]), - - // wallet seed entry - h('h3', this.context.t('walletSeed')), - h('textarea.twelve-word-phrase.letter-spacey', { - dataset: { - persistentFormId: 'wallet-seed', - }, - placeholder: this.context.t('secretPhrase'), - }), - - // password - h('input.large-input.letter-spacey', { - type: 'password', - id: 'password-box', - placeholder: this.context.t('newPassword8Chars'), - dataset: { - persistentFormId: 'password', - }, - style: { - width: 260, - marginTop: 12, - }, - }), - - // confirm password - h('input.large-input.letter-spacey', { - type: 'password', - id: 'password-box-confirm', - placeholder: this.context.t('confirmPassword'), - onKeyPress: this.createOnEnter.bind(this), - dataset: { - persistentFormId: 'password-confirmation', - }, - style: { - width: 260, - marginTop: 16, - }, - }), - - (state.warning) && ( - h('span.error.in-progress-notification', state.warning) - ), - - // submit - - h('.flex-row.flex-space-between', { - style: { - marginTop: 30, - width: '50%', - }, - }, [ - - // cancel - h('button.primary', { - onClick: this.showInitializeMenu.bind(this), - style: { - textTransform: 'uppercase', - }, - }, this.context.t('cancel')), - - // submit - h('button.primary', { - onClick: this.createNewVaultAndRestore.bind(this), - style: { - textTransform: 'uppercase', - }, - }, this.context.t('ok')), - ]), - ]) - ) -} - -RestoreVaultScreen.prototype.showInitializeMenu = function () { - const { dispatch, forgottenPassword } = this.props - dispatch(actions.unMarkPasswordForgotten()) - .then(() => { - if (forgottenPassword) { - dispatch(actions.backToUnlockView()) - } else { - dispatch(actions.showInitializeMenu()) - } - }) -} - -RestoreVaultScreen.prototype.createOnEnter = function (event) { - if (event.key === 'Enter') { - this.createNewVaultAndRestore() - } -} - -RestoreVaultScreen.prototype.createNewVaultAndRestore = function () { - // check password - var passwordBox = document.getElementById('password-box') - var password = passwordBox.value - var passwordConfirmBox = document.getElementById('password-box-confirm') - var passwordConfirm = passwordConfirmBox.value - if (password.length < 8) { - this.warning = this.context.t('passwordNotLongEnough') - - this.props.dispatch(actions.displayWarning(this.warning)) - return - } - if (password !== passwordConfirm) { - this.warning = this.context.t('passwordsDontMatch') - this.props.dispatch(actions.displayWarning(this.warning)) - return - } - // check seed - var seedBox = document.querySelector('textarea.twelve-word-phrase') - var seed = seedBox.value.trim() - - // true if the string has more than a space between words. - if (seed.split(' ').length > 1) { - this.warning = this.context.t('spaceBetween') - this.props.dispatch(actions.displayWarning(this.warning)) - return - } - // true if seed contains a character that is not between a-z or a space - if (!seed.match(/^[a-z ]+$/)) { - this.warning = this.context.t('loweCaseWords') - this.props.dispatch(actions.displayWarning(this.warning)) - return - } - if (seed.split(' ').length !== 12) { - this.warning = this.context.t('seedPhraseReq') - this.props.dispatch(actions.displayWarning(this.warning)) - return - } - // submit - this.warning = null - this.props.dispatch(actions.displayWarning(this.warning)) - this.props.dispatch(actions.createNewVaultAndRestore(password, seed)) - .catch(err => log.error(err.message)) -} diff --git a/ui/app/components/pages/add-token/add-token.component.js b/ui/app/pages/add-token/add-token.component.js index 3612e676c..40c1ff7fd 100644 --- a/ui/app/components/pages/add-token/add-token.component.js +++ b/ui/app/pages/add-token/add-token.component.js @@ -2,13 +2,13 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import ethUtil from 'ethereumjs-util' import { checkExistingAddresses } from './util' -import { tokenInfoGetter } from '../../../token-util' -import { DEFAULT_ROUTE, CONFIRM_ADD_TOKEN_ROUTE } from '../../../routes' -import TextField from '../../text-field' +import { tokenInfoGetter } from '../../helpers/utils/token-util' +import { DEFAULT_ROUTE, CONFIRM_ADD_TOKEN_ROUTE } from '../../helpers/constants/routes' +import TextField from '../../components/ui/text-field' import TokenList from './token-list' import TokenSearch from './token-search' -import PageContainer from '../../page-container' -import { Tabs, Tab } from '../../tabs' +import PageContainer from '../../components/ui/page-container' +import { Tabs, Tab } from '../../components/ui/tabs' const emptyAddr = '0x0000000000000000000000000000000000000000' const SEARCH_TAB = 'SEARCH' @@ -43,6 +43,7 @@ class AddToken extends Component { customDecimalsError: null, autoFilled: false, displayedTab: SEARCH_TAB, + forceEditSymbol: false, } } @@ -194,8 +195,8 @@ class AddToken extends Component { const symbolLength = customSymbol.length let customSymbolError = null - if (symbolLength <= 0 || symbolLength >= 10) { - customSymbolError = this.context.t('symbolBetweenZeroTen') + if (symbolLength <= 0 || symbolLength >= 12) { + customSymbolError = this.context.t('symbolBetweenZeroTwelve') } this.setState({ customSymbol, customSymbolError }) @@ -225,13 +226,14 @@ class AddToken extends Component { customSymbolError, customDecimalsError, autoFilled, + forceEditSymbol, } = this.state return ( <div className="add-token__custom-token-form"> <TextField id="custom-address" - label={this.context.t('tokenAddress')} + label={this.context.t('tokenContractAddress')} type="text" value={customAddress} onChange={e => this.handleCustomAddressChange(e.target.value)} @@ -241,14 +243,28 @@ class AddToken extends Component { /> <TextField id="custom-symbol" - label={this.context.t('tokenSymbol')} + label={( + <div className="add-token__custom-symbol__label-wrapper"> + <span className="add-token__custom-symbol__label"> + {this.context.t('tokenSymbol')} + </span> + {(autoFilled && !forceEditSymbol) && ( + <div + className="add-token__custom-symbol__edit" + onClick={() => this.setState({ forceEditSymbol: true })} + > + {this.context.t('edit')} + </div> + )} + </div> + )} type="text" value={customSymbol} onChange={e => this.handleCustomSymbolChange(e.target.value)} error={customSymbolError} fullWidth margin="normal" - disabled={autoFilled} + disabled={autoFilled && !forceEditSymbol} /> <TextField id="custom-decimals" diff --git a/ui/app/components/pages/add-token/add-token.container.js b/ui/app/pages/add-token/add-token.container.js index 87671b156..eee16dfc7 100644 --- a/ui/app/components/pages/add-token/add-token.container.js +++ b/ui/app/pages/add-token/add-token.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux' import AddToken from './add-token.component' -const { setPendingTokens, clearPendingTokens } = require('../../../actions') +const { setPendingTokens, clearPendingTokens } = require('../../store/actions') const mapStateToProps = ({ metamask }) => { const { identities, tokens, pendingTokens } = metamask diff --git a/ui/app/components/pages/add-token/index.js b/ui/app/pages/add-token/index.js index 3666cae82..3666cae82 100644 --- a/ui/app/components/pages/add-token/index.js +++ b/ui/app/pages/add-token/index.js diff --git a/ui/app/components/pages/add-token/index.scss b/ui/app/pages/add-token/index.scss index 39e86b97b..ef6802f96 100644 --- a/ui/app/components/pages/add-token/index.scss +++ b/ui/app/pages/add-token/index.scss @@ -1,4 +1,4 @@ -@import './token-list/index'; +@import 'token-list/index'; .add-token { &__custom-token-form { @@ -22,4 +22,24 @@ &__token-list { margin-top: 16px; } + + &__custom-symbol { + + &__label-wrapper { + display: flex; + flex-flow: row nowrap; + } + + &__label { + flex: 0 0 auto; + } + + &__edit { + flex: 1 1 auto; + text-align: right; + color: $curious-blue; + padding-right: 4px; + cursor: pointer; + } + } } diff --git a/ui/app/components/pages/add-token/token-list/index.js b/ui/app/pages/add-token/token-list/index.js index 21dd5ac72..21dd5ac72 100644 --- a/ui/app/components/pages/add-token/token-list/index.js +++ b/ui/app/pages/add-token/token-list/index.js diff --git a/ui/app/components/pages/add-token/token-list/index.scss b/ui/app/pages/add-token/token-list/index.scss index e32739d59..b7787a18e 100644 --- a/ui/app/components/pages/add-token/token-list/index.scss +++ b/ui/app/pages/add-token/token-list/index.scss @@ -1,4 +1,4 @@ -@import './token-list-placeholder/index'; +@import 'token-list-placeholder/index'; .token-list { &__title { diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js b/ui/app/pages/add-token/token-list/token-list-placeholder/index.js index b82f45e93..b82f45e93 100644 --- a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js +++ b/ui/app/pages/add-token/token-list/token-list-placeholder/index.js diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss b/ui/app/pages/add-token/token-list/token-list-placeholder/index.scss index cc495dfb0..cc495dfb0 100644 --- a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss +++ b/ui/app/pages/add-token/token-list/token-list-placeholder/index.scss diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js b/ui/app/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js index 20f550927..20f550927 100644 --- a/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js +++ b/ui/app/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js diff --git a/ui/app/components/pages/add-token/token-list/token-list.component.js b/ui/app/pages/add-token/token-list/token-list.component.js index 724a68d6e..724a68d6e 100644 --- a/ui/app/components/pages/add-token/token-list/token-list.component.js +++ b/ui/app/pages/add-token/token-list/token-list.component.js diff --git a/ui/app/components/pages/add-token/token-list/token-list.container.js b/ui/app/pages/add-token/token-list/token-list.container.js index cd7b07a37..cd7b07a37 100644 --- a/ui/app/components/pages/add-token/token-list/token-list.container.js +++ b/ui/app/pages/add-token/token-list/token-list.container.js diff --git a/ui/app/components/pages/add-token/token-search/index.js b/ui/app/pages/add-token/token-search/index.js index acaa6b084..acaa6b084 100644 --- a/ui/app/components/pages/add-token/token-search/index.js +++ b/ui/app/pages/add-token/token-search/index.js diff --git a/ui/app/components/pages/add-token/token-search/token-search.component.js b/ui/app/pages/add-token/token-search/token-search.component.js index 036b2db1e..5542a19ff 100644 --- a/ui/app/components/pages/add-token/token-search/token-search.component.js +++ b/ui/app/pages/add-token/token-search/token-search.component.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types' import contractMap from 'eth-contract-metadata' import Fuse from 'fuse.js' import InputAdornment from '@material-ui/core/InputAdornment' -import TextField from '../../../text-field' +import TextField from '../../../components/ui/text-field' const contractList = Object.entries(contractMap) .map(([ _, tokenData]) => tokenData) diff --git a/ui/app/components/pages/add-token/util.js b/ui/app/pages/add-token/util.js index 579c56cc0..579c56cc0 100644 --- a/ui/app/components/pages/add-token/util.js +++ b/ui/app/pages/add-token/util.js diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js b/ui/app/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js index ee5d6fa64..7edb8f541 100644 --- a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js +++ b/ui/app/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js @@ -1,9 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import { DEFAULT_ROUTE } from '../../../routes' -import Button from '../../button' -import Identicon from '../../../components/identicon' -import TokenBalance from '../../token-balance' +import { DEFAULT_ROUTE } from '../../helpers/constants/routes' +import Button from '../../components/ui/button' +import Identicon from '../../components/ui/identicon' +import TokenBalance from '../../components/ui/token-balance' export default class ConfirmAddSuggestedToken extends Component { static contextTypes = { diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js b/ui/app/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js index 1f2737e52..a90fe148f 100644 --- a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js +++ b/ui/app/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js @@ -5,7 +5,7 @@ import { withRouter } from 'react-router-dom' const extend = require('xtend') -const { addToken, removeSuggestedTokens } = require('../../../actions') +const { addToken, removeSuggestedTokens } = require('../../store/actions') const mapStateToProps = ({ metamask }) => { const { pendingTokens, suggestedTokens } = metamask diff --git a/ui/app/components/pages/confirm-add-suggested-token/index.js b/ui/app/pages/confirm-add-suggested-token/index.js index 2ca56b43c..2ca56b43c 100644 --- a/ui/app/components/pages/confirm-add-suggested-token/index.js +++ b/ui/app/pages/confirm-add-suggested-token/index.js diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js b/ui/app/pages/confirm-add-token/confirm-add-token.component.js index d3fec79d7..c0ec624ac 100644 --- a/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js +++ b/ui/app/pages/confirm-add-token/confirm-add-token.component.js @@ -1,9 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import { DEFAULT_ROUTE, ADD_TOKEN_ROUTE } from '../../../routes' -import Button from '../../button' -import Identicon from '../../identicon' -import TokenBalance from '../../token-balance' +import { DEFAULT_ROUTE, ADD_TOKEN_ROUTE } from '../../helpers/constants/routes' +import Button from '../../components/ui/button' +import Identicon from '../../components/ui/identicon' +import TokenBalance from '../../components/ui/token-balance' export default class ConfirmAddToken extends Component { static contextTypes = { diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js b/ui/app/pages/confirm-add-token/confirm-add-token.container.js index 0190024d9..961626177 100644 --- a/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js +++ b/ui/app/pages/confirm-add-token/confirm-add-token.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux' import ConfirmAddToken from './confirm-add-token.component' -const { addTokens, clearPendingTokens } = require('../../../actions') +const { addTokens, clearPendingTokens } = require('../../store/actions') const mapStateToProps = ({ metamask }) => { const { pendingTokens } = metamask diff --git a/ui/app/components/pages/confirm-add-token/index.js b/ui/app/pages/confirm-add-token/index.js index b7decabec..b7decabec 100644 --- a/ui/app/components/pages/confirm-add-token/index.js +++ b/ui/app/pages/confirm-add-token/index.js diff --git a/ui/app/components/pages/confirm-add-token/index.scss b/ui/app/pages/confirm-add-token/index.scss index 66146cf78..66146cf78 100644 --- a/ui/app/components/pages/confirm-add-token/index.scss +++ b/ui/app/pages/confirm-add-token/index.scss diff --git a/ui/app/components/pages/confirm-approve/confirm-approve.component.js b/ui/app/pages/confirm-approve/confirm-approve.component.js index b71eaa1d4..b71eaa1d4 100644 --- a/ui/app/components/pages/confirm-approve/confirm-approve.component.js +++ b/ui/app/pages/confirm-approve/confirm-approve.component.js diff --git a/ui/app/components/pages/confirm-approve/confirm-approve.container.js b/ui/app/pages/confirm-approve/confirm-approve.container.js index 4ef9f4ced..5f8bb8f0b 100644 --- a/ui/app/components/pages/confirm-approve/confirm-approve.container.js +++ b/ui/app/pages/confirm-approve/confirm-approve.container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux' import ConfirmApprove from './confirm-approve.component' -import { approveTokenAmountAndToAddressSelector } from '../../../selectors/confirm-transaction' +import { approveTokenAmountAndToAddressSelector } from '../../selectors/confirm-transaction' const mapStateToProps = state => { const { confirmTransaction: { tokenProps: { tokenSymbol } = {} } } = state diff --git a/ui/app/components/pages/confirm-approve/index.js b/ui/app/pages/confirm-approve/index.js index 791297be7..791297be7 100644 --- a/ui/app/components/pages/confirm-approve/index.js +++ b/ui/app/pages/confirm-approve/index.js diff --git a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js b/ui/app/pages/confirm-deploy-contract/confirm-deploy-contract.component.js index 9bc0daab9..9bc0daab9 100644 --- a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js +++ b/ui/app/pages/confirm-deploy-contract/confirm-deploy-contract.component.js diff --git a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js b/ui/app/pages/confirm-deploy-contract/confirm-deploy-contract.container.js index 336ee83ea..336ee83ea 100644 --- a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js +++ b/ui/app/pages/confirm-deploy-contract/confirm-deploy-contract.container.js diff --git a/ui/app/components/pages/confirm-deploy-contract/index.js b/ui/app/pages/confirm-deploy-contract/index.js index c4fb01b52..c4fb01b52 100644 --- a/ui/app/components/pages/confirm-deploy-contract/index.js +++ b/ui/app/pages/confirm-deploy-contract/index.js diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js b/ui/app/pages/confirm-send-ether/confirm-send-ether.component.js index 442a478b8..8daad675e 100644 --- a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js +++ b/ui/app/pages/confirm-send-ether/confirm-send-ether.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import ConfirmTransactionBase from '../confirm-transaction-base' -import { SEND_ROUTE } from '../../../routes' +import { SEND_ROUTE } from '../../helpers/constants/routes' export default class ConfirmSendEther extends Component { static contextTypes = { diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/app/pages/confirm-send-ether/confirm-send-ether.container.js index e48ef54a8..713da702d 100644 --- a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js +++ b/ui/app/pages/confirm-send-ether/confirm-send-ether.container.js @@ -1,8 +1,8 @@ import { connect } from 'react-redux' import { compose } from 'recompose' import { withRouter } from 'react-router-dom' -import { updateSend } from '../../../actions' -import { clearConfirmTransaction } from '../../../ducks/confirm-transaction.duck' +import { updateSend } from '../../store/actions' +import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck' import ConfirmSendEther from './confirm-send-ether.component' const mapStateToProps = state => { diff --git a/ui/app/components/pages/confirm-send-ether/index.js b/ui/app/pages/confirm-send-ether/index.js index 2d5767c39..2d5767c39 100644 --- a/ui/app/components/pages/confirm-send-ether/index.js +++ b/ui/app/pages/confirm-send-ether/index.js diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js b/ui/app/pages/confirm-send-token/confirm-send-token.component.js index cb39e3d7b..7f3b1c082 100644 --- a/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js +++ b/ui/app/pages/confirm-send-token/confirm-send-token.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import ConfirmTokenTransactionBase from '../confirm-token-transaction-base' -import { SEND_ROUTE } from '../../../routes' +import { SEND_ROUTE } from '../../helpers/constants/routes' export default class ConfirmSendToken extends Component { static propTypes = { diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js b/ui/app/pages/confirm-send-token/confirm-send-token.container.js index d60911e59..db9b08c48 100644 --- a/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js +++ b/ui/app/pages/confirm-send-token/confirm-send-token.container.js @@ -2,10 +2,10 @@ import { connect } from 'react-redux' import { compose } from 'recompose' import { withRouter } from 'react-router-dom' import ConfirmSendToken from './confirm-send-token.component' -import { clearConfirmTransaction } from '../../../ducks/confirm-transaction.duck' -import { setSelectedToken, updateSend, showSendTokenPage } from '../../../actions' -import { conversionUtil } from '../../../conversion-util' -import { sendTokenTokenAmountAndToAddressSelector } from '../../../selectors/confirm-transaction' +import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck' +import { setSelectedToken, updateSend, showSendTokenPage } from '../../store/actions' +import { conversionUtil } from '../../helpers/utils/conversion-util' +import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors/confirm-transaction' const mapStateToProps = state => { const { tokenAmount } = sendTokenTokenAmountAndToAddressSelector(state) diff --git a/ui/app/components/pages/confirm-send-token/index.js b/ui/app/pages/confirm-send-token/index.js index 409b6ef3d..409b6ef3d 100644 --- a/ui/app/components/pages/confirm-send-token/index.js +++ b/ui/app/pages/confirm-send-token/index.js diff --git a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js b/ui/app/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js index 7f1fb4e49..dbda3c1dc 100644 --- a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js +++ b/ui/app/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js @@ -1,15 +1,15 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import ConfirmTransactionBase from '../confirm-transaction-base' -import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' +import UserPreferencedCurrencyDisplay from '../../components/app/user-preferenced-currency-display' import { formatCurrency, convertTokenToFiat, addFiat, roundExponential, -} from '../../../helpers/confirm-transaction/util' -import { getWeiHexFromDecimalValue } from '../../../helpers/conversions.util' -import { ETH, PRIMARY } from '../../../constants/common' +} from '../../helpers/utils/confirm-tx.util' +import { getWeiHexFromDecimalValue } from '../../helpers/utils/conversions.util' +import { ETH, PRIMARY } from '../../helpers/constants/common' export default class ConfirmTokenTransactionBase extends Component { static contextTypes = { diff --git a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js b/ui/app/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js index be38acdb0..f5f30a460 100644 --- a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js +++ b/ui/app/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js @@ -3,7 +3,7 @@ import ConfirmTokenTransactionBase from './confirm-token-transaction-base.compon import { tokenAmountAndToAddressSelector, contractExchangeRateSelector, -} from '../../../selectors/confirm-transaction' +} from '../../selectors/confirm-transaction' const mapStateToProps = (state, ownProps) => { const { tokenAmount: ownTokenAmount } = ownProps diff --git a/ui/app/components/pages/confirm-token-transaction-base/index.js b/ui/app/pages/confirm-token-transaction-base/index.js index e15c5d56b..e15c5d56b 100644 --- a/ui/app/components/pages/confirm-token-transaction-base/index.js +++ b/ui/app/pages/confirm-token-transaction-base/index.js diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js index 7d01aaffb..9e749322f 100644 --- a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -1,19 +1,24 @@ +import ethUtil from 'ethereumjs-util' import React, { Component } from 'react' import PropTypes from 'prop-types' -import ConfirmPageContainer, { ConfirmDetailRow } from '../../confirm-page-container' -import { isBalanceSufficient } from '../../send/send.utils' -import { DEFAULT_ROUTE } from '../../../routes' +import { ENVIRONMENT_TYPE_NOTIFICATION } from '../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../app/scripts/lib/util' +import ConfirmPageContainer, { ConfirmDetailRow } from '../../components/app/confirm-page-container' +import { isBalanceSufficient } from '../../components/app/send/send.utils' +import { DEFAULT_ROUTE, CONFIRM_TRANSACTION_ROUTE } from '../../helpers/constants/routes' import { INSUFFICIENT_FUNDS_ERROR_KEY, TRANSACTION_ERROR_KEY, -} from '../../../constants/error-keys' -import { CONFIRMED_STATUS, DROPPED_STATUS } from '../../../constants/transactions' -import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../constants/common' +} from '../../helpers/constants/error-keys' +import { CONFIRMED_STATUS, DROPPED_STATUS } from '../../helpers/constants/transactions' +import UserPreferencedCurrencyDisplay from '../../components/app/user-preferenced-currency-display' +import { PRIMARY, SECONDARY } from '../../helpers/constants/common' +import AdvancedGasInputs from '../../components/app/gas-customization/advanced-gas-inputs' export default class ConfirmTransactionBase extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -55,6 +60,9 @@ export default class ConfirmTransactionBase extends Component { transactionStatus: PropTypes.string, txData: PropTypes.object, unapprovedTxCount: PropTypes.number, + currentNetworkUnapprovedTxs: PropTypes.object, + updateGasAndCalculate: PropTypes.func, + customGas: PropTypes.object, // Component props action: PropTypes.string, contentComponent: PropTypes.node, @@ -72,6 +80,8 @@ export default class ConfirmTransactionBase extends Component { onEdit: PropTypes.func, onEditGas: PropTypes.func, onSubmit: PropTypes.func, + setMetaMetricsSendCount: PropTypes.func, + metaMetricsSendCount: PropTypes.number, subtitle: PropTypes.string, subtitleComponent: PropTypes.node, summaryComponent: PropTypes.node, @@ -79,6 +89,9 @@ export default class ConfirmTransactionBase extends Component { titleComponent: PropTypes.node, valid: PropTypes.bool, warning: PropTypes.string, + advancedInlineGasShown: PropTypes.bool, + insufficientBalance: PropTypes.bool, + hideFiatConversion: PropTypes.bool, } state = { @@ -146,7 +159,20 @@ export default class ConfirmTransactionBase extends Component { } handleEditGas () { - const { onEditGas, showCustomizeGasModal } = this.props + const { onEditGas, showCustomizeGasModal, action, txData: { origin }, methodData = {} } = this.props + + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'User clicks "Edit" on gas', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) if (onEditGas) { onEditGas() @@ -163,6 +189,11 @@ export default class ConfirmTransactionBase extends Component { hexTransactionFee, hexTransactionTotal, hideDetails, + advancedInlineGasShown, + customGas, + insufficientBalance, + updateGasAndCalculate, + hideFiatConversion, } = this.props if (hideDetails) { @@ -179,14 +210,27 @@ export default class ConfirmTransactionBase extends Component { headerText="Edit" headerTextClassName="confirm-detail-row__header-text--edit" onHeaderClick={() => this.handleEditGas()} + secondaryText={hideFiatConversion ? this.context.t('noConversionRateAvailable') : ''} /> + {advancedInlineGasShown + ? <AdvancedGasInputs + updateCustomGasPrice={newGasPrice => updateGasAndCalculate({ ...customGas, gasPrice: newGasPrice })} + updateCustomGasLimit={newGasLimit => updateGasAndCalculate({ ...customGas, gasLimit: newGasLimit })} + customGasPrice={customGas.gasPrice} + customGasLimit={customGas.gasLimit} + insufficientBalance={insufficientBalance} + customPriceIsSafe={true} + isSpeedUp={false} + /> + : null + } </div> <div> <ConfirmDetailRow label="Total" value={hexTransactionTotal} primaryText={primaryTotalTextOverride} - secondaryText={secondaryTotalTextOverride} + secondaryText={hideFiatConversion ? this.context.t('noConversionRateAvailable') : secondaryTotalTextOverride} headerText="Amount + Gas Fee" headerTextClassName="confirm-detail-row__header-text--total" primaryValueTextColor="#2f9ae0" @@ -238,7 +282,7 @@ export default class ConfirmTransactionBase extends Component { ) } <div className="confirm-page-container-content__data-box-label"> - {`${t('hexData')}:`} + {`${t('hexData')}: ${ethUtil.toBuffer(data).length} bytes`} </div> <div className="confirm-page-container-content__data-box"> { data } @@ -248,7 +292,21 @@ export default class ConfirmTransactionBase extends Component { } handleEdit () { - const { txData, tokenData, tokenProps, onEdit } = this.props + const { txData, tokenData, tokenProps, onEdit, action, txData: { origin }, methodData = {} } = this.props + + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Edit Transaction', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) + onEdit({ txData, tokenData, tokenProps }) } @@ -272,9 +330,22 @@ export default class ConfirmTransactionBase extends Component { } handleCancel () { - const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction } = this.props + const { metricsEvent } = this.context + const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction, action, txData: { origin }, methodData = {} } = this.props if (onCancel) { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Cancel', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) onCancel(txData) } else { cancelTransaction(txData) @@ -286,29 +357,59 @@ export default class ConfirmTransactionBase extends Component { } handleSubmit () { - const { sendTransaction, clearConfirmTransaction, txData, history, onSubmit } = this.props + const { metricsEvent } = this.context + const { txData: { origin }, sendTransaction, clearConfirmTransaction, txData, history, onSubmit, action, metaMetricsSendCount = 0, setMetaMetricsSendCount, methodData = {} } = this.props const { submitting } = this.state if (submitting) { return } - this.setState({ submitting: true, submitError: null }) + this.setState({ + submitting: true, + submitError: null, + }, () => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Transaction Completed', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) - if (onSubmit) { - Promise.resolve(onSubmit(txData)) - .then(this.setState({ submitting: false })) - } else { - sendTransaction(txData) + setMetaMetricsSendCount(metaMetricsSendCount + 1) .then(() => { - clearConfirmTransaction() - this.setState({ submitting: false }) - history.push(DEFAULT_ROUTE) - }) - .catch(error => { - this.setState({ submitting: false, submitError: error.message }) + if (onSubmit) { + Promise.resolve(onSubmit(txData)) + .then(() => { + this.setState({ + submitting: false, + }) + }) + } else { + sendTransaction(txData) + .then(() => { + clearConfirmTransaction() + this.setState({ + submitting: false, + }, () => { + history.push(DEFAULT_ROUTE) + }) + }) + .catch(error => { + this.setState({ + submitting: false, + submitError: error.message, + }) + }) + } }) - } + }) } renderTitleComponent () { @@ -348,6 +449,63 @@ export default class ConfirmTransactionBase extends Component { ) } + handleNextTx (txId) { + const { history, clearConfirmTransaction } = this.props + if (txId) { + clearConfirmTransaction() + history.push(`${CONFIRM_TRANSACTION_ROUTE}/${txId}`) + } + } + + getNavigateTxData () { + const { currentNetworkUnapprovedTxs, txData: { id } = {} } = this.props + const enumUnapprovedTxs = Object.keys(currentNetworkUnapprovedTxs).reverse() + const currentPosition = enumUnapprovedTxs.indexOf(id.toString()) + + return { + totalTx: enumUnapprovedTxs.length, + positionOfCurrentTx: currentPosition + 1, + nextTxId: enumUnapprovedTxs[currentPosition + 1], + prevTxId: enumUnapprovedTxs[currentPosition - 1], + showNavigation: enumUnapprovedTxs.length > 1, + firstTx: enumUnapprovedTxs[0], + lastTx: enumUnapprovedTxs[enumUnapprovedTxs.length - 1], + ofText: this.context.t('ofTextNofM'), + requestsWaitingText: this.context.t('requestsAwaitingAcknowledgement'), + } + } + + componentDidMount () { + const { txData: { origin, id } = {}, cancelTransaction } = this.props + const { metricsEvent } = this.context + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Confirm: Started', + }, + customVariables: { + origin, + }, + }) + + if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_NOTIFICATION) { + window.onbeforeunload = () => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Cancel Tx Via Notification Close', + }, + customVariables: { + origin, + }, + }) + cancelTransaction({ id }) + } + } + } + render () { const { isTxReprice, @@ -376,6 +534,7 @@ export default class ConfirmTransactionBase extends Component { const { name } = methodData const { valid, errorKey } = this.getErrorKey() + const { totalTx, positionOfCurrentTx, nextTxId, prevTxId, showNavigation, firstTx, lastTx, ofText, requestsWaitingText } = this.getNavigateTxData() return ( <ConfirmPageContainer @@ -384,7 +543,7 @@ export default class ConfirmTransactionBase extends Component { toName={toName} toAddress={toAddress} showEdit={onEdit && !isTxReprice} - action={action || name || this.context.t('unknownFunction')} + action={action || getMethodName(name) || this.context.t('contractInteraction')} title={title} titleComponent={this.renderTitleComponent()} subtitle={subtitle} @@ -401,6 +560,16 @@ export default class ConfirmTransactionBase extends Component { errorMessage={errorMessage || submitError} errorKey={propsErrorKey || errorKey} warning={warning} + totalTx={totalTx} + positionOfCurrentTx={positionOfCurrentTx} + nextTxId={nextTxId} + prevTxId={prevTxId} + showNavigation={showNavigation} + onNextTx={(txId) => this.handleNextTx(txId)} + firstTx={firstTx} + lastTx={lastTx} + ofText={ofText} + requestsWaitingText={requestsWaitingText} disabled={!propsValid || !valid || submitting} onEdit={() => this.handleEdit()} onCancelAll={() => this.handleCancelAll()} @@ -410,3 +579,14 @@ export default class ConfirmTransactionBase extends Component { ) } } + +export function getMethodName (camelCase) { + if (!camelCase || typeof camelCase !== 'string') { + return '' + } + + return camelCase + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z])([a-z])/g, ' $1$2') + .replace(/ +/g, ' ') +} diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js index c366d5137..83543f1a4 100644 --- a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -7,17 +7,18 @@ import ConfirmTransactionBase from './confirm-transaction-base.component' import { clearConfirmTransaction, updateGasAndCalculate, -} from '../../../ducks/confirm-transaction.duck' -import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal } from '../../../actions' +} from '../../ducks/confirm-transaction/confirm-transaction.duck' +import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal, setMetaMetricsSendCount } from '../../store/actions' import { INSUFFICIENT_FUNDS_ERROR_KEY, GAS_LIMIT_TOO_LOW_ERROR_KEY, -} from '../../../constants/error-keys' -import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' -import { isBalanceSufficient } from '../../send/send.utils' -import { conversionGreaterThan } from '../../../conversion-util' -import { MIN_GAS_LIMIT_DEC } from '../../send/send.constants' -import { addressSlicer, valuesFor } from '../../../util' +} from '../../helpers/constants/error-keys' +import { getHexGasTotal } from '../../helpers/utils/confirm-tx.util' +import { isBalanceSufficient, calcGasTotal } from '../../components/app/send/send.utils' +import { conversionGreaterThan } from '../../helpers/utils/conversion-util' +import { MIN_GAS_LIMIT_DEC } from '../../components/app/send/send.constants' +import { checksumAddress, addressSlicer, valuesFor } from '../../helpers/utils/util' +import {getMetaMaskAccounts, getAdvancedInlineGasShown, preferencesSelector, getIsMainnet} from '../../selectors/selectors' const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { return { @@ -28,7 +29,9 @@ const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { const mapStateToProps = (state, props) => { const { toAddress: propsToAddress } = props - const { confirmTransaction, metamask } = state + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const { confirmTransaction, metamask, gas } = state const { ethTransactionAmount, ethTransactionFee, @@ -46,25 +49,42 @@ const mapStateToProps = (state, props) => { nonce, } = confirmTransaction const { txParams = {}, lastGasPrice, id: transactionId } = txData - const { from: fromAddress, to: txParamsToAddress } = txParams + const { + from: fromAddress, + to: txParamsToAddress, + gasPrice, + gas: gasLimit, + value: amount, + } = txParams + const accounts = getMetaMaskAccounts(state) const { conversionRate, identities, currentCurrency, - accounts, selectedAddress, selectedAddressTxList, assetImages, network, unapprovedTxs, + metaMetricsSendCount, } = metamask const assetImage = assetImages[txParamsToAddress] + + const { + customGasLimit, + customGasPrice, + } = gas + const { balance } = accounts[selectedAddress] const { name: fromName } = identities[selectedAddress] const toAddress = propsToAddress || txParamsToAddress const toName = identities[toAddress] ? identities[toAddress].name - : casedContractMap[toAddress] ? casedContractMap[toAddress].name : addressSlicer(toAddress) + : ( + casedContractMap[toAddress] + ? casedContractMap[toAddress].name + : addressSlicer(checksumAddress(toAddress)) + ) const isTxReprice = Boolean(lastGasPrice) @@ -73,9 +93,16 @@ const mapStateToProps = (state, props) => { const currentNetworkUnapprovedTxs = R.filter( ({ metamaskNetworkId }) => metamaskNetworkId === network, - valuesFor(unapprovedTxs), + unapprovedTxs, ) - const unapprovedTxCount = currentNetworkUnapprovedTxs.length + const unapprovedTxCount = valuesFor(currentNetworkUnapprovedTxs).length + + const insufficientBalance = !isBalanceSufficient({ + amount, + gasTotal: calcGasTotal(gasLimit, gasPrice), + balance, + conversionRate, + }) return { balance, @@ -104,6 +131,16 @@ const mapStateToProps = (state, props) => { assetImage, unapprovedTxs, unapprovedTxCount, + currentNetworkUnapprovedTxs, + customGas: { + gasLimit: customGasLimit || gasLimit, + gasPrice: customGasPrice || gasPrice, + }, + advancedInlineGasShown: getAdvancedInlineGasShown(state), + insufficientBalance, + hideSubtitle: (!isMainnet && !showFiatInTestnets), + hideFiatConversion: (!isMainnet && !showFiatInTestnets), + metaMetricsSendCount, } } @@ -115,7 +152,7 @@ const mapDispatchToProps = dispatch => { return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onSubmit })) }, showCustomizeGasModal: ({ txData, onSubmit, validate }) => { - return dispatch(showModal({ name: 'CONFIRM_CUSTOMIZE_GAS', txData, onSubmit, validate })) + return dispatch(showModal({ name: 'CUSTOMIZE_GAS', txData, onSubmit, validate })) }, updateGasAndCalculate: ({ gasLimit, gasPrice }) => { return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) @@ -126,6 +163,7 @@ const mapDispatchToProps = dispatch => { cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), sendTransaction: txData => dispatch(updateAndApproveTx(txData)), + setMetaMetricsSendCount: val => dispatch(setMetaMetricsSendCount(val)), } } @@ -190,10 +228,11 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { ...ownProps, showCustomizeGasModal: () => dispatchShowCustomizeGasModal({ txData, - onSubmit: txData => dispatchUpdateGasAndCalculate(txData), + onSubmit: customGas => dispatchUpdateGasAndCalculate(customGas), validate: validateEditGas, }), cancelAllTransactions: () => dispatchCancelAllTransactions(valuesFor(unapprovedTxs)), + updateGasAndCalculate: dispatchUpdateGasAndCalculate, } } diff --git a/ui/app/components/pages/confirm-transaction-base/index.js b/ui/app/pages/confirm-transaction-base/index.js index 9996e9aeb..9996e9aeb 100644 --- a/ui/app/components/pages/confirm-transaction-base/index.js +++ b/ui/app/pages/confirm-transaction-base/index.js diff --git a/ui/app/pages/confirm-transaction-base/tests/confirm-transaction-base.component.test.js b/ui/app/pages/confirm-transaction-base/tests/confirm-transaction-base.component.test.js new file mode 100644 index 000000000..8ca7ca4e7 --- /dev/null +++ b/ui/app/pages/confirm-transaction-base/tests/confirm-transaction-base.component.test.js @@ -0,0 +1,14 @@ +import assert from 'assert' +import { getMethodName } from '../confirm-transaction-base.component' + +describe('ConfirmTransactionBase Component', () => { + describe('getMethodName', () => { + it('should get correct method names', () => { + assert.equal(getMethodName(undefined), '') + assert.equal(getMethodName({}), '') + assert.equal(getMethodName('confirm'), 'confirm') + assert.equal(getMethodName('balanceOf'), 'balance Of') + assert.equal(getMethodName('ethToTokenSwapInput'), 'eth To Token Swap Input') + }) + }) +}) diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.component.js index 2c44b6094..cd471b822 100644 --- a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js +++ b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { Redirect } from 'react-router-dom' -import Loading from '../../loading-screen' +import Loading from '../../components/ui/loading-screen' import { CONFIRM_TRANSACTION_ROUTE, CONFIRM_DEPLOY_CONTRACT_PATH, @@ -11,13 +11,13 @@ import { CONFIRM_TRANSFER_FROM_PATH, CONFIRM_TOKEN_METHOD_PATH, SIGNATURE_REQUEST_PATH, -} from '../../../routes' -import { isConfirmDeployContract } from '../../../helpers/transactions.util' +} from '../../helpers/constants/routes' +import { isConfirmDeployContract } from '../../helpers/utils/transactions.util' import { TOKEN_METHOD_TRANSFER, TOKEN_METHOD_APPROVE, TOKEN_METHOD_TRANSFER_FROM, -} from '../../../constants/transactions' +} from '../../helpers/constants/transactions' export default class ConfirmTransactionSwitch extends Component { static propTypes = { @@ -36,15 +36,15 @@ export default class ConfirmTransactionSwitch extends Component { } = this.props const { id, txParams: { data } = {} } = txData + if (fetchingData) { + return <Loading /> + } + if (isConfirmDeployContract(txData)) { const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_DEPLOY_CONTRACT_PATH}` return <Redirect to={{ pathname }} /> } - if (fetchingData) { - return <Loading /> - } - if (isEtherTransaction) { const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` return <Redirect to={{ pathname }} /> diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.container.js index 7f2c36af2..7f2c36af2 100644 --- a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js +++ b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.container.js diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.util.js index 536aa5212..536aa5212 100644 --- a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js +++ b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.util.js diff --git a/ui/app/components/pages/confirm-transaction-switch/index.js b/ui/app/pages/confirm-transaction-switch/index.js index c288acb1a..c288acb1a 100644 --- a/ui/app/components/pages/confirm-transaction-switch/index.js +++ b/ui/app/pages/confirm-transaction-switch/index.js diff --git a/ui/app/conf-tx.js b/ui/app/pages/confirm-transaction/conf-tx.js index 0784a872e..f9af6624e 100644 --- a/ui/app/conf-tx.js +++ b/ui/app/pages/confirm-transaction/conf-tx.js @@ -4,14 +4,15 @@ const h = require('react-hyperscript') const connect = require('react-redux').connect const { withRouter } = require('react-router-dom') const { compose } = require('recompose') -const actions = require('./actions') -const txHelper = require('../lib/tx-helper') +const actions = require('../../store/actions') +const txHelper = require('../../../lib/tx-helper') const log = require('loglevel') const R = require('ramda') -const SignatureRequest = require('./components/signature-request') -const Loading = require('./components/loading-screen') -const { DEFAULT_ROUTE } = require('./routes') +const SignatureRequest = require('../../components/app/signature-request') +const Loading = require('../../components/ui/loading-screen') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') +const { getMetaMaskAccounts } = require('../../selectors/selectors') module.exports = compose( withRouter, @@ -28,7 +29,7 @@ function mapStateToProps (state) { return { identities: state.metamask.identities, - accounts: state.metamask.accounts, + accounts: getMetaMaskAccounts(state), selectedAddress: state.metamask.selectedAddress, unapprovedTxs: state.metamask.unapprovedTxs, unapprovedMsgs: state.metamask.unapprovedMsgs, diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js b/ui/app/pages/confirm-transaction/confirm-transaction.component.js index 3ac656d73..35b8dc5aa 100644 --- a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js +++ b/ui/app/pages/confirm-transaction/confirm-transaction.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { Switch, Route } from 'react-router-dom' -import Loading from '../../loading-screen' +import Loading from '../../components/ui/loading-screen' import ConfirmTransactionSwitch from '../confirm-transaction-switch' import ConfirmTransactionBase from '../confirm-transaction-base' import ConfirmSendEther from '../confirm-send-ether' @@ -9,7 +9,7 @@ import ConfirmSendToken from '../confirm-send-token' import ConfirmDeployContract from '../confirm-deploy-contract' import ConfirmApprove from '../confirm-approve' import ConfirmTokenTransactionBase from '../confirm-token-transaction-base' -import ConfTx from '../../../conf-tx' +import ConfTx from './conf-tx' import { DEFAULT_ROUTE, CONFIRM_TRANSACTION_ROUTE, @@ -20,7 +20,7 @@ import { CONFIRM_TRANSFER_FROM_PATH, CONFIRM_TOKEN_METHOD_PATH, SIGNATURE_REQUEST_PATH, -} from '../../../routes' +} from '../../helpers/constants/routes' export default class ConfirmTransaction extends Component { static propTypes = { @@ -32,6 +32,7 @@ export default class ConfirmTransaction extends Component { setTransactionToConfirm: PropTypes.func, confirmTransaction: PropTypes.object, clearConfirmTransaction: PropTypes.func, + fetchBasicGasAndTimeEstimates: PropTypes.func, } getParamsTransactionId () { @@ -45,6 +46,7 @@ export default class ConfirmTransaction extends Component { send = {}, history, confirmTransaction: { txData: { id: transactionId } = {} }, + fetchBasicGasAndTimeEstimates, } = this.props if (!totalUnapprovedCount && !send.to) { @@ -53,6 +55,7 @@ export default class ConfirmTransaction extends Component { } if (!transactionId) { + fetchBasicGasAndTimeEstimates() this.setTransactionToConfirm() } } diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js b/ui/app/pages/confirm-transaction/confirm-transaction.container.js index 1bc2f1efb..2dd5e833e 100644 --- a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js +++ b/ui/app/pages/confirm-transaction/confirm-transaction.container.js @@ -4,10 +4,13 @@ import { withRouter } from 'react-router-dom' import { setTransactionToConfirm, clearConfirmTransaction, -} from '../../../ducks/confirm-transaction.duck' +} from '../../ducks/confirm-transaction/confirm-transaction.duck' +import { + fetchBasicGasAndTimeEstimates, +} from '../../ducks/gas/gas.duck' import ConfirmTransaction from './confirm-transaction.component' -import { getTotalUnapprovedCount } from '../../../selectors' -import { unconfirmedTransactionsListSelector } from '../../../selectors/confirm-transaction' +import { getTotalUnapprovedCount } from '../../selectors/selectors' +import { unconfirmedTransactionsListSelector } from '../../selectors/confirm-transaction' const mapStateToProps = state => { const { metamask: { send }, confirmTransaction } = state @@ -24,6 +27,7 @@ const mapDispatchToProps = dispatch => { return { setTransactionToConfirm: transactionId => dispatch(setTransactionToConfirm(transactionId)), clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()), } } diff --git a/ui/app/components/pages/confirm-transaction/index.js b/ui/app/pages/confirm-transaction/index.js index 4bf42d85c..4bf42d85c 100644 --- a/ui/app/components/pages/confirm-transaction/index.js +++ b/ui/app/pages/confirm-transaction/index.js diff --git a/ui/app/components/pages/create-account/connect-hardware/account-list.js b/ui/app/pages/create-account/connect-hardware/account-list.js index 2767b2e1f..617fb8833 100644 --- a/ui/app/components/pages/create-account/connect-hardware/account-list.js +++ b/ui/app/pages/create-account/connect-hardware/account-list.js @@ -1,9 +1,9 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') -const genAccountLink = require('../../../../../lib/account-link.js') +const genAccountLink = require('../../../../lib/account-link.js') const Select = require('react-select').default -import Button from '../../../button' +import Button from '../../../components/ui/button' class AccountList extends Component { constructor (props, context) { @@ -152,7 +152,7 @@ class AccountList extends Component { }, [this.context.t('cancel')]), h(Button, { - type: 'primary', + type: 'confirm', large: true, className: 'new-account-connect-form__button unlock', disabled, diff --git a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js b/ui/app/pages/create-account/connect-hardware/connect-screen.js index d3abf3119..7e9dee970 100644 --- a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js +++ b/ui/app/pages/create-account/connect-hardware/connect-screen.js @@ -1,7 +1,7 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') -import Button from '../../../button' +import Button from '../../../components/ui/button' class ConnectScreen extends Component { constructor (props, context) { @@ -45,11 +45,13 @@ class ConnectScreen extends Component { this.renderConnectToLedgerButton(), this.renderConnectToTrezorButton(), ]), - h( - `button.hw-connect__connect-btn${!this.state.selectedDevice ? '.disabled' : ''}`, - { onClick: this.connect }, - this.context.t('connect') - ), + h(Button, { + type: 'confirm', + large: true, + className: 'hw-connect__connect-btn', + onClick: this.connect, + disabled: !this.state.selectedDevice, + }, this.context.t('connect')), ]) ) } @@ -67,9 +69,7 @@ class ConnectScreen extends Component { onClick: () => global.platform.openWindow({ url: 'https://google.com/chrome', }), - }, - this.context.t('downloadGoogleChrome') - ), + }, this.context.t('downloadGoogleChrome')), ]) ) } diff --git a/ui/app/components/pages/create-account/connect-hardware/index.js b/ui/app/pages/create-account/connect-hardware/index.js index 547df5223..1398fa680 100644 --- a/ui/app/components/pages/create-account/connect-hardware/index.js +++ b/ui/app/pages/create-account/connect-hardware/index.js @@ -2,13 +2,14 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') const connect = require('react-redux').connect -const actions = require('../../../../actions') +const actions = require('../../../store/actions') +const { getMetaMaskAccounts } = require('../../../selectors/selectors') const ConnectScreen = require('./connect-screen') const AccountList = require('./account-list') -const { DEFAULT_ROUTE } = require('../../../../routes') -const { formatBalance } = require('../../../../util') -const { getPlatform } = require('../../../../../../app/scripts/lib/util') -const { PLATFORM_FIREFOX } = require('../../../../../../app/scripts/lib/enums') +const { DEFAULT_ROUTE } = require('../../../helpers/constants/routes') +const { formatBalance } = require('../../../helpers/utils/util') +const { getPlatform } = require('../../../../../app/scripts/lib/util') +const { PLATFORM_FIREFOX } = require('../../../../../app/scripts/lib/enums') class ConnectHardwareForm extends Component { constructor (props, context) { @@ -50,9 +51,8 @@ class ConnectHardwareForm extends Component { } connectToHardwareWallet = (device) => { - // None of the hardware wallets are supported - // At least for now - if (getPlatform() === PLATFORM_FIREFOX) { + // Ledger hardware wallets are not supported on firefox + if (getPlatform() === PLATFORM_FIREFOX && device === 'ledger') { this.setState({ browserSupported: false, error: null}) return null } @@ -126,7 +126,7 @@ class ConnectHardwareForm extends Component { .catch(e => { if (e === 'Window blocked') { this.setState({ browserSupported: false, error: null}) - } else if (e !== 'Window closed') { + } else if (e !== 'Window closed' && e !== 'Popup closed') { this.setState({ error: e.toString() }) } }) @@ -154,8 +154,25 @@ class ConnectHardwareForm extends Component { this.props.unlockHardwareWalletAccount(this.state.selectedAccount, device) .then(_ => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Connected Hardware Wallet', + name: 'Connected Account with: ' + device, + }, + }) this.props.history.push(DEFAULT_ROUTE) }).catch(e => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Connected Hardware Wallet', + name: 'Error connecting hardware wallet', + }, + customVariables: { + error: e.toString(), + }, + }) this.setState({ error: e.toString() }) }) } @@ -225,8 +242,9 @@ ConnectHardwareForm.propTypes = { const mapStateToProps = state => { const { - metamask: { network, selectedAddress, identities = {}, accounts = [] }, + metamask: { network, selectedAddress, identities = {} }, } = state + const accounts = getMetaMaskAccounts(state) const numberOfExistingAccounts = Object.keys(identities).length const { appState: { defaultHdPaths }, @@ -267,6 +285,7 @@ const mapDispatchToProps = dispatch => { ConnectHardwareForm.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)( diff --git a/ui/app/components/pages/create-account/import-account/index.js b/ui/app/pages/create-account/import-account/index.js index 48d8f8838..48d8f8838 100644 --- a/ui/app/components/pages/create-account/import-account/index.js +++ b/ui/app/pages/create-account/import-account/index.js diff --git a/ui/app/components/pages/create-account/import-account/json.js b/ui/app/pages/create-account/import-account/json.js index 90279bbbd..17bef763c 100644 --- a/ui/app/components/pages/create-account/import-account/json.js +++ b/ui/app/pages/create-account/import-account/json.js @@ -4,11 +4,12 @@ const h = require('react-hyperscript') const { withRouter } = require('react-router-dom') const { compose } = require('recompose') const connect = require('react-redux').connect -const actions = require('../../../../actions') +const actions = require('../../../store/actions') const FileInput = require('react-simple-file-input').default -const { DEFAULT_ROUTE } = require('../../../../routes') +const { DEFAULT_ROUTE } = require('../../../helpers/constants/routes') +const { getMetaMaskAccounts } = require('../../../selectors/selectors') const HELP_LINK = 'https://support.metamask.io/kb/article/7-importing-accounts' -import Button from '../../../button' +import Button from '../../../components/ui/button' class JsonImportSubview extends Component { constructor (props) { @@ -103,18 +104,27 @@ class JsonImportSubview extends Component { const passwordInput = document.getElementById('json-password-box') const password = passwordInput.value - if (!password) { - const message = this.context.t('needImportPassword') - return displayWarning(message) - } - importNewJsonAccount([ fileContents, password ]) .then(({ selectedAddress }) => { if (selectedAddress) { history.push(DEFAULT_ROUTE) + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Import Account', + name: 'Imported Account with JSON', + }, + }) displayWarning(null) } else { displayWarning('Error importing account.') + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Import Account', + name: 'Error importing JSON', + }, + }) setSelectedAddress(firstAddress) } }) @@ -136,7 +146,7 @@ JsonImportSubview.propTypes = { const mapStateToProps = state => { return { error: state.appState.warning, - firstAddress: Object.keys(state.metamask.accounts)[0], + firstAddress: Object.keys(getMetaMaskAccounts(state))[0], } } @@ -151,6 +161,7 @@ const mapDispatchToProps = dispatch => { JsonImportSubview.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( diff --git a/ui/app/components/pages/create-account/import-account/private-key.js b/ui/app/pages/create-account/import-account/private-key.js index 8db1bfbdd..450614e87 100644 --- a/ui/app/components/pages/create-account/import-account/private-key.js +++ b/ui/app/pages/create-account/import-account/private-key.js @@ -5,12 +5,14 @@ const { withRouter } = require('react-router-dom') const { compose } = require('recompose') const PropTypes = require('prop-types') const connect = require('react-redux').connect -const actions = require('../../../../actions') -const { DEFAULT_ROUTE } = require('../../../../routes') -import Button from '../../../button' +const actions = require('../../../store/actions') +const { DEFAULT_ROUTE } = require('../../../helpers/constants/routes') +const { getMetaMaskAccounts } = require('../../../selectors/selectors') +import Button from '../../../components/ui/button' PrivateKeyImportView.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( @@ -22,7 +24,7 @@ module.exports = compose( function mapStateToProps (state) { return { error: state.appState.warning, - firstAddress: Object.keys(state.metamask.accounts)[0], + firstAddress: Object.keys(getMetaMaskAccounts(state))[0], } } @@ -101,10 +103,24 @@ PrivateKeyImportView.prototype.createNewKeychain = function () { importNewAccount('Private Key', [ privateKey ]) .then(({ selectedAddress }) => { if (selectedAddress) { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Import Account', + name: 'Imported Account with Private Key', + }, + }) history.push(DEFAULT_ROUTE) displayWarning(null) } else { displayWarning('Error importing account.') + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Import Account', + name: 'Error importing with Private Key', + }, + }) setSelectedAddress(firstAddress) } }) diff --git a/ui/app/components/pages/create-account/import-account/seed.js b/ui/app/pages/create-account/import-account/seed.js index d98909baa..d98909baa 100644 --- a/ui/app/components/pages/create-account/import-account/seed.js +++ b/ui/app/pages/create-account/import-account/seed.js diff --git a/ui/app/components/pages/create-account/index.js b/ui/app/pages/create-account/index.js index d3de1ea01..ce84db028 100644 --- a/ui/app/components/pages/create-account/index.js +++ b/ui/app/pages/create-account/index.js @@ -3,8 +3,8 @@ const { Switch, Route, matchPath } = require('react-router-dom') const PropTypes = require('prop-types') const h = require('react-hyperscript') const connect = require('react-redux').connect -const actions = require('../../../actions') -const { getCurrentViewContext } = require('../../../selectors') +const actions = require('../../store/actions') +const { getCurrentViewContext } = require('../../selectors/selectors') const classnames = require('classnames') const NewAccountCreateForm = require('./new-account') const NewAccountImportForm = require('./import-account') @@ -13,7 +13,7 @@ const { NEW_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE, CONNECT_HARDWARE_ROUTE, -} = require('../../../routes') +} = require('../../helpers/constants/routes') class CreateAccountPage extends Component { renderTabs () { diff --git a/ui/app/components/pages/create-account/new-account.js b/ui/app/pages/create-account/new-account.js index 94a5fa487..316fbe6f1 100644 --- a/ui/app/components/pages/create-account/new-account.js +++ b/ui/app/pages/create-account/new-account.js @@ -2,9 +2,9 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') const connect = require('react-redux').connect -const actions = require('../../../actions') -const { DEFAULT_ROUTE } = require('../../../routes') -import Button from '../../button' +const actions = require('../../store/actions') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') +import Button from '../../components/ui/button' class NewAccountCreateForm extends Component { constructor (props, context) { @@ -52,7 +52,28 @@ class NewAccountCreateForm extends Component { className: 'new-account-create-form__button', onClick: () => { createAccount(newAccountName || defaultAccountName) - .then(() => history.push(DEFAULT_ROUTE)) + .then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Add New Account', + name: 'Added New Account', + }, + }) + history.push(DEFAULT_ROUTE) + }) + .catch((e) => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Add New Account', + name: 'Error', + }, + customVariables: { + errorMessage: e.message, + }, + }) + }) }, }, [this.context.t('create')]), @@ -102,6 +123,7 @@ const mapDispatchToProps = dispatch => { NewAccountCreateForm.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)(NewAccountCreateForm) diff --git a/ui/app/pages/first-time-flow/create-password/create-password.component.js b/ui/app/pages/first-time-flow/create-password/create-password.component.js new file mode 100644 index 000000000..5e67a2244 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/create-password.component.js @@ -0,0 +1,71 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route } from 'react-router-dom' +import NewAccount from './new-account' +import ImportWithSeedPhrase from './import-with-seed-phrase' +import { + INITIALIZE_CREATE_PASSWORD_ROUTE, + INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, + INITIALIZE_SEED_PHRASE_ROUTE, +} from '../../../helpers/constants/routes' + +export default class CreatePassword extends PureComponent { + static propTypes = { + history: PropTypes.object, + isInitialized: PropTypes.bool, + onCreateNewAccount: PropTypes.func, + onCreateNewAccountFromSeed: PropTypes.func, + } + + componentDidMount () { + const { isInitialized, history } = this.props + + if (isInitialized) { + history.push(INITIALIZE_SEED_PHRASE_ROUTE) + } + } + + render () { + const { onCreateNewAccount, onCreateNewAccountFromSeed } = this.props + + return ( + <div className="first-time-flow__wrapper"> + <div className="app-header__logo-container"> + <img + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" + height={42} + width={42} + /> + </div> + <Switch> + <Route + exact + path={INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE} + render={props => ( + <ImportWithSeedPhrase + { ...props } + onSubmit={onCreateNewAccountFromSeed} + /> + )} + /> + <Route + exact + path={INITIALIZE_CREATE_PASSWORD_ROUTE} + render={props => ( + <NewAccount + { ...props } + onSubmit={onCreateNewAccount} + /> + )} + /> + </Switch> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/create-password/create-password.container.js b/ui/app/pages/first-time-flow/create-password/create-password.container.js new file mode 100644 index 000000000..89106f016 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/create-password.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import CreatePassword from './create-password.component' + +const mapStateToProps = state => { + const { metamask: { isInitialized } } = state + + return { + isInitialized, + } +} + +export default connect(mapStateToProps)(CreatePassword) diff --git a/ui/app/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js b/ui/app/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js new file mode 100644 index 000000000..433dad6e2 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js @@ -0,0 +1,256 @@ +import {validateMnemonic} from 'bip39' +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import TextField from '../../../../components/ui/text-field' +import Button from '../../../../components/ui/button' +import { + INITIALIZE_SELECT_ACTION_ROUTE, + INITIALIZE_END_OF_FLOW_ROUTE, +} from '../../../../helpers/constants/routes' + +export default class ImportWithSeedPhrase extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + onSubmit: PropTypes.func.isRequired, + } + + state = { + seedPhrase: '', + password: '', + confirmPassword: '', + seedPhraseError: '', + passwordError: '', + confirmPasswordError: '', + termsChecked: false, + } + + parseSeedPhrase = (seedPhrase) => { + return seedPhrase + .trim() + .match(/\w+/g) + .join(' ') + } + + handleSeedPhraseChange (seedPhrase) { + let seedPhraseError = '' + + if (seedPhrase) { + const parsedSeedPhrase = this.parseSeedPhrase(seedPhrase) + if (parsedSeedPhrase.split(' ').length !== 12) { + seedPhraseError = this.context.t('seedPhraseReq') + } else if (!validateMnemonic(parsedSeedPhrase)) { + seedPhraseError = this.context.t('invalidSeedPhrase') + } + } + + this.setState({ seedPhrase, seedPhraseError }) + } + + handlePasswordChange (password) { + const { t } = this.context + + this.setState(state => { + const { confirmPassword } = state + let confirmPasswordError = '' + let passwordError = '' + + if (password && password.length < 8) { + passwordError = t('passwordNotLongEnough') + } + + if (confirmPassword && password !== confirmPassword) { + confirmPasswordError = t('passwordsDontMatch') + } + + return { + password, + passwordError, + confirmPasswordError, + } + }) + } + + handleConfirmPasswordChange (confirmPassword) { + const { t } = this.context + + this.setState(state => { + const { password } = state + let confirmPasswordError = '' + + if (password !== confirmPassword) { + confirmPasswordError = t('passwordsDontMatch') + } + + return { + confirmPassword, + confirmPasswordError, + } + }) + } + + handleImport = async event => { + event.preventDefault() + + if (!this.isValid()) { + return + } + + const { password, seedPhrase } = this.state + const { history, onSubmit } = this.props + + try { + await onSubmit(password, this.parseSeedPhrase(seedPhrase)) + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Import Seed Phrase', + name: 'Import Complete', + }, + }) + history.push(INITIALIZE_END_OF_FLOW_ROUTE) + } catch (error) { + this.setState({ seedPhraseError: error.message }) + } + } + + isValid () { + const { + seedPhrase, + password, + confirmPassword, + passwordError, + confirmPasswordError, + seedPhraseError, + } = this.state + + if (!password || !confirmPassword || !seedPhrase || password !== confirmPassword) { + return false + } + + if (password.length < 8) { + return false + } + + return !passwordError && !confirmPasswordError && !seedPhraseError + } + + toggleTermsCheck = () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Import Seed Phrase', + name: 'Check ToS', + }, + }) + + this.setState((prevState) => ({ + termsChecked: !prevState.termsChecked, + })) + } + + render () { + const { t } = this.context + const { seedPhraseError, passwordError, confirmPasswordError, termsChecked } = this.state + + return ( + <form + className="first-time-flow__form" + onSubmit={this.handleImport} + > + <div className="first-time-flow__create-back"> + <a + onClick={e => { + e.preventDefault() + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Import Seed Phrase', + name: 'Go Back from Onboarding Import', + }, + }) + this.props.history.push(INITIALIZE_SELECT_ACTION_ROUTE) + }} + href="#" + > + {`< Back`} + </a> + </div> + <div className="first-time-flow__header"> + { t('importAccountSeedPhrase') } + </div> + <div className="first-time-flow__text-block"> + { t('secretPhrase') } + </div> + <div className="first-time-flow__textarea-wrapper"> + <label>{ t('walletSeed') }</label> + <textarea + className="first-time-flow__textarea" + onChange={e => this.handleSeedPhraseChange(e.target.value)} + value={this.state.seedPhrase} + placeholder={t('seedPhrasePlaceholder')} + /> + </div> + { + seedPhraseError && ( + <span className="error"> + { seedPhraseError } + </span> + ) + } + <TextField + id="password" + label={t('newPassword')} + type="password" + className="first-time-flow__input" + value={this.state.password} + onChange={event => this.handlePasswordChange(event.target.value)} + error={passwordError} + autoComplete="new-password" + margin="normal" + largeLabel + /> + <TextField + id="confirm-password" + label={t('confirmPassword')} + type="password" + className="first-time-flow__input" + value={this.state.confirmPassword} + onChange={event => this.handleConfirmPasswordChange(event.target.value)} + error={confirmPasswordError} + autoComplete="confirm-password" + margin="normal" + largeLabel + /> + <div className="first-time-flow__checkbox-container" onClick={this.toggleTermsCheck}> + <div className="first-time-flow__checkbox"> + {termsChecked ? <i className="fa fa-check fa-2x" /> : null} + </div> + <span className="first-time-flow__checkbox-label"> + I have read and agree to the <a + href="https://metamask.io/terms.html" + target="_blank" + rel="noopener noreferrer" + > + <span className="first-time-flow__link-text"> + { 'Terms of Use' } + </span> + </a> + </span> + </div> + <Button + type="confirm" + className="first-time-flow__button" + disabled={!this.isValid() || !termsChecked} + onClick={this.handleImport} + > + { t('import') } + </Button> + </form> + ) + } +} diff --git a/ui/app/pages/first-time-flow/create-password/import-with-seed-phrase/index.js b/ui/app/pages/first-time-flow/create-password/import-with-seed-phrase/index.js new file mode 100644 index 000000000..e5ff1fde5 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/import-with-seed-phrase/index.js @@ -0,0 +1 @@ +export { default } from './import-with-seed-phrase.component' diff --git a/ui/app/pages/first-time-flow/create-password/index.js b/ui/app/pages/first-time-flow/create-password/index.js new file mode 100644 index 000000000..42e7436f9 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/index.js @@ -0,0 +1 @@ +export { default } from './create-password.container' diff --git a/ui/app/pages/first-time-flow/create-password/new-account/index.js b/ui/app/pages/first-time-flow/create-password/new-account/index.js new file mode 100644 index 000000000..97db39cc3 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/new-account/index.js @@ -0,0 +1 @@ +export { default } from './new-account.component' diff --git a/ui/app/pages/first-time-flow/create-password/new-account/new-account.component.js b/ui/app/pages/first-time-flow/create-password/new-account/new-account.component.js new file mode 100644 index 000000000..c040cff88 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/new-account/new-account.component.js @@ -0,0 +1,225 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Button from '../../../../components/ui/button' +import { + INITIALIZE_SEED_PHRASE_ROUTE, + INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, + INITIALIZE_SELECT_ACTION_ROUTE, +} from '../../../../helpers/constants/routes' +import TextField from '../../../../components/ui/text-field' + +export default class NewAccount extends PureComponent { + static contextTypes = { + metricsEvent: PropTypes.func, + t: PropTypes.func, + } + + static propTypes = { + onSubmit: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, + } + + state = { + password: '', + confirmPassword: '', + passwordError: '', + confirmPasswordError: '', + termsChecked: false, + } + + isValid () { + const { + password, + confirmPassword, + passwordError, + confirmPasswordError, + } = this.state + + if (!password || !confirmPassword || password !== confirmPassword) { + return false + } + + if (password.length < 8) { + return false + } + + return !passwordError && !confirmPasswordError + } + + handlePasswordChange (password) { + const { t } = this.context + + this.setState(state => { + const { confirmPassword } = state + let passwordError = '' + let confirmPasswordError = '' + + if (password && password.length < 8) { + passwordError = t('passwordNotLongEnough') + } + + if (confirmPassword && password !== confirmPassword) { + confirmPasswordError = t('passwordsDontMatch') + } + + return { + password, + passwordError, + confirmPasswordError, + } + }) + } + + handleConfirmPasswordChange (confirmPassword) { + const { t } = this.context + + this.setState(state => { + const { password } = state + let confirmPasswordError = '' + + if (password !== confirmPassword) { + confirmPasswordError = t('passwordsDontMatch') + } + + return { + confirmPassword, + confirmPasswordError, + } + }) + } + + handleCreate = async event => { + event.preventDefault() + + if (!this.isValid()) { + return + } + + const { password } = this.state + const { onSubmit, history } = this.props + + try { + await onSubmit(password) + + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Create Password', + name: 'Submit Password', + }, + }) + + history.push(INITIALIZE_SEED_PHRASE_ROUTE) + } catch (error) { + this.setState({ passwordError: error.message }) + } + } + + handleImportWithSeedPhrase = event => { + const { history } = this.props + + event.preventDefault() + history.push(INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE) + } + + toggleTermsCheck = () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Create Password', + name: 'Check ToS', + }, + }) + + this.setState((prevState) => ({ + termsChecked: !prevState.termsChecked, + })) + } + + render () { + const { t } = this.context + const { password, confirmPassword, passwordError, confirmPasswordError, termsChecked } = this.state + + return ( + <div> + <div className="first-time-flow__create-back"> + <a + onClick={e => { + e.preventDefault() + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Create Password', + name: 'Go Back from Onboarding Create', + }, + }) + this.props.history.push(INITIALIZE_SELECT_ACTION_ROUTE) + }} + href="#" + > + {`< Back`} + </a> + </div> + <div className="first-time-flow__header"> + { t('createPassword') } + </div> + <form + className="first-time-flow__form" + onSubmit={this.handleCreate} + > + <TextField + id="create-password" + label={t('newPassword')} + type="password" + className="first-time-flow__input" + value={password} + onChange={event => this.handlePasswordChange(event.target.value)} + error={passwordError} + autoFocus + autoComplete="new-password" + margin="normal" + fullWidth + largeLabel + /> + <TextField + id="confirm-password" + label={t('confirmPassword')} + type="password" + className="first-time-flow__input" + value={confirmPassword} + onChange={event => this.handleConfirmPasswordChange(event.target.value)} + error={confirmPasswordError} + autoComplete="confirm-password" + margin="normal" + fullWidth + largeLabel + /> + <div className="first-time-flow__checkbox-container" onClick={this.toggleTermsCheck}> + <div className="first-time-flow__checkbox"> + {termsChecked ? <i className="fa fa-check fa-2x" /> : null} + </div> + <span className="first-time-flow__checkbox-label"> + I have read and agree to the <a + href="https://metamask.io/terms.html" + target="_blank" + rel="noopener noreferrer" + > + <span className="first-time-flow__link-text"> + { 'Terms of Use' } + </span> + </a> + </span> + </div> + <Button + type="confirm" + className="first-time-flow__button" + disabled={!this.isValid() || !termsChecked} + onClick={this.handleCreate} + > + { t('create') } + </Button> + </form> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/create-password/unique-image/index.js b/ui/app/pages/first-time-flow/create-password/unique-image/index.js new file mode 100644 index 000000000..0e97bf755 --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/unique-image/index.js @@ -0,0 +1 @@ +export { default } from './unique-image.container' diff --git a/ui/app/pages/first-time-flow/create-password/unique-image/unique-image.component.js b/ui/app/pages/first-time-flow/create-password/unique-image/unique-image.component.js new file mode 100644 index 000000000..3434d117a --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/unique-image/unique-image.component.js @@ -0,0 +1,55 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Button from '../../../../components/ui/button' +import { INITIALIZE_END_OF_FLOW_ROUTE } from '../../../../helpers/constants/routes' + +export default class UniqueImageScreen extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + } + + render () { + const { t } = this.context + const { history } = this.props + + return ( + <div> + <img + src="/images/sleuth.svg" + height={42} + width={42} + /> + <div className="first-time-flow__header"> + { t('protectYourKeys') } + </div> + <div className="first-time-flow__text-block"> + { t('protectYourKeysMessage1') } + </div> + <div className="first-time-flow__text-block"> + { t('protectYourKeysMessage2') } + </div> + <Button + type="confirm" + className="first-time-flow__button" + onClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Agree to Phishing Warning', + name: 'Agree to Phishing Warning', + }, + }) + history.push(INITIALIZE_END_OF_FLOW_ROUTE) + }} + > + { t('next') } + </Button> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/create-password/unique-image/unique-image.container.js b/ui/app/pages/first-time-flow/create-password/unique-image/unique-image.container.js new file mode 100644 index 000000000..34874aaec --- /dev/null +++ b/ui/app/pages/first-time-flow/create-password/unique-image/unique-image.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import UniqueImage from './unique-image.component' + +const mapStateToProps = ({ metamask }) => { + const { selectedAddress } = metamask + + return { + address: selectedAddress, + } +} + +export default connect(mapStateToProps)(UniqueImage) diff --git a/ui/app/pages/first-time-flow/end-of-flow/end-of-flow.component.js b/ui/app/pages/first-time-flow/end-of-flow/end-of-flow.component.js new file mode 100644 index 000000000..c4292331b --- /dev/null +++ b/ui/app/pages/first-time-flow/end-of-flow/end-of-flow.component.js @@ -0,0 +1,93 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Button from '../../../components/ui/button' +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes' + +export default class EndOfFlowScreen extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + completeOnboarding: PropTypes.func, + completionMetaMetricsName: PropTypes.string, + } + + render () { + const { t } = this.context + const { history, completeOnboarding, completionMetaMetricsName } = this.props + + return ( + <div className="end-of-flow"> + <div className="app-header__logo-container"> + <img + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" + height={42} + width={42} + /> + </div> + <div className="end-of-flow__emoji">🎉</div> + <div className="first-time-flow__header"> + { t('congratulations') } + </div> + <div className="first-time-flow__text-block end-of-flow__text-1"> + { t('endOfFlowMessage1') } + </div> + <div className="first-time-flow__text-block end-of-flow__text-2"> + { t('endOfFlowMessage2') } + </div> + <div className="end-of-flow__text-3"> + { '• ' + t('endOfFlowMessage3') } + </div> + <div className="end-of-flow__text-3"> + { '• ' + t('endOfFlowMessage4') } + </div> + <div className="end-of-flow__text-3"> + { '• ' + t('endOfFlowMessage5') } + </div> + <div className="end-of-flow__text-3"> + { '• ' + t('endOfFlowMessage6') } + </div> + <div className="end-of-flow__text-3"> + { '• ' + t('endOfFlowMessage7') } + </div> + <div className="first-time-flow__text-block end-of-flow__text-4"> + *MetaMask cannot recover your seedphrase. <a + href="https://metamask.zendesk.com/hc/en-us/articles/360015489591-Basic-Safety-Tips" + target="_blank" + rel="noopener noreferrer" + > + <span className="first-time-flow__link-text"> + Learn More + </span> + </a>. + </div> + <Button + type="confirm" + className="first-time-flow__button" + onClick={async () => { + await completeOnboarding() + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Onboarding Complete', + name: completionMetaMetricsName, + }, + }) + history.push(DEFAULT_ROUTE) + }} + > + { 'All Done' } + </Button> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/end-of-flow/end-of-flow.container.js b/ui/app/pages/first-time-flow/end-of-flow/end-of-flow.container.js new file mode 100644 index 000000000..38313806c --- /dev/null +++ b/ui/app/pages/first-time-flow/end-of-flow/end-of-flow.container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux' +import EndOfFlow from './end-of-flow.component' +import { setCompletedOnboarding } from '../../../store/actions' + +const firstTimeFlowTypeNameMap = { + create: 'New Wallet Created', + 'import': 'New Wallet Imported', +} + +const mapStateToProps = ({ metamask }) => { + const { firstTimeFlowType } = metamask + + return { + completionMetaMetricsName: firstTimeFlowTypeNameMap[firstTimeFlowType], + } +} + + +const mapDispatchToProps = dispatch => { + return { + completeOnboarding: () => dispatch(setCompletedOnboarding()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(EndOfFlow) diff --git a/ui/app/pages/first-time-flow/end-of-flow/index.js b/ui/app/pages/first-time-flow/end-of-flow/index.js new file mode 100644 index 000000000..b0643d155 --- /dev/null +++ b/ui/app/pages/first-time-flow/end-of-flow/index.js @@ -0,0 +1 @@ +export { default } from './end-of-flow.container' diff --git a/ui/app/pages/first-time-flow/end-of-flow/index.scss b/ui/app/pages/first-time-flow/end-of-flow/index.scss new file mode 100644 index 000000000..d7eb4513b --- /dev/null +++ b/ui/app/pages/first-time-flow/end-of-flow/index.scss @@ -0,0 +1,53 @@ +.end-of-flow { + color: black; + font-family: Roboto; + font-style: normal; + + .app-header__logo-container { + width: 742px; + margin-top: 3%; + + @media screen and (max-width: $break-small) { + width: 100%; + } + } + + &__text-1, &__text-3 { + font-weight: normal; + font-size: 16px; + margin-top: 18px; + } + + &__text-2 { + font-weight: bold; + font-size: 16px; + margin-top: 26px; + } + + &__text-3 { + margin-top: 2px; + margin-bottom: 2px; + + @media screen and (max-width: $break-small) { + margin-bottom: 16px; + font-size: .875rem; + } + } + + &__text-4 { + margin-top: 26px; + } + + button { + width: 207px; + } + + &__start-over-button { + width: 744px; + } + + &__emoji { + font-size: 80px; + margin-top: 70px; + } +}
\ No newline at end of file diff --git a/ui/app/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.component.js b/ui/app/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.component.js new file mode 100644 index 000000000..4fd028482 --- /dev/null +++ b/ui/app/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.component.js @@ -0,0 +1,57 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { Redirect } from 'react-router-dom' +import { + DEFAULT_ROUTE, + LOCK_ROUTE, + INITIALIZE_WELCOME_ROUTE, + INITIALIZE_UNLOCK_ROUTE, + INITIALIZE_SEED_PHRASE_ROUTE, + INITIALIZE_METAMETRICS_OPT_IN_ROUTE, +} from '../../../helpers/constants/routes' + +export default class FirstTimeFlowSwitch extends PureComponent { + static propTypes = { + completedOnboarding: PropTypes.bool, + isInitialized: PropTypes.bool, + isUnlocked: PropTypes.bool, + seedPhrase: PropTypes.string, + optInMetaMetrics: PropTypes.bool, + } + + render () { + const { + completedOnboarding, + isInitialized, + isUnlocked, + seedPhrase, + optInMetaMetrics, + } = this.props + + if (completedOnboarding) { + return <Redirect to={{ pathname: DEFAULT_ROUTE }} /> + } + + if (isUnlocked && !seedPhrase) { + return <Redirect to={{ pathname: LOCK_ROUTE }} /> + } + + if (!isInitialized) { + return <Redirect to={{ pathname: INITIALIZE_WELCOME_ROUTE }} /> + } + + if (!isUnlocked) { + return <Redirect to={{ pathname: INITIALIZE_UNLOCK_ROUTE }} /> + } + + if (seedPhrase) { + return <Redirect to={{ pathname: INITIALIZE_SEED_PHRASE_ROUTE }} /> + } + + if (optInMetaMetrics === null) { + return <Redirect to={{ pathname: INITIALIZE_WELCOME_ROUTE }} /> + } + + return <Redirect to={{ pathname: INITIALIZE_METAMETRICS_OPT_IN_ROUTE }} /> + } +} diff --git a/ui/app/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.container.js b/ui/app/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.container.js new file mode 100644 index 000000000..d68f7a153 --- /dev/null +++ b/ui/app/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import FirstTimeFlowSwitch from './first-time-flow-switch.component' + +const mapStateToProps = ({ metamask }) => { + const { + completedOnboarding, + isInitialized, + isUnlocked, + participateInMetaMetrics: optInMetaMetrics, + } = metamask + + return { + completedOnboarding, + isInitialized, + isUnlocked, + optInMetaMetrics, + } +} + +export default connect(mapStateToProps)(FirstTimeFlowSwitch) diff --git a/ui/app/pages/first-time-flow/first-time-flow-switch/index.js b/ui/app/pages/first-time-flow/first-time-flow-switch/index.js new file mode 100644 index 000000000..3647756ef --- /dev/null +++ b/ui/app/pages/first-time-flow/first-time-flow-switch/index.js @@ -0,0 +1 @@ +export { default } from './first-time-flow-switch.container' diff --git a/ui/app/pages/first-time-flow/first-time-flow.component.js b/ui/app/pages/first-time-flow/first-time-flow.component.js new file mode 100644 index 000000000..bf6e80ca9 --- /dev/null +++ b/ui/app/pages/first-time-flow/first-time-flow.component.js @@ -0,0 +1,152 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route } from 'react-router-dom' +import FirstTimeFlowSwitch from './first-time-flow-switch' +import Welcome from './welcome' +import SelectAction from './select-action' +import EndOfFlow from './end-of-flow' +import Unlock from '../unlock-page' +import CreatePassword from './create-password' +import SeedPhrase from './seed-phrase' +import MetaMetricsOptInScreen from './metametrics-opt-in' +import { + DEFAULT_ROUTE, + INITIALIZE_WELCOME_ROUTE, + INITIALIZE_CREATE_PASSWORD_ROUTE, + INITIALIZE_SEED_PHRASE_ROUTE, + INITIALIZE_UNLOCK_ROUTE, + INITIALIZE_SELECT_ACTION_ROUTE, + INITIALIZE_END_OF_FLOW_ROUTE, + INITIALIZE_METAMETRICS_OPT_IN_ROUTE, +} from '../../helpers/constants/routes' + +export default class FirstTimeFlow extends PureComponent { + static propTypes = { + completedOnboarding: PropTypes.bool, + createNewAccount: PropTypes.func, + createNewAccountFromSeed: PropTypes.func, + history: PropTypes.object, + isInitialized: PropTypes.bool, + isUnlocked: PropTypes.bool, + unlockAccount: PropTypes.func, + nextRoute: PropTypes.func, + } + + state = { + seedPhrase: '', + isImportedKeyring: false, + } + + componentDidMount () { + const { completedOnboarding, history, isInitialized, isUnlocked } = this.props + + if (completedOnboarding) { + history.push(DEFAULT_ROUTE) + return + } + + if (isInitialized && !isUnlocked) { + history.push(INITIALIZE_UNLOCK_ROUTE) + return + } + } + + handleCreateNewAccount = async password => { + const { createNewAccount } = this.props + + try { + const seedPhrase = await createNewAccount(password) + this.setState({ seedPhrase }) + } catch (error) { + throw new Error(error.message) + } + } + + handleImportWithSeedPhrase = async (password, seedPhrase) => { + const { createNewAccountFromSeed } = this.props + + try { + await createNewAccountFromSeed(password, seedPhrase) + this.setState({ isImportedKeyring: true }) + } catch (error) { + throw new Error(error.message) + } + } + + handleUnlock = async password => { + const { unlockAccount, history, nextRoute } = this.props + + try { + const seedPhrase = await unlockAccount(password) + this.setState({ seedPhrase }, () => { + history.push(nextRoute) + }) + } catch (error) { + throw new Error(error.message) + } + } + + render () { + const { seedPhrase, isImportedKeyring } = this.state + + return ( + <div className="first-time-flow"> + <Switch> + <Route + path={INITIALIZE_SEED_PHRASE_ROUTE} + render={props => ( + <SeedPhrase + { ...props } + seedPhrase={seedPhrase} + /> + )} + /> + <Route + path={INITIALIZE_CREATE_PASSWORD_ROUTE} + render={props => ( + <CreatePassword + { ...props } + isImportedKeyring={isImportedKeyring} + onCreateNewAccount={this.handleCreateNewAccount} + onCreateNewAccountFromSeed={this.handleImportWithSeedPhrase} + /> + )} + /> + <Route + path={INITIALIZE_SELECT_ACTION_ROUTE} + component={SelectAction} + /> + <Route + path={INITIALIZE_UNLOCK_ROUTE} + render={props => ( + <Unlock + { ...props } + onSubmit={this.handleUnlock} + /> + )} + /> + <Route + exact + path={INITIALIZE_END_OF_FLOW_ROUTE} + component={EndOfFlow} + /> + <Route + exact + path={INITIALIZE_WELCOME_ROUTE} + component={Welcome} + /> + <Route + exact + path={INITIALIZE_METAMETRICS_OPT_IN_ROUTE} + component={MetaMetricsOptInScreen} + /> + <Route + exact + path="*" + component={FirstTimeFlowSwitch} + /> + </Switch> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/first-time-flow.container.js b/ui/app/pages/first-time-flow/first-time-flow.container.js new file mode 100644 index 000000000..16025a489 --- /dev/null +++ b/ui/app/pages/first-time-flow/first-time-flow.container.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux' +import FirstTimeFlow from './first-time-flow.component' +import { getFirstTimeFlowTypeRoute } from './first-time-flow.selectors' +import { + createNewVaultAndGetSeedPhrase, + createNewVaultAndRestore, + unlockAndGetSeedPhrase, +} from '../../store/actions' + +const mapStateToProps = state => { + const { metamask: { completedOnboarding, isInitialized, isUnlocked } } = state + + return { + completedOnboarding, + isInitialized, + isUnlocked, + nextRoute: getFirstTimeFlowTypeRoute(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + createNewAccount: password => dispatch(createNewVaultAndGetSeedPhrase(password)), + createNewAccountFromSeed: (password, seedPhrase) => { + return dispatch(createNewVaultAndRestore(password, seedPhrase)) + }, + unlockAccount: password => dispatch(unlockAndGetSeedPhrase(password)), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(FirstTimeFlow) diff --git a/ui/app/pages/first-time-flow/first-time-flow.selectors.js b/ui/app/pages/first-time-flow/first-time-flow.selectors.js new file mode 100644 index 000000000..e6cd5a84a --- /dev/null +++ b/ui/app/pages/first-time-flow/first-time-flow.selectors.js @@ -0,0 +1,26 @@ +import { + INITIALIZE_CREATE_PASSWORD_ROUTE, + INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, + DEFAULT_ROUTE, +} from '../../helpers/constants/routes' + +const selectors = { + getFirstTimeFlowTypeRoute, +} + +module.exports = selectors + +function getFirstTimeFlowTypeRoute (state) { + const { firstTimeFlowType } = state.metamask + + let nextRoute + if (firstTimeFlowType === 'create') { + nextRoute = INITIALIZE_CREATE_PASSWORD_ROUTE + } else if (firstTimeFlowType === 'import') { + nextRoute = INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE + } else { + nextRoute = DEFAULT_ROUTE + } + + return nextRoute +} diff --git a/ui/app/pages/first-time-flow/index.js b/ui/app/pages/first-time-flow/index.js new file mode 100644 index 000000000..5db42437c --- /dev/null +++ b/ui/app/pages/first-time-flow/index.js @@ -0,0 +1 @@ +export { default } from './first-time-flow.container' diff --git a/ui/app/pages/first-time-flow/index.scss b/ui/app/pages/first-time-flow/index.scss new file mode 100644 index 000000000..6c65cfdae --- /dev/null +++ b/ui/app/pages/first-time-flow/index.scss @@ -0,0 +1,159 @@ +@import 'welcome/index'; + +@import 'select-action/index'; + +@import 'seed-phrase/index'; + +@import 'end-of-flow/index'; + +@import 'metametrics-opt-in/index'; + + +.first-time-flow { + width: 100%; + background-color: $white; + display: flex; + justify-content: center; + + &__wrapper { + @media screen and (min-width: $break-large) { + max-width: 742px; + display: flex; + flex-direction: column; + width: 100%; + margin-top: 2%; + } + + .app-header__metafox-logo { + margin-bottom: 40px; + } + } + + &__form { + display: flex; + flex-direction: column; + } + + &__create-back { + margin-bottom: 16px; + } + + &__header { + font-size: 2.5rem; + margin-bottom: 24px; + color: black; + } + + &__subheader { + margin-bottom: 16px; + } + + &__input { + max-width: 350px; + } + + &__textarea-wrapper { + margin-bottom: 8px; + display: inline-flex; + padding: 0; + position: relative; + min-width: 0; + flex-direction: column; + max-width: 350px; + } + + &__textarea-label { + margin-bottom: 9px; + color: #1B344D; + font-size: 18px; + } + + &__textarea { + font-size: 1rem; + font-family: Roboto; + height: 190px; + border: 1px solid #CDCDCD; + border-radius: 6px; + background-color: #FFFFFF; + padding: 16px; + margin-top: 8px; + } + + &__breadcrumbs { + margin: 36px 0; + } + + &__unique-image { + margin-bottom: 20px; + } + + &__markdown { + border: 1px solid #979797; + border-radius: 8px; + background-color: $white; + height: 200px; + overflow-y: auto; + color: #757575; + font-size: .75rem; + line-height: 15px; + text-align: justify; + margin: 0; + padding: 16px 20px; + height: 30vh; + } + + &__text-block { + margin-bottom: 24px; + color: black; + + @media screen and (max-width: $break-small) { + margin-bottom: 16px; + font-size: .875rem; + } + } + + &__button { + margin: 35px 0 14px; + width: 140px; + height: 44px; + } + + &__checkbox-container { + display: flex; + align-items: center; + margin-top: 24px; + } + + &__checkbox { + background: #FFFFFF; + border: 1px solid #CDCDCD; + box-sizing: border-box; + height: 34px; + width: 34px; + display: flex; + justify-content: center; + align-items: center; + + &:hover { + border: 1.5px solid #2f9ae0; + } + + .fa-check { + color: #2f9ae0 + } + } + + &__checkbox-label { + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: normal; + font-size: 18px; + color: #939090; + margin-left: 18px; + } + + &__link-text { + color: $curious-blue; + } +} diff --git a/ui/app/pages/first-time-flow/metametrics-opt-in/index.js b/ui/app/pages/first-time-flow/metametrics-opt-in/index.js new file mode 100644 index 000000000..4bc2fc3a7 --- /dev/null +++ b/ui/app/pages/first-time-flow/metametrics-opt-in/index.js @@ -0,0 +1 @@ +export { default } from './metametrics-opt-in.container' diff --git a/ui/app/pages/first-time-flow/metametrics-opt-in/index.scss b/ui/app/pages/first-time-flow/metametrics-opt-in/index.scss new file mode 100644 index 000000000..6c2e37785 --- /dev/null +++ b/ui/app/pages/first-time-flow/metametrics-opt-in/index.scss @@ -0,0 +1,136 @@ +.metametrics-opt-in { + position: relative; + width: 100%; + + a { + color: #2f9ae0bf; + } + + &__main { + display: flex; + flex-direction: column; + margin-left: 26.26%; + margin-right: 28%; + color: black; + + @media screen and (max-width: 575px) { + justify-content: center; + margin-left: 2%; + margin-right: 0%; + } + + .app-header__logo-container { + margin-top: 3%; + } + } + + &__title { + position: relative; + margin-top: 20px; + + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: normal; + font-size: 42px; + } + + &__body-graphic { + margin-top: 25px; + + .fa-bar-chart { + color: #C4C4C4; + } + } + + &__description { + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: 21px; + font-size: 16px; + margin-top: 12px; + } + + &__committments { + display: flex; + flex-direction: column; + } + + &__content { + overflow-y: scroll; + flex: 1; + } + + &__row { + display: flex; + margin-top: 8px; + + .fa-check { + margin-right: 12px; + color: #1ACC56; + } + + .fa-times { + margin-right: 12px; + color: #D0021B; + } + } + + &__bold { + font-weight: bold; + } + + &__break-row { + margin-top: 30px; + } + + &__body { + position: relative; + display: flex; + max-width: 730px; + flex-direction: column; + } + + &__body-text { + max-width: 548px; + margin-left: 16px; + margin-right: 16px; + } + + &__bottom-text { + margin-top: 10px; + color: #9a9a9a; + } + + &__content { + overflow-y: auto; + } + + &__footer { + margin-top: 26px; + + @media screen and (max-width: 575px) { + margin-top: 10px; + justify-content: center; + margin-left: 2%; + max-height: 520px; + } + + .page-container__footer { + border-top: none; + max-width: 535px; + margin-bottom: 15px; + + button { + height: 44px; + min-height: 44px; + margin-right: 16px; + } + + header { + padding: 0px; + } + } + } +}
\ No newline at end of file diff --git a/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js b/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js new file mode 100644 index 000000000..19c668278 --- /dev/null +++ b/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js @@ -0,0 +1,169 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import PageContainerFooter from '../../../components/ui/page-container/page-container-footer' + +export default class MetaMetricsOptIn extends Component { + static propTypes = { + history: PropTypes.object, + setParticipateInMetaMetrics: PropTypes.func, + nextRoute: PropTypes.string, + firstTimeSelectionMetaMetricsName: PropTypes.string, + participateInMetaMetrics: PropTypes.bool, + } + + static contextTypes = { + metricsEvent: PropTypes.func, + } + + render () { + const { metricsEvent } = this.context + const { + nextRoute, + history, + setParticipateInMetaMetrics, + firstTimeSelectionMetaMetricsName, + participateInMetaMetrics, + } = this.props + + return ( + <div className="metametrics-opt-in"> + <div className="metametrics-opt-in__main"> + <div className="app-header__logo-container"> + <img + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" + height={42} + width={42} + /> + </div> + <div className="metametrics-opt-in__body-graphic"> + <img src="images/metrics-chart.svg" /> + </div> + <div className="metametrics-opt-in__title">Help Us Improve MetaMask</div> + <div className="metametrics-opt-in__body"> + <div className="metametrics-opt-in__description"> + MetaMask would like to gather usage data to better understand how our users interact with the extension. This data + will be used to continually improve the usability and user experience of our product and the Ethereum ecosystem. + </div> + <div className="metametrics-opt-in__description"> + MetaMask will.. + </div> + + <div className="metametrics-opt-in__committments"> + <div className="metametrics-opt-in__row"> + <i className="fa fa-check" /> + <div className="metametrics-opt-in__row-description"> + Always allow you to opt-out via Settings + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-check" /> + <div className="metametrics-opt-in__row-description"> + Send anonymized click & pageview events + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-check" /> + <div className="metametrics-opt-in__row-description"> + Maintain a public aggregate dashboard to educate the community + </div> + </div> + <div className="metametrics-opt-in__row metametrics-opt-in__break-row"> + <i className="fa fa-times" /> + <div className="metametrics-opt-in__row-description"> + <span className="metametrics-opt-in__bold">Never</span> collect keys, addresses, transactions, balances, hashes, or any personal information + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-times" /> + <div className="metametrics-opt-in__row-description"> + <span className="metametrics-opt-in__bold">Never</span> collect your full IP address + </div> + </div> + <div className="metametrics-opt-in__row"> + <i className="fa fa-times" /> + <div className="metametrics-opt-in__row-description"> + <span className="metametrics-opt-in__bold">Never</span> sell data for profit. Ever! + </div> + </div> + </div> + </div> + <div className="metametrics-opt-in__footer"> + <PageContainerFooter + onCancel={() => { + setParticipateInMetaMetrics(false) + .then(() => { + const promise = participateInMetaMetrics !== false + ? metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Metrics Option', + name: 'Metrics Opt Out', + }, + isOptIn: true, + }) + : Promise.resolve() + + promise + .then(() => { + history.push(nextRoute) + }) + }) + }} + cancelText={'No Thanks'} + hideCancel={false} + onSubmit={() => { + setParticipateInMetaMetrics(true) + .then(([participateStatus, metaMetricsId]) => { + const promise = participateInMetaMetrics !== true + ? metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Metrics Option', + name: 'Metrics Opt In', + }, + isOptIn: true, + }) + : Promise.resolve() + + promise + .then(() => { + return metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Import or Create', + name: firstTimeSelectionMetaMetricsName, + }, + isOptIn: true, + metaMetricsId, + }) + }) + .then(() => { + history.push(nextRoute) + }) + }) + }} + submitText={'I agree'} + submitButtonType={'confirm'} + disabled={false} + /> + <div className="metametrics-opt-in__bottom-text"> + This data is aggregated and is therefore anonymous for the purposes of General Data Protection Regulation (EU) 2016/679. For more information in relation to our privacy practices, please see our <a + href="https://metamask.io/privacy.html" + target="_blank" + rel="noopener noreferrer" + > + Privacy Policy here + </a>. + </div> + </div> + </div> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.container.js b/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.container.js new file mode 100644 index 000000000..2566a2a56 --- /dev/null +++ b/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.container.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux' +import MetaMetricsOptIn from './metametrics-opt-in.component' +import { setParticipateInMetaMetrics } from '../../../store/actions' +import { getFirstTimeFlowTypeRoute } from '../first-time-flow.selectors' + +const firstTimeFlowTypeNameMap = { + create: 'Selected Create New Wallet', + 'import': 'Selected Import Wallet', +} + +const mapStateToProps = (state) => { + const { firstTimeFlowType, participateInMetaMetrics } = state.metamask + + return { + nextRoute: getFirstTimeFlowTypeRoute(state), + firstTimeSelectionMetaMetricsName: firstTimeFlowTypeNameMap[firstTimeFlowType], + participateInMetaMetrics, + } +} + +const mapDispatchToProps = dispatch => { + return { + setParticipateInMetaMetrics: (val) => dispatch(setParticipateInMetaMetrics(val)), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(MetaMetricsOptIn) diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js new file mode 100644 index 000000000..59b4f73a6 --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js @@ -0,0 +1,155 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import shuffle from 'lodash.shuffle' +import Button from '../../../../components/ui/button' +import { + INITIALIZE_END_OF_FLOW_ROUTE, + INITIALIZE_SEED_PHRASE_ROUTE, +} from '../../../../helpers/constants/routes' +import { exportAsFile } from '../../../../helpers/utils/util' +import { selectSeedWord, deselectSeedWord } from './confirm-seed-phrase.state' + +export default class ConfirmSeedPhrase extends PureComponent { + static contextTypes = { + metricsEvent: PropTypes.func, + t: PropTypes.func, + } + + static defaultProps = { + seedPhrase: '', + } + + static propTypes = { + history: PropTypes.object, + onSubmit: PropTypes.func, + seedPhrase: PropTypes.string, + } + + state = { + selectedSeedWords: [], + shuffledSeedWords: [], + // Hash of shuffledSeedWords index {Number} to selectedSeedWords index {Number} + selectedSeedWordsHash: {}, + } + + componentDidMount () { + const { seedPhrase = '' } = this.props + const shuffledSeedWords = shuffle(seedPhrase.split(' ')) || [] + this.setState({ shuffledSeedWords }) + } + + handleExport = () => { + exportAsFile('MetaMask Secret Backup Phrase', this.props.seedPhrase, 'text/plain') + } + + handleSubmit = async () => { + const { history } = this.props + + if (!this.isValid()) { + return + } + + try { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Seed Phrase Setup', + name: 'Verify Complete', + }, + }) + history.push(INITIALIZE_END_OF_FLOW_ROUTE) + } catch (error) { + console.error(error.message) + } + } + + handleSelectSeedWord = (word, shuffledIndex) => { + this.setState(selectSeedWord(word, shuffledIndex)) + } + + handleDeselectSeedWord = shuffledIndex => { + this.setState(deselectSeedWord(shuffledIndex)) + } + + isValid () { + const { seedPhrase } = this.props + const { selectedSeedWords } = this.state + return seedPhrase === selectedSeedWords.join(' ') + } + + render () { + const { t } = this.context + const { history } = this.props + const { selectedSeedWords, shuffledSeedWords, selectedSeedWordsHash } = this.state + + return ( + <div className="confirm-seed-phrase"> + <div className="confirm-seed-phrase__back-button"> + <a + onClick={e => { + e.preventDefault() + history.push(INITIALIZE_SEED_PHRASE_ROUTE) + }} + href="#" + > + {`< Back`} + </a> + </div> + <div className="first-time-flow__header"> + { t('confirmSecretBackupPhrase') } + </div> + <div className="first-time-flow__text-block"> + { t('selectEachPhrase') } + </div> + <div className="confirm-seed-phrase__selected-seed-words"> + { + selectedSeedWords.map((word, index) => ( + <div + key={index} + className="confirm-seed-phrase__seed-word" + > + { word } + </div> + )) + } + </div> + <div className="confirm-seed-phrase__shuffled-seed-words"> + { + shuffledSeedWords.map((word, index) => { + const isSelected = index in selectedSeedWordsHash + + return ( + <div + key={index} + className={classnames( + 'confirm-seed-phrase__seed-word', + 'confirm-seed-phrase__seed-word--shuffled', + { 'confirm-seed-phrase__seed-word--selected': isSelected } + )} + onClick={() => { + if (!isSelected) { + this.handleSelectSeedWord(word, index) + } else { + this.handleDeselectSeedWord(index) + } + }} + > + { word } + </div> + ) + }) + } + </div> + <Button + type="confirm" + className="first-time-flow__button" + onClick={this.handleSubmit} + disabled={!this.isValid()} + > + { t('confirm') } + </Button> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.state.js b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.state.js new file mode 100644 index 000000000..f2476fc5c --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.state.js @@ -0,0 +1,41 @@ +export function selectSeedWord (word, shuffledIndex) { + return function update (state) { + const { selectedSeedWords, selectedSeedWordsHash } = state + const nextSelectedIndex = selectedSeedWords.length + + return { + selectedSeedWords: [ ...selectedSeedWords, word ], + selectedSeedWordsHash: { ...selectedSeedWordsHash, [shuffledIndex]: nextSelectedIndex }, + } + } +} + +export function deselectSeedWord (shuffledIndex) { + return function update (state) { + const { + selectedSeedWords: prevSelectedSeedWords, + selectedSeedWordsHash: prevSelectedSeedWordsHash, + } = state + + const selectedSeedWords = [...prevSelectedSeedWords] + const indexToRemove = prevSelectedSeedWordsHash[shuffledIndex] + selectedSeedWords.splice(indexToRemove, 1) + const selectedSeedWordsHash = Object.keys(prevSelectedSeedWordsHash).reduce((acc, index) => { + const output = { ...acc } + const selectedSeedWordIndex = prevSelectedSeedWordsHash[index] + + if (selectedSeedWordIndex < indexToRemove) { + output[index] = selectedSeedWordIndex + } else if (selectedSeedWordIndex > indexToRemove) { + output[index] = selectedSeedWordIndex - 1 + } + + return output + }, {}) + + return { + selectedSeedWords, + selectedSeedWordsHash, + } + } +} diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.js b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.js new file mode 100644 index 000000000..c7b511503 --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.js @@ -0,0 +1 @@ +export { default } from './confirm-seed-phrase.component' diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.scss b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.scss new file mode 100644 index 000000000..93137618c --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.scss @@ -0,0 +1,48 @@ +.confirm-seed-phrase { + &__back-button { + margin-bottom: 12px; + } + + &__selected-seed-words { + min-height: 190px; + max-width: 496px; + border: 1px solid #CDCDCD; + border-radius: 6px; + background-color: $white; + margin: 24px 0 36px; + padding: 12px; + } + + &__shuffled-seed-words { + max-width: 496px; + } + + &__seed-word { + display: inline-block; + color: #5B5D67; + background-color: #E7E7E7; + padding: 8px 18px; + min-width: 64px; + margin: 4px; + text-align: center; + + &--selected { + background-color: #85D1CC; + color: $white; + } + + &--shuffled { + cursor: pointer; + margin: 6px; + } + + @media screen and (max-width: 575px) { + font-size: .875rem; + padding: 6px 18px; + } + } + + button { + margin-top: 0xp; + } +} diff --git a/ui/app/pages/first-time-flow/seed-phrase/index.js b/ui/app/pages/first-time-flow/seed-phrase/index.js new file mode 100644 index 000000000..185b3f089 --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/index.js @@ -0,0 +1 @@ +export { default } from './seed-phrase.component' diff --git a/ui/app/pages/first-time-flow/seed-phrase/index.scss b/ui/app/pages/first-time-flow/seed-phrase/index.scss new file mode 100644 index 000000000..24da45ded --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/index.scss @@ -0,0 +1,40 @@ +@import 'confirm-seed-phrase/index'; + +@import 'reveal-seed-phrase/index'; + +.seed-phrase { + + &__sections { + display: flex; + + @media screen and (min-width: $break-large) { + flex-direction: row; + } + + @media screen and (max-width: $break-small) { + flex-direction: column; + } + } + + &__main { + flex: 3; + min-width: 0; + } + + &__side { + flex: 2; + min-width: 0; + + @media screen and (min-width: $break-large) { + margin-left: 81px; + } + + @media screen and (max-width: $break-small) { + margin-top: 24px; + } + + .first-time-flow__text-block { + color: #5A5A5A; + } + } +} diff --git a/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/index.js b/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/index.js new file mode 100644 index 000000000..4a1b191b5 --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/index.js @@ -0,0 +1 @@ +export { default } from './reveal-seed-phrase.component' diff --git a/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/index.scss b/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/index.scss new file mode 100644 index 000000000..8a47447ed --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/index.scss @@ -0,0 +1,57 @@ +.reveal-seed-phrase { + &__secret { + position: relative; + display: flex; + justify-content: center; + border: 1px solid #CDCDCD; + border-radius: 6px; + background-color: $white; + padding: 18px; + margin-top: 36px; + max-width: 350px; + } + + &__secret-words { + width: 310px; + font-size: 1.25rem; + text-align: center; + + &--hidden { + filter: blur(5px); + } + } + + &__secret-blocker { + position: absolute; + top: 0; + bottom: 0; + height: 100%; + width: 100%; + background-color: rgba(0,0,0,0.6); + display: flex; + flex-flow: column nowrap; + align-items: center; + justify-content: center; + padding: 8px 0 18px; + cursor: pointer; + } + + &__reveal-button { + color: $white; + font-size: .75rem; + font-weight: 500; + text-transform: uppercase; + margin-top: 8px; + text-align: center; + } + + &__export-text { + color: $curious-blue; + cursor: pointer; + font-weight: 500; + } + + button { + margin-top: 0xp; + } +} diff --git a/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js b/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js new file mode 100644 index 000000000..ee352d74e --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js @@ -0,0 +1,143 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import LockIcon from '../../../../components/ui/lock-icon' +import Button from '../../../../components/ui/button' +import { INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE } from '../../../../helpers/constants/routes' +import { exportAsFile } from '../../../../helpers/utils/util' + +export default class RevealSeedPhrase extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + seedPhrase: PropTypes.string, + } + + state = { + isShowingSeedPhrase: false, + } + + handleExport = () => { + exportAsFile('MetaMask Secret Backup Phrase', this.props.seedPhrase, 'text/plain') + } + + handleNext = event => { + event.preventDefault() + const { isShowingSeedPhrase } = this.state + const { history } = this.props + + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Seed Phrase Setup', + name: 'Advance to Verify', + }, + }) + + if (!isShowingSeedPhrase) { + return + } + + history.push(INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE) + } + + renderSecretWordsContainer () { + const { t } = this.context + const { seedPhrase } = this.props + const { isShowingSeedPhrase } = this.state + + return ( + <div className="reveal-seed-phrase__secret"> + <div className={classnames( + 'reveal-seed-phrase__secret-words', + { 'reveal-seed-phrase__secret-words--hidden': !isShowingSeedPhrase } + )}> + { seedPhrase } + </div> + { + !isShowingSeedPhrase && ( + <div + className="reveal-seed-phrase__secret-blocker" + onClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Seed Phrase Setup', + name: 'Revealed Words', + }, + }) + this.setState({ isShowingSeedPhrase: true }) + }} + > + <LockIcon + width="28px" + height="35px" + fill="#FFFFFF" + /> + <div className="reveal-seed-phrase__reveal-button"> + { t('clickToRevealSeed') } + </div> + </div> + ) + } + </div> + ) + } + + render () { + const { t } = this.context + const { isShowingSeedPhrase } = this.state + + return ( + <div className="reveal-seed-phrase"> + <div className="seed-phrase__sections"> + <div className="seed-phrase__main"> + <div className="first-time-flow__header"> + { t('secretBackupPhrase') } + </div> + <div className="first-time-flow__text-block"> + { t('secretBackupPhraseDescription') } + </div> + <div className="first-time-flow__text-block"> + { t('secretBackupPhraseWarning') } + </div> + { this.renderSecretWordsContainer() } + </div> + <div className="seed-phrase__side"> + <div className="first-time-flow__text-block"> + { `${t('tips')}:` } + </div> + <div className="first-time-flow__text-block"> + { t('storePhrase') } + </div> + <div className="first-time-flow__text-block"> + { t('writePhrase') } + </div> + <div className="first-time-flow__text-block"> + { t('memorizePhrase') } + </div> + <div className="first-time-flow__text-block"> + <a + className="reveal-seed-phrase__export-text" + onClick={this.handleExport}> + { t('downloadSecretBackup') } + </a> + </div> + </div> + </div> + <Button + type="confirm" + className="first-time-flow__button" + onClick={this.handleNext} + disabled={!isShowingSeedPhrase} + > + { t('next') } + </Button> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/seed-phrase/seed-phrase.component.js b/ui/app/pages/first-time-flow/seed-phrase/seed-phrase.component.js new file mode 100644 index 000000000..9a9f84049 --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/seed-phrase.component.js @@ -0,0 +1,70 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route } from 'react-router-dom' +import RevealSeedPhrase from './reveal-seed-phrase' +import ConfirmSeedPhrase from './confirm-seed-phrase' +import { + INITIALIZE_SEED_PHRASE_ROUTE, + INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE, + DEFAULT_ROUTE, +} from '../../../helpers/constants/routes' + +export default class SeedPhrase extends PureComponent { + static propTypes = { + address: PropTypes.string, + history: PropTypes.object, + seedPhrase: PropTypes.string, + } + + componentDidMount () { + const { seedPhrase, history } = this.props + + if (!seedPhrase) { + history.push(DEFAULT_ROUTE) + } + } + + render () { + const { seedPhrase } = this.props + + return ( + <div className="first-time-flow__wrapper"> + <div className="app-header__logo-container"> + <img + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" + height={42} + width={42} + /> + </div> + <Switch> + <Route + exact + path={INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE} + render={props => ( + <ConfirmSeedPhrase + { ...props } + seedPhrase={seedPhrase} + /> + )} + /> + <Route + exact + path={INITIALIZE_SEED_PHRASE_ROUTE} + render={props => ( + <RevealSeedPhrase + { ...props } + seedPhrase={seedPhrase} + /> + )} + /> + </Switch> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/select-action/index.js b/ui/app/pages/first-time-flow/select-action/index.js new file mode 100644 index 000000000..4fbe1823b --- /dev/null +++ b/ui/app/pages/first-time-flow/select-action/index.js @@ -0,0 +1 @@ +export { default } from './select-action.container' diff --git a/ui/app/pages/first-time-flow/select-action/index.scss b/ui/app/pages/first-time-flow/select-action/index.scss new file mode 100644 index 000000000..e1b22d05b --- /dev/null +++ b/ui/app/pages/first-time-flow/select-action/index.scss @@ -0,0 +1,88 @@ +.select-action { + .app-header__logo-container { + width: 742px; + margin-top: 3%; + } + + &__body { + display: flex; + flex-direction: column; + align-items: center; + } + + &__body-header { + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: 39px; + font-size: 28px; + text-align: center; + margin-top: 65px; + color: black; + } + + &__select-buttons { + display: flex; + flex-direction: row; + margin-top: 40px; + } + + &__select-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; + width: 388px; + height: 278px; + + border: 1px solid #D8D8D8; + box-sizing: border-box; + border-radius: 10px; + margin-left: 22px; + + .first-time-flow__button { + max-width: 221px; + height: 44px; + } + } + + &__button-symbol { + color: #C4C4C4; + margin-top: 41px; + } + + &__button-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 144px; + } + + &__button-text-big { + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: 28px; + font-size: 20px; + color: #000000; + margin-top: 12px; + text-align: center; + } + + &__button-text-small { + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: 20px; + font-size: 14px; + color: #7A7A7B; + margin-top: 10px; + text-align: center; + } + + button { + font-weight: 500; + width: 221px; + } +}
\ No newline at end of file diff --git a/ui/app/pages/first-time-flow/select-action/select-action.component.js b/ui/app/pages/first-time-flow/select-action/select-action.component.js new file mode 100644 index 000000000..b25a15514 --- /dev/null +++ b/ui/app/pages/first-time-flow/select-action/select-action.component.js @@ -0,0 +1,112 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Button from '../../../components/ui/button' +import { + INITIALIZE_METAMETRICS_OPT_IN_ROUTE, +} from '../../../helpers/constants/routes' + +export default class SelectAction extends PureComponent { + static propTypes = { + history: PropTypes.object, + isInitialized: PropTypes.bool, + setFirstTimeFlowType: PropTypes.func, + nextRoute: PropTypes.string, + } + + static contextTypes = { + t: PropTypes.func, + } + + componentDidMount () { + const { history, isInitialized, nextRoute } = this.props + + if (isInitialized) { + history.push(nextRoute) + } + } + + handleCreate = () => { + this.props.setFirstTimeFlowType('create') + this.props.history.push(INITIALIZE_METAMETRICS_OPT_IN_ROUTE) + } + + handleImport = () => { + this.props.setFirstTimeFlowType('import') + this.props.history.push(INITIALIZE_METAMETRICS_OPT_IN_ROUTE) + } + + render () { + const { t } = this.context + + return ( + <div className="select-action"> + <div className="app-header__logo-container"> + <img + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" + height={42} + width={42} + /> + </div> + + <div className="select-action__wrapper"> + + + <div className="select-action__body"> + <div className="select-action__body-header"> + { t('newToMetaMask') } + </div> + <div className="select-action__select-buttons"> + <div className="select-action__select-button"> + <div className="select-action__button-content"> + <div className="select-action__button-symbol"> + <img src="/images/download-alt.svg" /> + </div> + <div className="select-action__button-text-big"> + { t('noAlreadyHaveSeed') } + </div> + <div className="select-action__button-text-small"> + { t('importYourExisting') } + </div> + </div> + <Button + type="primary" + className="first-time-flow__button" + onClick={this.handleImport} + > + { t('importWallet') } + </Button> + </div> + <div className="select-action__select-button"> + <div className="select-action__button-content"> + <div className="select-action__button-symbol"> + <img src="/images/thin-plus.svg" /> + </div> + <div className="select-action__button-text-big"> + { t('letsGoSetUp') } + </div> + <div className="select-action__button-text-small"> + { t('thisWillCreate') } + </div> + </div> + <Button + type="confirm" + className="first-time-flow__button" + onClick={this.handleCreate} + > + { t('createAWallet') } + </Button> + </div> + </div> + </div> + + </div> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/select-action/select-action.container.js b/ui/app/pages/first-time-flow/select-action/select-action.container.js new file mode 100644 index 000000000..9dc988430 --- /dev/null +++ b/ui/app/pages/first-time-flow/select-action/select-action.container.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import { setFirstTimeFlowType } from '../../../store/actions' +import { getFirstTimeFlowTypeRoute } from '../first-time-flow.selectors' +import Welcome from './select-action.component' + +const mapStateToProps = (state) => { + return { + nextRoute: getFirstTimeFlowTypeRoute(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + setFirstTimeFlowType: type => dispatch(setFirstTimeFlowType(type)), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(Welcome) diff --git a/ui/app/pages/first-time-flow/welcome/index.js b/ui/app/pages/first-time-flow/welcome/index.js new file mode 100644 index 000000000..8abeddaa1 --- /dev/null +++ b/ui/app/pages/first-time-flow/welcome/index.js @@ -0,0 +1 @@ +export { default } from './welcome.container' diff --git a/ui/app/pages/first-time-flow/welcome/index.scss b/ui/app/pages/first-time-flow/welcome/index.scss new file mode 100644 index 000000000..3b5071480 --- /dev/null +++ b/ui/app/pages/first-time-flow/welcome/index.scss @@ -0,0 +1,42 @@ +.welcome-page { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + max-width: 442px; + padding: 0 18px; + color: black; + + &__wrapper { + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; + height: 100%; + margin-top: 110px; + } + + &__header { + font-size: 28px; + margin-bottom: 22px; + margin-top: 50px; + } + + &__description { + text-align: center; + + div { + font-size: 16px; + } + + @media screen and (max-width: 575px) { + font-size: .9rem; + } + } + + .first-time-flow__button { + width: 184px; + font-weight: 500; + margin-top: 44px; + } +} diff --git a/ui/app/pages/first-time-flow/welcome/welcome.component.js b/ui/app/pages/first-time-flow/welcome/welcome.component.js new file mode 100644 index 000000000..3b8d6eb17 --- /dev/null +++ b/ui/app/pages/first-time-flow/welcome/welcome.component.js @@ -0,0 +1,69 @@ +import EventEmitter from 'events' +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Mascot from '../../../components/ui/mascot' +import Button from '../../../components/ui/button' +import { INITIALIZE_CREATE_PASSWORD_ROUTE, INITIALIZE_SELECT_ACTION_ROUTE } from '../../../helpers/constants/routes' + +export default class Welcome extends PureComponent { + static propTypes = { + history: PropTypes.object, + isInitialized: PropTypes.bool, + participateInMetaMetrics: PropTypes.bool, + welcomeScreenSeen: PropTypes.bool, + } + + static contextTypes = { + t: PropTypes.func, + } + + constructor (props) { + super(props) + + this.animationEventEmitter = new EventEmitter() + } + + componentDidMount () { + const { history, participateInMetaMetrics, welcomeScreenSeen } = this.props + + if (welcomeScreenSeen && participateInMetaMetrics !== null) { + history.push(INITIALIZE_CREATE_PASSWORD_ROUTE) + } else if (welcomeScreenSeen) { + history.push(INITIALIZE_SELECT_ACTION_ROUTE) + } + } + + handleContinue = () => { + this.props.history.push(INITIALIZE_SELECT_ACTION_ROUTE) + } + + render () { + const { t } = this.context + + return ( + <div className="welcome-page__wrapper"> + <div className="welcome-page"> + <Mascot + animationEventEmitter={this.animationEventEmitter} + width="125" + height="125" + /> + <div className="welcome-page__header"> + { t('welcome') } + </div> + <div className="welcome-page__description"> + <div>{ t('metamaskDescription') }</div> + <div>{ t('happyToSeeYou') }</div> + </div> + <Button + type="confirm" + className="first-time-flow__button" + onClick={this.handleContinue} + > + { t('getStarted') } + </Button> + </div> + </div> + ) + } +} diff --git a/ui/app/pages/first-time-flow/welcome/welcome.container.js b/ui/app/pages/first-time-flow/welcome/welcome.container.js new file mode 100644 index 000000000..ce4b2b471 --- /dev/null +++ b/ui/app/pages/first-time-flow/welcome/welcome.container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import { closeWelcomeScreen } from '../../../store/actions' +import Welcome from './welcome.component' + +const mapStateToProps = ({ metamask }) => { + const { welcomeScreenSeen, isInitialized, participateInMetaMetrics } = metamask + + return { + welcomeScreenSeen, + isInitialized, + participateInMetaMetrics, + } +} + +const mapDispatchToProps = dispatch => { + return { + closeWelcomeScreen: () => dispatch(closeWelcomeScreen()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(Welcome) diff --git a/ui/app/components/pages/home/home.component.js b/ui/app/pages/home/home.component.js index b9ec3c258..29d93a9fa 100644 --- a/ui/app/components/pages/home/home.component.js +++ b/ui/app/pages/home/home.component.js @@ -2,23 +2,20 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import Media from 'react-media' import { Redirect } from 'react-router-dom' -import WalletView from '../../wallet-view' -import TransactionView from '../../transaction-view' +import WalletView from '../../components/app/wallet-view' +import TransactionView from '../../components/app/transaction-view' import ProviderApproval from '../provider-approval' import { - INITIALIZE_BACKUP_PHRASE_ROUTE, + INITIALIZE_SEED_PHRASE_ROUTE, RESTORE_VAULT_ROUTE, CONFIRM_TRANSACTION_ROUTE, - NOTICE_ROUTE, CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, -} from '../../../routes' +} from '../../helpers/constants/routes' export default class Home extends PureComponent { static propTypes = { history: PropTypes.object, - noActiveNotices: PropTypes.bool, - lostAccounts: PropTypes.array, forgottenPassword: PropTypes.bool, seedWords: PropTypes.string, suggestedTokens: PropTypes.object, @@ -45,21 +42,14 @@ export default class Home extends PureComponent { render () { const { - noActiveNotices, - lostAccounts, forgottenPassword, seedWords, providerRequests, } = this.props - // notices - if (!noActiveNotices || (lostAccounts && lostAccounts.length > 0)) { - return <Redirect to={{ pathname: NOTICE_ROUTE }} /> - } - // seed words if (seedWords) { - return <Redirect to={{ pathname: INITIALIZE_BACKUP_PHRASE_ROUTE }}/> + return <Redirect to={{ pathname: INITIALIZE_SEED_PHRASE_ROUTE }}/> } if (forgottenPassword) { diff --git a/ui/app/components/pages/home/home.container.js b/ui/app/pages/home/home.container.js index bb8cf5e81..02ec4b9c6 100644 --- a/ui/app/components/pages/home/home.container.js +++ b/ui/app/pages/home/home.container.js @@ -2,7 +2,7 @@ import Home from './home.component' import { compose } from 'recompose' import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' -import { unconfirmedTransactionsCountSelector } from '../../../selectors/confirm-transaction' +import { unconfirmedTransactionsCountSelector } from '../../selectors/confirm-transaction' const mapStateToProps = state => { const { metamask, appState } = state diff --git a/ui/app/components/pages/home/index.js b/ui/app/pages/home/index.js index 4474ba5b8..4474ba5b8 100644 --- a/ui/app/components/pages/home/index.js +++ b/ui/app/pages/home/index.js diff --git a/ui/app/pages/index.js b/ui/app/pages/index.js new file mode 100644 index 000000000..56fc4af04 --- /dev/null +++ b/ui/app/pages/index.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react' +const PropTypes = require('prop-types') +const { Provider } = require('react-redux') +const { HashRouter } = require('react-router-dom') +const Routes = require('./routes') +const I18nProvider = require('../helpers/higher-order-components/i18n-provider') +const MetaMetricsProvider = require('../helpers/higher-order-components/metametrics/metametrics.provider') + +class Index extends Component { + render () { + const { store } = this.props + + return ( + <Provider store={store}> + <HashRouter hashType="noslash"> + <MetaMetricsProvider> + <I18nProvider> + <Routes /> + </I18nProvider> + </MetaMetricsProvider> + </HashRouter> + </Provider> + ) + } +} + +Index.propTypes = { + store: PropTypes.object, +} + +module.exports = Index diff --git a/ui/app/pages/index.scss b/ui/app/pages/index.scss new file mode 100644 index 000000000..cb9f0d80c --- /dev/null +++ b/ui/app/pages/index.scss @@ -0,0 +1,11 @@ +@import 'unlock-page/index'; + +@import 'add-token/index'; + +@import 'confirm-add-token/index'; + +@import 'settings/index'; + +@import 'first-time-flow/index'; + +@import 'keychains/index'; diff --git a/ui/app/pages/keychains/index.scss b/ui/app/pages/keychains/index.scss new file mode 100644 index 000000000..868185419 --- /dev/null +++ b/ui/app/pages/keychains/index.scss @@ -0,0 +1,197 @@ +.first-view-main-wrapper { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + padding: 0 10px; +} + +.first-view-main { + display: flex; + flex-direction: row; + justify-content: flex-start; +} + +@media screen and (min-width: 1281px) { + .first-view-main { + width: 62vw; + } +} + +.import-account { + display: flex; + flex-flow: column nowrap; + margin: 60px 0 30px 0; + position: relative; + max-width: initial; +} + +@media only screen and (max-width: 575px) { + .import-account{ + margin: 24px; + display: flex; + flex-flow: column nowrap; + width: calc(100vw - 80px); + } + + .import-account__title { + width: initial !important; + } + + .first-view-main { + height: 100%; + flex-direction: column; + align-items: center; + justify-content: flex-start; + margin-top: 12px; + } + + .first-view-phone-invisible { + display: none; + } + + .first-time-flow__input { + width: 100%; + } + + .import-account__secret-phrase { + width: initial !important; + height: initial !important; + min-height: 190px; + } +} + +.import-account__title { + color: #1B344D; + font-size: 40px; + line-height: 51px; + margin-bottom: 10px; +} + +.import-account__back-button { + margin-bottom: 18px; + color: #22232c; + font-size: 16px; + line-height: 21px; + position: absolute; + top: -25px; +} + +.import-account__secret-phrase { + height: 190px; + width: 495px; + border: 1px solid #CDCDCD; + border-radius: 6px; + background-color: #FFFFFF; + padding: 17px; + font-size: 16px; +} + +.import-account__secret-phrase::placeholder { + color: #9B9B9B; + font-weight: 200; +} + +.import-account__faq-link { + font-size: 18px; + line-height: 23px; + font-family: Roboto; +} + +.import-account__selector-label { + color: #1B344D; + font-size: 16px; +} + +.import-account__dropdown { + width: 325px; + border: 1px solid #CDCDCD; + border-radius: 4px; + background-color: #FFFFFF; + margin-top: 14px; + color: #5B5D67; + font-family: Roboto; + font-size: 18px; + line-height: 23px; + padding: 14px 21px; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + cursor: pointer; +} + +.import-account__description-text { + color: #757575; + font-size: 18px; + line-height: 23px; + margin-top: 21px; + font-family: Roboto; +} + +.import-account__input-wrapper { + display: flex; + flex-flow: column nowrap; + margin-top: 30px; +} + +.import-account__input-error-message { + margin-top: 10px; + width: 422px; + color: #FF001F; + font-size: 16px; + line-height: 21px; +} + +.import-account__input-label { + margin-bottom: 9px; + color: #1B344D; + font-size: 18px; + line-height: 23px; +} + +.import-account__input-label__disabled { + opacity: 0.5; +} + +.import-account__input { + width: 350px; +} + +@media only screen and (max-width: 575px) { + .import-account__input { + width: 100%; + } +} + +.import-account__file-input { + display: none; +} + +.import-account__file-input-label { + height: 53px; + width: 148px; + border: 1px solid #1B344D; + border-radius: 4px; + color: #1B344D; + font-family: Roboto; + font-size: 18px; + display: flex; + flex-flow: column nowrap; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.import-account__file-picker-wrapper { + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +.import-account__file-name { + color: #000000; + font-family: Roboto; + font-size: 18px; + line-height: 23px; + margin-left: 22px; +} diff --git a/ui/app/components/pages/keychains/restore-vault.js b/ui/app/pages/keychains/restore-vault.js index d90a33e49..574949258 100644 --- a/ui/app/components/pages/keychains/restore-vault.js +++ b/ui/app/pages/keychains/restore-vault.js @@ -4,13 +4,15 @@ import {connect} from 'react-redux' import { createNewVaultAndRestore, unMarkPasswordForgotten, -} from '../../../actions' -import { DEFAULT_ROUTE } from '../../../routes' -import TextField from '../../text-field' +} from '../../store/actions' +import { DEFAULT_ROUTE } from '../../helpers/constants/routes' +import TextField from '../../components/ui/text-field' +import Button from '../../components/ui/button' class RestoreVaultPage extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -83,7 +85,16 @@ class RestoreVaultPage extends Component { leaveImportSeedScreenState() createNewVaultAndRestore(password, this.parseSeedPhrase(seedPhrase)) - .then(() => history.push(DEFAULT_ROUTE)) + .then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Retention', + action: 'userEntersSeedPhrase', + name: 'onboardingRestoredVault', + }, + }) + history.push(DEFAULT_ROUTE) + }) } hasError () { @@ -160,13 +171,14 @@ class RestoreVaultPage extends Component { margin="normal" largeLabel /> - <button + <Button + type="first-time" className="first-time-flow__button" onClick={() => !disabled && this.onClick()} disabled={disabled} > {this.context.t('restore')} - </button> + </Button> </div> </div> </div> @@ -174,10 +186,6 @@ class RestoreVaultPage extends Component { } } -RestoreVaultPage.contextTypes = { - t: PropTypes.func, -} - export default connect( ({ appState: { warning, isLoading } }) => ({ warning, isLoading }), dispatch => ({ diff --git a/ui/app/components/pages/keychains/reveal-seed.js b/ui/app/pages/keychains/reveal-seed.js index 32557066f..edc9db5a0 100644 --- a/ui/app/components/pages/keychains/reveal-seed.js +++ b/ui/app/pages/keychains/reveal-seed.js @@ -4,11 +4,11 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const classnames = require('classnames') -const { requestRevealSeedWords } = require('../../../actions') -const { DEFAULT_ROUTE } = require('../../../routes') -const ExportTextContainer = require('../../export-text-container') +const { requestRevealSeedWords } = require('../../store/actions') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') +const ExportTextContainer = require('../../components/ui/export-text-container') -import Button from '../../button' +import Button from '../../components/ui/button' const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' diff --git a/ui/app/pages/lock/index.js b/ui/app/pages/lock/index.js new file mode 100644 index 000000000..7bfe2a61f --- /dev/null +++ b/ui/app/pages/lock/index.js @@ -0,0 +1 @@ +export { default } from './lock.container' diff --git a/ui/app/pages/lock/lock.component.js b/ui/app/pages/lock/lock.component.js new file mode 100644 index 000000000..1145158c5 --- /dev/null +++ b/ui/app/pages/lock/lock.component.js @@ -0,0 +1,26 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Loading from '../../components/ui/loading-screen' +import { DEFAULT_ROUTE } from '../../helpers/constants/routes' + +export default class Lock extends PureComponent { + static propTypes = { + history: PropTypes.object, + isUnlocked: PropTypes.bool, + lockMetamask: PropTypes.func, + } + + componentDidMount () { + const { lockMetamask, isUnlocked, history } = this.props + + if (isUnlocked) { + lockMetamask().then(() => history.push(DEFAULT_ROUTE)) + } else { + history.replace(DEFAULT_ROUTE) + } + } + + render () { + return <Loading /> + } +} diff --git a/ui/app/components/pages/unlock-page/unlock-page.container.js b/ui/app/pages/lock/lock.container.js index 18fed9b2e..6a20b6ed1 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.container.js +++ b/ui/app/pages/lock/lock.container.js @@ -1,17 +1,12 @@ +import Lock from './lock.component' +import { compose } from 'recompose' import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' - -const { - tryUnlockMetamask, - forgotPassword, - markPasswordForgotten, -} = require('../../../actions') - -import UnlockPage from './unlock-page.component' +import { lockMetamask } from '../../store/actions' const mapStateToProps = state => { const { metamask: { isUnlocked } } = state + return { isUnlocked, } @@ -19,13 +14,11 @@ const mapStateToProps = state => { const mapDispatchToProps = dispatch => { return { - forgotPassword: () => dispatch(forgotPassword()), - tryUnlockMetamask: password => dispatch(tryUnlockMetamask(password)), - markPasswordForgotten: () => dispatch(markPasswordForgotten()), + lockMetamask: () => dispatch(lockMetamask()), } } export default compose( withRouter, connect(mapStateToProps, mapDispatchToProps) -)(UnlockPage) +)(Lock) diff --git a/ui/app/pages/mobile-sync/index.js b/ui/app/pages/mobile-sync/index.js new file mode 100644 index 000000000..0938ad103 --- /dev/null +++ b/ui/app/pages/mobile-sync/index.js @@ -0,0 +1,387 @@ +const { Component } = require('react') +const { connect } = require('react-redux') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const classnames = require('classnames') +const PubNub = require('pubnub') + +const { requestRevealSeedWords, fetchInfoToSync } = require('../../store/actions') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') +const actions = require('../../store/actions') + +const qrCode = require('qrcode-generator') + +import Button from '../../components/ui/button' +import LoadingScreen from '../../components/ui/loading-screen' + +const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' +const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' + +class MobileSyncPage extends Component { + static propTypes = { + history: PropTypes.object, + selectedAddress: PropTypes.string, + displayWarning: PropTypes.func, + fetchInfoToSync: PropTypes.func, + requestRevealSeedWords: PropTypes.func, + } + + constructor (props) { + super(props) + + this.state = { + screen: PASSWORD_PROMPT_SCREEN, + password: '', + seedWords: null, + error: null, + syncing: false, + completed: false, + } + + this.syncing = false + } + + componentDidMount () { + const passwordBox = document.getElementById('password-box') + if (passwordBox) { + passwordBox.focus() + } + } + + handleSubmit (event) { + event.preventDefault() + this.setState({ seedWords: null, error: null }) + this.props.requestRevealSeedWords(this.state.password) + .then(seedWords => { + this.generateCipherKeyAndChannelName() + this.setState({ seedWords, screen: REVEAL_SEED_SCREEN }) + this.initWebsockets() + }) + .catch(error => this.setState({ error: error.message })) + } + + generateCipherKeyAndChannelName () { + this.cipherKey = `${this.props.selectedAddress.substr(-4)}-${PubNub.generateUUID()}` + this.channelName = `mm-${PubNub.generateUUID()}` + } + + initWebsockets () { + this.pubnub = new PubNub({ + subscribeKey: process.env.PUBNUB_SUB_KEY, + publishKey: process.env.PUBNUB_PUB_KEY, + cipherKey: this.cipherKey, + ssl: true, + }) + + this.pubnubListener = this.pubnub.addListener({ + message: (data) => { + const {channel, message} = data + // handle message + if (channel !== this.channelName || !message) { + return false + } + + if (message.event === 'start-sync') { + this.startSyncing() + } else if (message.event === 'end-sync') { + this.disconnectWebsockets() + this.setState({syncing: false, completed: true}) + } + }, + }) + + this.pubnub.subscribe({ + channels: [this.channelName], + withPresence: false, + }) + + } + + disconnectWebsockets () { + if (this.pubnub && this.pubnubListener) { + this.pubnub.disconnect(this.pubnubListener) + } + } + + // Calculating a PubNub Message Payload Size. + calculatePayloadSize (channel, message) { + return encodeURIComponent( + channel + JSON.stringify(message) + ).length + 100 + } + + chunkString (str, size) { + const numChunks = Math.ceil(str.length / size) + const chunks = new Array(numChunks) + for (let i = 0, o = 0; i < numChunks; ++i, o += size) { + chunks[i] = str.substr(o, size) + } + return chunks + } + + notifyError (errorMsg) { + return new Promise((resolve, reject) => { + this.pubnub.publish( + { + message: { + event: 'error-sync', + data: errorMsg, + }, + channel: this.channelName, + sendByPost: false, // true to send via post + storeInHistory: false, + }, + (status, response) => { + if (!status.error) { + resolve() + } else { + reject(response) + } + }) + }) + } + + async startSyncing () { + if (this.syncing) return false + this.syncing = true + this.setState({syncing: true}) + + const { accounts, network, preferences, transactions } = await this.props.fetchInfoToSync() + + const allDataStr = JSON.stringify({ + accounts, + network, + preferences, + transactions, + udata: { + pwd: this.state.password, + seed: this.state.seedWords, + }, + }) + + const chunks = this.chunkString(allDataStr, 17000) + const totalChunks = chunks.length + try { + for (let i = 0; i < totalChunks; i++) { + await this.sendMessage(chunks[i], i + 1, totalChunks) + } + } catch (e) { + this.props.displayWarning('Sync failed :(') + this.setState({syncing: false}) + this.syncing = false + this.notifyError(e.toString()) + } + } + + sendMessage (data, pkg, count) { + return new Promise((resolve, reject) => { + this.pubnub.publish( + { + message: { + event: 'syncing-data', + data, + totalPkg: count, + currentPkg: pkg, + }, + channel: this.channelName, + sendByPost: false, // true to send via post + storeInHistory: false, + }, + (status, response) => { + if (!status.error) { + resolve() + } else { + reject(response) + } + } + ) + }) + } + + + componentWillUnmount () { + this.disconnectWebsockets() + } + + renderWarning (text) { + return ( + h('.page-container__warning-container', [ + h('.page-container__warning-message', [ + h('div', [text]), + ]), + ]) + ) + } + + renderContent () { + const { t } = this.context + + if (this.state.syncing) { + return h(LoadingScreen, {loadingMessage: 'Sync in progress'}) + } + + if (this.state.completed) { + return h('div.reveal-seed__content', {}, + h('label.reveal-seed__label', { + style: { + width: '100%', + textAlign: 'center', + }, + }, t('syncWithMobileComplete')), + ) + } + + return this.state.screen === PASSWORD_PROMPT_SCREEN + ? h('div', {}, [ + this.renderWarning(this.context.t('mobileSyncText')), + h('.reveal-seed__content', [ + this.renderPasswordPromptContent(), + ]), + ]) + : h('div', {}, [ + this.renderWarning(this.context.t('syncWithMobileBeCareful')), + h('.reveal-seed__content', [ this.renderRevealSeedContent() ]), + ]) + } + + renderPasswordPromptContent () { + const { t } = this.context + + return ( + h('form', { + onSubmit: event => this.handleSubmit(event), + }, [ + h('label.input-label', { + htmlFor: 'password-box', + }, t('enterPasswordContinue')), + h('.input-group', [ + h('input.form-control', { + type: 'password', + placeholder: t('password'), + id: 'password-box', + value: this.state.password, + onChange: event => this.setState({ password: event.target.value }), + className: classnames({ 'form-control--error': this.state.error }), + }), + ]), + this.state.error && h('.reveal-seed__error', this.state.error), + ]) + ) + } + + renderRevealSeedContent () { + + const qrImage = qrCode(0, 'M') + qrImage.addData(`metamask-sync:${this.channelName}|@|${this.cipherKey}`) + qrImage.make() + + const { t } = this.context + return ( + h('div', [ + h('label.reveal-seed__label', { + style: { + width: '100%', + textAlign: 'center', + }, + }, t('syncWithMobileScanThisCode')), + h('.div.qr-wrapper', { + style: { + display: 'flex', + justifyContent: 'center', + }, + dangerouslySetInnerHTML: { + __html: qrImage.createTableTag(4), + }, + }), + ]) + ) + } + + renderFooter () { + return this.state.screen === PASSWORD_PROMPT_SCREEN + ? this.renderPasswordPromptFooter() + : this.renderRevealSeedFooter() + } + + renderPasswordPromptFooter () { + return ( + h('div.new-account-import-form__buttons', {style: {padding: 30}}, [ + + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', + onClick: () => this.props.history.push(DEFAULT_ROUTE), + }, this.context.t('cancel')), + + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', + onClick: event => this.handleSubmit(event), + disabled: this.state.password === '', + }, this.context.t('next')), + ]) + ) + } + + renderRevealSeedFooter () { + return ( + h('.page-container__footer', {style: {padding: 30}}, [ + h(Button, { + type: 'default', + large: true, + className: 'page-container__footer-button', + onClick: () => this.props.history.push(DEFAULT_ROUTE), + }, this.context.t('close')), + ]) + ) + } + + render () { + return ( + h('.page-container', [ + h('.page-container__header', [ + h('.page-container__title', this.context.t('syncWithMobileTitle')), + this.state.screen === PASSWORD_PROMPT_SCREEN ? h('.page-container__subtitle', this.context.t('syncWithMobileDesc')) : null, + this.state.screen === PASSWORD_PROMPT_SCREEN ? h('.page-container__subtitle', this.context.t('syncWithMobileDescNewUsers')) : null, + ]), + h('.page-container__content', [ + this.renderContent(), + ]), + this.renderFooter(), + ]) + ) + } +} + +MobileSyncPage.propTypes = { + requestRevealSeedWords: PropTypes.func, + fetchInfoToSync: PropTypes.func, + history: PropTypes.object, +} + +MobileSyncPage.contextTypes = { + t: PropTypes.func, +} + +const mapDispatchToProps = dispatch => { + return { + requestRevealSeedWords: password => dispatch(requestRevealSeedWords(password)), + fetchInfoToSync: () => dispatch(fetchInfoToSync()), + displayWarning: (message) => dispatch(actions.displayWarning(message || null)), + } + +} + +const mapStateToProps = state => { + const { + metamask: { selectedAddress }, + } = state + + return { + selectedAddress, + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(MobileSyncPage) diff --git a/ui/app/components/pages/notice.js b/ui/app/pages/notice/notice.js index a9077b98b..d8274dfcb 100644 --- a/ui/app/components/pages/notice.js +++ b/ui/app/pages/notice/notice.js @@ -6,8 +6,8 @@ const ReactMarkdown = require('react-markdown') const linker = require('extension-link-enabler') const generateLostAccountsNotice = require('../../../lib/lost-accounts-notice') const findDOMNode = require('react-dom').findDOMNode -const actions = require('../../actions') -const { DEFAULT_ROUTE } = require('../../routes') +const actions = require('../../store/actions') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') class Notice extends Component { constructor (props) { diff --git a/ui/app/components/pages/provider-approval/index.js b/ui/app/pages/provider-approval/index.js index 4162f3155..4162f3155 100644 --- a/ui/app/components/pages/provider-approval/index.js +++ b/ui/app/pages/provider-approval/index.js diff --git a/ui/app/components/pages/provider-approval/provider-approval.component.js b/ui/app/pages/provider-approval/provider-approval.component.js index da98bc3fc..1f1d68da7 100644 --- a/ui/app/components/pages/provider-approval/provider-approval.component.js +++ b/ui/app/pages/provider-approval/provider-approval.component.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' -import ProviderPageContainer from '../../provider-page-container' +import ProviderPageContainer from '../../components/app/provider-page-container' export default class ProviderApproval extends Component { static propTypes = { @@ -19,6 +19,7 @@ export default class ProviderApproval extends Component { <ProviderPageContainer approveProviderRequest={approveProviderRequest} origin={providerRequest.origin} + tabID={providerRequest.tabID} rejectProviderRequest={rejectProviderRequest} siteImage={providerRequest.siteImage} siteTitle={providerRequest.siteTitle} diff --git a/ui/app/components/pages/provider-approval/provider-approval.container.js b/ui/app/pages/provider-approval/provider-approval.container.js index b223244a1..d53c0ae4d 100644 --- a/ui/app/components/pages/provider-approval/provider-approval.container.js +++ b/ui/app/pages/provider-approval/provider-approval.container.js @@ -1,11 +1,11 @@ import { connect } from 'react-redux' import ProviderApproval from './provider-approval.component' -import { approveProviderRequest, rejectProviderRequest } from '../../../actions' +import { approveProviderRequest, rejectProviderRequest } from '../../store/actions' function mapDispatchToProps (dispatch) { return { - approveProviderRequest: origin => dispatch(approveProviderRequest(origin)), - rejectProviderRequest: origin => dispatch(rejectProviderRequest(origin)), + approveProviderRequest: tabID => dispatch(approveProviderRequest(tabID)), + rejectProviderRequest: tabID => dispatch(rejectProviderRequest(tabID)), } } diff --git a/ui/app/pages/routes/index.js b/ui/app/pages/routes/index.js new file mode 100644 index 000000000..460cec958 --- /dev/null +++ b/ui/app/pages/routes/index.js @@ -0,0 +1,441 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Route, Switch, withRouter, matchPath } from 'react-router-dom' +import { compose } from 'recompose' +import actions from '../../store/actions' +import log from 'loglevel' +import { getMetaMaskAccounts, getNetworkIdentifier } from '../../selectors/selectors' + +// init +import FirstTimeFlow from '../first-time-flow' +// accounts +const SendTransactionScreen = require('../../components/app/send/send.container') +const ConfirmTransaction = require('../confirm-transaction') + +// slideout menu +const Sidebar = require('../../components/app/sidebars').default +const { WALLET_VIEW_SIDEBAR } = require('../../components/app/sidebars/sidebar.constants') + +// other views +import Home from '../home' +import Settings from '../settings' +import Authenticated from '../../helpers/higher-order-components/authenticated' +import Initialized from '../../helpers/higher-order-components/initialized' +import Lock from '../lock' +import UiMigrationAnnouncement from '../../components/app/ui-migration-annoucement' +const RestoreVaultPage = require('../keychains/restore-vault').default +const RevealSeedConfirmation = require('../keychains/reveal-seed') +const MobileSyncPage = require('../mobile-sync') +const AddTokenPage = require('../add-token') +const ConfirmAddTokenPage = require('../confirm-add-token') +const ConfirmAddSuggestedTokenPage = require('../confirm-add-suggested-token') +const CreateAccountPage = require('../create-account') +const NoticeScreen = require('../notice/notice') + +const Loading = require('../../components/ui/loading-screen') +const LoadingNetwork = require('../../components/app/loading-network-screen').default +const NetworkDropdown = require('../../components/app/dropdowns/network-dropdown') +import AccountMenu from '../../components/app/account-menu' + +// Global Modals +const Modal = require('../../components/app/modals').Modal +// Global Alert +const Alert = require('../../components/ui/alert') + +import AppHeader from '../../components/app/app-header' +import UnlockPage from '../unlock-page' + +import { + submittedPendingTransactionsSelector, +} from '../../selectors/transactions' + +// Routes +import { + DEFAULT_ROUTE, + LOCK_ROUTE, + UNLOCK_ROUTE, + SETTINGS_ROUTE, + REVEAL_SEED_ROUTE, + MOBILE_SYNC_ROUTE, + RESTORE_VAULT_ROUTE, + ADD_TOKEN_ROUTE, + CONFIRM_ADD_TOKEN_ROUTE, + CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, + NEW_ACCOUNT_ROUTE, + SEND_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + INITIALIZE_ROUTE, + INITIALIZE_UNLOCK_ROUTE, + NOTICE_ROUTE, +} from '../../helpers/constants/routes' + +// enums +import { + ENVIRONMENT_TYPE_NOTIFICATION, + ENVIRONMENT_TYPE_POPUP, +} from '../../../../app/scripts/lib/enums' + +class Routes extends Component { + componentWillMount () { + const { currentCurrency, setCurrentCurrencyToUSD } = this.props + + if (!currentCurrency) { + setCurrentCurrencyToUSD() + } + + this.props.history.listen((locationObj, action) => { + if (action === 'PUSH') { + const url = `&url=${encodeURIComponent('http://www.metamask.io/metametrics' + locationObj.pathname)}` + this.context.metricsEvent({}, { + currentPath: '', + pathname: locationObj.pathname, + url, + pageOpts: { + hideDimensions: true, + }, + }) + } + }) + } + + renderRoutes () { + return ( + <Switch> + <Route path={LOCK_ROUTE} component={Lock} exact /> + <Route path={INITIALIZE_ROUTE} component={FirstTimeFlow} /> + <Initialized path={UNLOCK_ROUTE} component={UnlockPage} exact /> + <Initialized path={RESTORE_VAULT_ROUTE} component={RestoreVaultPage} exact /> + <Authenticated path={REVEAL_SEED_ROUTE} component={RevealSeedConfirmation} exact /> + <Authenticated path={MOBILE_SYNC_ROUTE} component={MobileSyncPage} exact /> + <Authenticated path={SETTINGS_ROUTE} component={Settings} /> + <Authenticated path={NOTICE_ROUTE} component={NoticeScreen} exact /> + <Authenticated path={`${CONFIRM_TRANSACTION_ROUTE}/:id?`} component={ConfirmTransaction} /> + <Authenticated path={SEND_ROUTE} component={SendTransactionScreen} exact /> + <Authenticated path={ADD_TOKEN_ROUTE} component={AddTokenPage} exact /> + <Authenticated path={CONFIRM_ADD_TOKEN_ROUTE} component={ConfirmAddTokenPage} exact /> + <Authenticated path={CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE} component={ConfirmAddSuggestedTokenPage} exact /> + <Authenticated path={NEW_ACCOUNT_ROUTE} component={CreateAccountPage} /> + <Authenticated path={DEFAULT_ROUTE} component={Home} exact /> + </Switch> + ) + } + + onInitializationUnlockPage () { + const { location } = this.props + return Boolean(matchPath(location.pathname, { path: INITIALIZE_UNLOCK_ROUTE, exact: true })) + } + + onConfirmPage () { + const { location } = this.props + return Boolean(matchPath(location.pathname, { path: CONFIRM_TRANSACTION_ROUTE, exact: false })) + } + + hasProviderRequests () { + const { providerRequests } = this.props + return Array.isArray(providerRequests) && providerRequests.length > 0 + } + + hideAppHeader () { + const { location } = this.props + + const isInitializing = Boolean(matchPath(location.pathname, { + path: INITIALIZE_ROUTE, exact: false, + })) + + if (isInitializing && !this.onInitializationUnlockPage()) { + return true + } + + if (window.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { + return true + } + + if (window.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_POPUP) { + return this.onConfirmPage() || this.hasProviderRequests() + } + } + + render () { + const { + isLoading, + alertMessage, + loadingMessage, + network, + provider, + frequentRpcListDetail, + currentView, + setMouseUserState, + sidebar, + submittedPendingTransactions, + } = this.props + const isLoadingNetwork = network === 'loading' && currentView.name !== 'config' + const loadMessage = loadingMessage || isLoadingNetwork ? + this.getConnectingLabel(loadingMessage) : null + log.debug('Main ui render function') + + const sidebarOnOverlayClose = sidebarType === WALLET_VIEW_SIDEBAR + ? () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Wallet Sidebar', + name: 'Closed Sidebare Via Overlay', + }, + }) + } + : null + + const { + isOpen: sidebarIsOpen, + transitionName: sidebarTransitionName, + type: sidebarType, + props, + } = sidebar + const { transaction: sidebarTransaction } = props || {} + + return ( + <div + className="app" + onClick={() => setMouseUserState(true)} + onKeyDown={e => { + if (e.keyCode === 9) { + setMouseUserState(false) + } + }} + > + <UiMigrationAnnouncement /> + <Modal /> + <Alert + visible={this.props.alertOpen} + msg={alertMessage} + /> + { + !this.hideAppHeader() && ( + <AppHeader + hideNetworkIndicator={this.onInitializationUnlockPage()} + disabled={this.onConfirmPage()} + /> + ) + } + <Sidebar + sidebarOpen={sidebarIsOpen} + sidebarShouldClose={sidebarTransaction && !submittedPendingTransactions.find(({ id }) => id === sidebarTransaction.id)} + hideSidebar={this.props.hideSidebar} + transitionName={sidebarTransitionName} + type={sidebarType} + sidebarProps={sidebar.props} + onOverlayClose={sidebarOnOverlayClose} + /> + <NetworkDropdown + provider={provider} + frequentRpcListDetail={frequentRpcListDetail} + /> + <AccountMenu /> + <div className="main-container-wrapper"> + { isLoading && <Loading loadingMessage={loadMessage} /> } + { !isLoading && isLoadingNetwork && <LoadingNetwork /> } + { this.renderRoutes() } + </div> + </div> + ) + } + + toggleMetamaskActive () { + if (!this.props.isUnlocked) { + // currently inactive: redirect to password box + var passwordBox = document.querySelector('input[type=password]') + if (!passwordBox) return + passwordBox.focus() + } else { + // currently active: deactivate + this.props.dispatch(actions.lockMetamask(false)) + } + } + + getConnectingLabel = function (loadingMessage) { + if (loadingMessage) { + return loadingMessage + } + const { provider, providerId } = this.props + const providerName = provider.type + + let name + + if (providerName === 'mainnet') { + name = this.context.t('connectingToMainnet') + } else if (providerName === 'ropsten') { + name = this.context.t('connectingToRopsten') + } else if (providerName === 'kovan') { + name = this.context.t('connectingToKovan') + } else if (providerName === 'rinkeby') { + name = this.context.t('connectingToRinkeby') + } else { + name = this.context.t('connectingTo', [providerId]) + } + + return name + } + + getNetworkName () { + const { provider } = this.props + const providerName = provider.type + + let name + + if (providerName === 'mainnet') { + name = this.context.t('mainnet') + } else if (providerName === 'ropsten') { + name = this.context.t('ropsten') + } else if (providerName === 'kovan') { + name = this.context.t('kovan') + } else if (providerName === 'rinkeby') { + name = this.context.t('rinkeby') + } else { + name = this.context.t('unknownNetwork') + } + + return name + } +} + +Routes.propTypes = { + currentCurrency: PropTypes.string, + setCurrentCurrencyToUSD: PropTypes.func, + isLoading: PropTypes.bool, + loadingMessage: PropTypes.string, + alertMessage: PropTypes.string, + network: PropTypes.string, + provider: PropTypes.object, + frequentRpcListDetail: PropTypes.array, + currentView: PropTypes.object, + sidebar: PropTypes.object, + alertOpen: PropTypes.bool, + hideSidebar: PropTypes.func, + isOnboarding: PropTypes.bool, + isUnlocked: PropTypes.bool, + networkDropdownOpen: PropTypes.bool, + showNetworkDropdown: PropTypes.func, + hideNetworkDropdown: PropTypes.func, + history: PropTypes.object, + location: PropTypes.object, + dispatch: PropTypes.func, + toggleAccountMenu: PropTypes.func, + selectedAddress: PropTypes.string, + noActiveNotices: PropTypes.bool, + lostAccounts: PropTypes.array, + isInitialized: PropTypes.bool, + forgottenPassword: PropTypes.bool, + activeAddress: PropTypes.string, + unapprovedTxs: PropTypes.object, + seedWords: PropTypes.string, + submittedPendingTransactions: PropTypes.array, + unapprovedMsgCount: PropTypes.number, + unapprovedPersonalMsgCount: PropTypes.number, + unapprovedTypedMessagesCount: PropTypes.number, + welcomeScreenSeen: PropTypes.bool, + isPopup: PropTypes.bool, + isMouseUser: PropTypes.bool, + setMouseUserState: PropTypes.func, + t: PropTypes.func, + providerId: PropTypes.string, + providerRequests: PropTypes.array, +} + +function mapStateToProps (state) { + const { appState, metamask } = state + const { + networkDropdownOpen, + sidebar, + alertOpen, + alertMessage, + isLoading, + loadingMessage, + } = appState + + const accounts = getMetaMaskAccounts(state) + + const { + identities, + address, + keyrings, + isInitialized, + noActiveNotices, + seedWords, + unapprovedTxs, + nextUnreadNotice, + lostAccounts, + unapprovedMsgCount, + unapprovedPersonalMsgCount, + unapprovedTypedMessagesCount, + providerRequests, + } = metamask + const selected = address || Object.keys(accounts)[0] + + return { + // state from plugin + networkDropdownOpen, + sidebar, + alertOpen, + alertMessage, + isLoading, + loadingMessage, + noActiveNotices, + isInitialized, + isUnlocked: state.metamask.isUnlocked, + selectedAddress: state.metamask.selectedAddress, + currentView: state.appState.currentView, + activeAddress: state.appState.activeAddress, + transForward: state.appState.transForward, + isOnboarding: Boolean(!noActiveNotices || seedWords || !isInitialized), + isPopup: state.metamask.isPopup, + seedWords: state.metamask.seedWords, + submittedPendingTransactions: submittedPendingTransactionsSelector(state), + unapprovedTxs, + unapprovedMsgs: state.metamask.unapprovedMsgs, + unapprovedMsgCount, + unapprovedPersonalMsgCount, + unapprovedTypedMessagesCount, + menuOpen: state.appState.menuOpen, + network: state.metamask.network, + provider: state.metamask.provider, + forgottenPassword: state.appState.forgottenPassword, + nextUnreadNotice, + lostAccounts, + frequentRpcListDetail: state.metamask.frequentRpcListDetail || [], + currentCurrency: state.metamask.currentCurrency, + isMouseUser: state.appState.isMouseUser, + isRevealingSeedWords: state.metamask.isRevealingSeedWords, + Qr: state.appState.Qr, + welcomeScreenSeen: state.metamask.welcomeScreenSeen, + providerId: getNetworkIdentifier(state), + + // state needed to get account dropdown temporarily rendering from app bar + identities, + selected, + keyrings, + providerRequests, + } +} + +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()), + setMouseUserState: (isMouseUser) => dispatch(actions.setMouseUserState(isMouseUser)), + } +} + +Routes.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(Routes) diff --git a/ui/app/components/pages/settings/settings-tab/settings-tab.component.js b/ui/app/pages/settings/advanced-tab/advanced-tab.component.js index a0a8ed47e..d1cad1746 100644 --- a/ui/app/components/pages/settings/settings-tab/settings-tab.component.js +++ b/ui/app/pages/settings/advanced-tab/advanced-tab.component.js @@ -1,66 +1,30 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import infuraCurrencies from '../../../../infura-conversion.json' import validUrl from 'valid-url' -import { exportAsFile } from '../../../../util' -import SimpleDropdown from '../../../dropdowns/simple-dropdown' +import { exportAsFile } from '../../../helpers/utils/util' import ToggleButton from 'react-toggle-button' -import { REVEAL_SEED_ROUTE } from '../../../../routes' -import locales from '../../../../../../app/_locales/index.json' -import TextField from '../../../text-field' -import Button from '../../../button' +import TextField from '../../../components/ui/text-field' +import Button from '../../../components/ui/button' +import { MOBILE_SYNC_ROUTE } from '../../../helpers/constants/routes' -const sortedCurrencies = infuraCurrencies.objects.sort((a, b) => { - return a.quote.name.toLocaleLowerCase().localeCompare(b.quote.name.toLocaleLowerCase()) -}) - -const infuraCurrencyOptions = sortedCurrencies.map(({ quote: { code, name } }) => { - return { - displayValue: `${code.toUpperCase()} - ${name}`, - key: code, - value: code, - } -}) - -const localeOptions = locales.map(locale => { - return { - displayValue: `${locale.name}`, - key: locale.code, - value: locale.code, - } -}) - -export default class SettingsTab extends PureComponent { +export default class AdvancedTab extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { - metamask: PropTypes.object, - setUseBlockie: PropTypes.func, setHexDataFeatureFlag: PropTypes.func, - setPrivacyMode: PropTypes.func, - privacyMode: PropTypes.bool, - setCurrentCurrency: PropTypes.func, setRpcTarget: PropTypes.func, - delRpcTarget: PropTypes.func, displayWarning: PropTypes.func, - revealSeedConfirmation: PropTypes.func, - setFeatureFlagToBeta: PropTypes.func, - showClearApprovalModal: PropTypes.func, showResetAccountConfirmationModal: PropTypes.func, warning: PropTypes.string, history: PropTypes.object, - isMascara: PropTypes.bool, - updateCurrentLocale: PropTypes.func, - currentLocale: PropTypes.string, - useBlockie: PropTypes.bool, sendHexData: PropTypes.bool, - currentCurrency: PropTypes.string, - conversionDate: PropTypes.number, - nativeCurrency: PropTypes.string, - useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, - setUseNativeCurrencyAsPrimaryCurrencyPreference: PropTypes.func, + setAdvancedInlineGasFeatureFlag: PropTypes.func, + advancedInlineGas: PropTypes.bool, + showFiatInTestnets: PropTypes.bool, + setShowFiatConversionOnTestnetsPreference: PropTypes.func.isRequired, } state = { @@ -71,62 +35,6 @@ export default class SettingsTab extends PureComponent { nickname: '', } - renderCurrentConversion () { - const { t } = this.context - const { currentCurrency, conversionDate, setCurrentCurrency } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('currentConversion') }</span> - <span className="settings-page__content-description"> - { t('updatedWithDate', [Date(conversionDate)]) } - </span> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <SimpleDropdown - placeholder={t('selectCurrency')} - options={infuraCurrencyOptions} - selectedOption={currentCurrency} - onSelect={newCurrency => setCurrentCurrency(newCurrency)} - /> - </div> - </div> - </div> - ) - } - - renderCurrentLocale () { - const { t } = this.context - const { updateCurrentLocale, currentLocale } = this.props - const currentLocaleMeta = locales.find(locale => locale.code === currentLocale) - const currentLocaleName = currentLocaleMeta ? currentLocaleMeta.name : '' - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span className="settings-page__content-label"> - { t('currentLanguage') } - </span> - <span className="settings-page__content-description"> - { currentLocaleName } - </span> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <SimpleDropdown - placeholder={t('selectLocale')} - options={localeOptions} - selectedOption={currentLocale} - onSelect={async newLocale => updateCurrentLocale(newLocale)} - /> - </div> - </div> - </div> - ) - } - renderNewRpcUrl () { const { t } = this.context const { newRpc, chainId, ticker, nickname } = this.state @@ -205,10 +113,10 @@ export default class SettingsTab extends PureComponent { /> <div className="flex-row flex-align-center space-between"> <span className="settings-tab__advanced-link" - onClick={e => { - e.preventDefault() - this.setState({ showOptions: !this.state.showOptions }) - }} + onClick={e => { + e.preventDefault() + this.setState({ showOptions: !this.state.showOptions }) + }} > { t(this.state.showOptions ? 'hideAdvancedOptions' : 'showAdvancedOptions') } </span> @@ -230,10 +138,35 @@ export default class SettingsTab extends PureComponent { validateRpc (newRpc, chainId, ticker = 'ETH', nickname) { const { setRpcTarget, displayWarning } = this.props - if (validUrl.isWebUri(newRpc)) { + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Custom RPC', + name: 'Success', + }, + customVariables: { + networkId: newRpc, + chainId, + }, + }) + if (!!chainId && Number.isNaN(parseInt(chainId))) { + return displayWarning(`${this.context.t('invalidInput')} chainId`) + } + setRpcTarget(newRpc, chainId, ticker, nickname) } else { + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Custom RPC', + name: 'Error', + }, + customVariables: { + networkId: newRpc, + chainId, + }, + }) const appendedRpc = `http://${newRpc}` if (validUrl.isWebUri(appendedRpc)) { @@ -244,91 +177,26 @@ export default class SettingsTab extends PureComponent { } } - renderStateLogs () { - const { t } = this.context - const { displayWarning } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('stateLogs') }</span> - <span className="settings-page__content-description"> - { t('stateLogsDescription') } - </span> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <Button - type="primary" - large - onClick={() => { - window.logStateString((err, result) => { - if (err) { - displayWarning(t('stateLogError')) - } else { - exportAsFile('MetaMask State Logs.json', result) - } - }) - }} - > - { t('downloadStateLogs') } - </Button> - </div> - </div> - </div> - ) - } - - renderClearApproval () { - const { t } = this.context - const { showClearApprovalModal } = this.props - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('approvalData') }</span> - <span className="settings-page__content-description"> - { t('approvalDataDescription') } - </span> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <Button - type="secondary" - large - className="settings-tab__button--orange" - onClick={event => { - event.preventDefault() - showClearApprovalModal() - }} - > - { t('clearApprovalData') } - </Button> - </div> - </div> - </div> - ) - } - - renderSeedWords () { + renderMobileSync () { const { t } = this.context const { history } = this.props - +// return ( <div className="settings-page__content-row"> <div className="settings-page__content-item"> - <span>{ t('revealSeedWords') }</span> + <span>{ t('syncWithMobile') }</span> </div> <div className="settings-page__content-item"> <div className="settings-page__content-item-col"> <Button - type="secondary" + type="primary" large onClick={event => { event.preventDefault() - history.push(REVEAL_SEED_ROUTE) + history.push(MOBILE_SYNC_ROUTE) }} > - { t('revealSeedWords') } + { t('syncWithMobile') } </Button> </div> </div> @@ -336,27 +204,34 @@ export default class SettingsTab extends PureComponent { ) } - renderOldUI () { + renderStateLogs () { const { t } = this.context - const { setFeatureFlagToBeta } = this.props + const { displayWarning } = this.props return ( <div className="settings-page__content-row"> <div className="settings-page__content-item"> - <span>{ t('useOldUI') }</span> + <span>{ t('stateLogs') }</span> + <span className="settings-page__content-description"> + { t('stateLogsDescription') } + </span> </div> <div className="settings-page__content-item"> <div className="settings-page__content-item-col"> <Button - type="secondary" + type="primary" large - className="settings-tab__button--orange" - onClick={event => { - event.preventDefault() - setFeatureFlagToBeta() + onClick={() => { + window.logStateString((err, result) => { + if (err) { + displayWarning(t('stateLogError')) + } else { + exportAsFile('MetaMask State Logs.json', result) + } + }) }} > - { t('useOldUI') } + { t('downloadStateLogs') } </Button> </div> </div> @@ -381,6 +256,13 @@ export default class SettingsTab extends PureComponent { className="settings-tab__button--orange" onClick={event => { event.preventDefault() + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Reset Account', + name: 'Reset Account', + }, + }) showResetAccountConfirmationModal() }} > @@ -392,28 +274,6 @@ export default class SettingsTab extends PureComponent { ) } - renderBlockieOptIn () { - const { useBlockie, setUseBlockie } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ this.context.t('blockiesIdenticon') }</span> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <ToggleButton - value={useBlockie} - onToggle={value => setUseBlockie(!value)} - activeLabel="" - inactiveLabel="" - /> - </div> - </div> - </div> - ) - } - renderHexDataOptIn () { const { t } = this.context const { sendHexData, setHexDataFeatureFlag } = this.props @@ -440,77 +300,52 @@ export default class SettingsTab extends PureComponent { ) } - renderUsePrimaryCurrencyOptions () { + renderAdvancedGasInputInline () { const { t } = this.context - const { - nativeCurrency, - setUseNativeCurrencyAsPrimaryCurrencyPreference, - useNativeCurrencyAsPrimaryCurrency, - } = this.props + const { advancedInlineGas, setAdvancedInlineGasFeatureFlag } = this.props return ( <div className="settings-page__content-row"> <div className="settings-page__content-item"> - <span>{ t('primaryCurrencySetting') }</span> + <span>{ t('showAdvancedGasInline') }</span> <div className="settings-page__content-description"> - { t('primaryCurrencySettingDescription') } + { t('showAdvancedGasInlineDescription') } </div> </div> <div className="settings-page__content-item"> <div className="settings-page__content-item-col"> - <div className="settings-tab__radio-buttons"> - <div className="settings-tab__radio-button"> - <input - type="radio" - id="native-primary-currency" - onChange={() => setUseNativeCurrencyAsPrimaryCurrencyPreference(true)} - checked={Boolean(useNativeCurrencyAsPrimaryCurrency)} - /> - <label - htmlFor="native-primary-currency" - className="settings-tab__radio-label" - > - { nativeCurrency } - </label> - </div> - <div className="settings-tab__radio-button"> - <input - type="radio" - id="fiat-primary-currency" - onChange={() => setUseNativeCurrencyAsPrimaryCurrencyPreference(false)} - checked={!useNativeCurrencyAsPrimaryCurrency} - /> - <label - htmlFor="fiat-primary-currency" - className="settings-tab__radio-label" - > - { t('fiat') } - </label> - </div> - </div> + <ToggleButton + value={advancedInlineGas} + onToggle={value => setAdvancedInlineGasFeatureFlag(!value)} + activeLabel="" + inactiveLabel="" + /> </div> </div> </div> ) } - renderPrivacyOptIn () { + renderShowConversionInTestnets () { const { t } = this.context - const { privacyMode, setPrivacyMode } = this.props + const { + showFiatInTestnets, + setShowFiatConversionOnTestnetsPreference, + } = this.props return ( <div className="settings-page__content-row"> <div className="settings-page__content-item"> - <span>{ t('privacyMode') }</span> + <span>{ t('showFiatConversionInTestnets') }</span> <div className="settings-page__content-description"> - { t('privacyModeDescription') } + { t('showFiatConversionInTestnetsDescription') } </div> </div> <div className="settings-page__content-item"> <div className="settings-page__content-item-col"> <ToggleButton - value={privacyMode} - onToggle={value => setPrivacyMode(!value)} + value={showFiatInTestnets} + onToggle={value => setShowFiatConversionOnTestnetsPreference(!value)} activeLabel="" inactiveLabel="" /> @@ -520,25 +355,24 @@ export default class SettingsTab extends PureComponent { ) } - render () { - const { warning, isMascara } = this.props + renderContent () { + const { warning } = this.props return ( - <div className="settings-page__content"> + <div className="settings-page__body"> { warning && <div className="settings-tab__error">{ warning }</div> } - { this.renderCurrentConversion() } - { this.renderUsePrimaryCurrencyOptions() } - { this.renderCurrentLocale() } - { this.renderNewRpcUrl() } { this.renderStateLogs() } - { this.renderSeedWords() } - { !isMascara && this.renderOldUI() } + { this.renderMobileSync() } + { this.renderNewRpcUrl() } { this.renderResetAccount() } - { this.renderClearApproval() } - { this.renderPrivacyOptIn() } + { this.renderAdvancedGasInputInline() } { this.renderHexDataOptIn() } - { this.renderBlockieOptIn() } + { this.renderShowConversionInTestnets() } </div> ) } + + render () { + return this.renderContent() + } } diff --git a/ui/app/pages/settings/advanced-tab/advanced-tab.container.js b/ui/app/pages/settings/advanced-tab/advanced-tab.container.js new file mode 100644 index 000000000..69d7e07e6 --- /dev/null +++ b/ui/app/pages/settings/advanced-tab/advanced-tab.container.js @@ -0,0 +1,48 @@ +import AdvancedTab from './advanced-tab.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { + updateAndSetCustomRpc, + displayWarning, + setFeatureFlag, + showModal, + setShowFiatConversionOnTestnetsPreference, +} from '../../../store/actions' +import {preferencesSelector} from '../../../selectors/selectors' + +const mapStateToProps = state => { + const { appState: { warning }, metamask } = state + const { + featureFlags: { + sendHexData, + advancedInlineGas, + } = {}, + } = metamask + const { showFiatInTestnets } = preferencesSelector(state) + + return { + warning, + sendHexData, + advancedInlineGas, + showFiatInTestnets, + } +} + +const mapDispatchToProps = dispatch => { + return { + setHexDataFeatureFlag: shouldShow => dispatch(setFeatureFlag('sendHexData', shouldShow)), + setRpcTarget: (newRpc, chainId, ticker, nickname) => dispatch(updateAndSetCustomRpc(newRpc, chainId, ticker, nickname)), + displayWarning: warning => dispatch(displayWarning(warning)), + showResetAccountConfirmationModal: () => dispatch(showModal({ name: 'CONFIRM_RESET_ACCOUNT' })), + setAdvancedInlineGasFeatureFlag: shouldShow => dispatch(setFeatureFlag('advancedInlineGas', shouldShow)), + setShowFiatConversionOnTestnetsPreference: value => { + return dispatch(setShowFiatConversionOnTestnetsPreference(value)) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(AdvancedTab) diff --git a/ui/app/pages/settings/advanced-tab/index.js b/ui/app/pages/settings/advanced-tab/index.js new file mode 100644 index 000000000..85955174e --- /dev/null +++ b/ui/app/pages/settings/advanced-tab/index.js @@ -0,0 +1 @@ +export { default } from './advanced-tab.container' diff --git a/ui/app/components/pages/settings/index.js b/ui/app/pages/settings/index.js index 44a9ffa63..44a9ffa63 100644 --- a/ui/app/components/pages/settings/index.js +++ b/ui/app/pages/settings/index.js diff --git a/ui/app/pages/settings/index.scss b/ui/app/pages/settings/index.scss new file mode 100644 index 000000000..52208dc85 --- /dev/null +++ b/ui/app/pages/settings/index.scss @@ -0,0 +1,143 @@ +@import 'info-tab/index'; + +@import 'settings-tab/index'; + +.settings-page { + position: relative; + background: $white; + display: flex; + flex-flow: column nowrap; + + &__header { + display: flex; + flex-flow: row nowrap; + padding: 12px 24px; + align-items: center; + border-bottom: 1px solid $alto; + flex: 0 0 auto; + + &__title { + flex: 1 0 auto; + font-size: 24px; + } + } + + &__back-button { + display: none; + + @media screen and (max-width: 575px) { + display: block; + background-image: url('/images/caret-left-black.svg'); + width: 18px; + height: 18px; + opacity: .5; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-right: 16px; + cursor: pointer; + } + } + + &__close-button::after { + content: '\00D7'; + font-size: 40px; + color: $dusty-gray; + cursor: pointer; + } + + &__content { + display: flex; + flex-flow: row nowrap; + height: auto; + overflow: auto; + + &__tabs { + display: flex; + flex-direction: column; + flex: 1 1 auto; + + @media screen and (min-width: 576px) { + flex: 0 0 32%; + max-width: 210px; + border-right: 1px solid $alto; + } + } + + &__modules { + overflow-y: auto; + flex: 1 1 auto; + + @media screen and (max-width: 575px) { + display: none; + } + } + } + + &__body { + padding: 12px 24px; + } + + &__content-row { + display: flex; + flex-direction: column; + padding: 10px 0 20px; + } + + &__content-item { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: 0 5px; + min-height: 71px; + + @media screen and (max-width: 575px) { + height: initial; + padding: 5px 0; + } + + &--without-height { + height: initial; + } + } + + &__content-label { + text-transform: capitalize; + } + + &__content-description { + font-size: 14px; + color: $dusty-gray; + padding-top: 5px; + } + + &__content-item-col { + max-width: 300px; + display: flex; + flex-direction: column; + + @media screen and (max-width: 575px) { + max-width: 100%; + width: 100%; + } + } + + &--selected { + .settings-page { + &__content { + &__tabs { + @media screen and (max-width: 575px) { + display: none; + } + } + + &__modules { + @media screen and (max-width: 575px) { + display: block; + } + } + } + } + } +} diff --git a/ui/app/components/pages/settings/info-tab/index.js b/ui/app/pages/settings/info-tab/index.js index 7556a258d..7556a258d 100644 --- a/ui/app/components/pages/settings/info-tab/index.js +++ b/ui/app/pages/settings/info-tab/index.js diff --git a/ui/app/components/pages/settings/info-tab/index.scss b/ui/app/pages/settings/info-tab/index.scss index 43ad6f652..43ad6f652 100644 --- a/ui/app/components/pages/settings/info-tab/index.scss +++ b/ui/app/pages/settings/info-tab/index.scss diff --git a/ui/app/components/pages/settings/info-tab/info-tab.component.js b/ui/app/pages/settings/info-tab/info-tab.component.js index 72f7d835e..552dd156e 100644 --- a/ui/app/components/pages/settings/info-tab/info-tab.component.js +++ b/ui/app/pages/settings/info-tab/info-tab.component.js @@ -101,11 +101,11 @@ export default class InfoTab extends PureComponent { ) } - render () { + renderContent () { const { t } = this.context return ( - <div className="settings-page__content"> + <div className="settings-page__body"> <div className="settings-page__content-row"> <div className="settings-page__content-item settings-page__content-item--without-height"> <div className="info-tab__logo-wrapper"> @@ -133,4 +133,8 @@ export default class InfoTab extends PureComponent { </div> ) } + + render () { + return this.renderContent() + } } diff --git a/ui/app/pages/settings/security-tab/index.js b/ui/app/pages/settings/security-tab/index.js new file mode 100644 index 000000000..7ffc291a2 --- /dev/null +++ b/ui/app/pages/settings/security-tab/index.js @@ -0,0 +1 @@ +export { default } from './security-tab.container' diff --git a/ui/app/pages/settings/security-tab/security-tab.component.js b/ui/app/pages/settings/security-tab/security-tab.component.js new file mode 100644 index 000000000..233561115 --- /dev/null +++ b/ui/app/pages/settings/security-tab/security-tab.component.js @@ -0,0 +1,195 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { exportAsFile } from '../../../helpers/utils/util' +import ToggleButton from 'react-toggle-button' +import { REVEAL_SEED_ROUTE } from '../../../helpers/constants/routes' +import Button from '../../../components/ui/button' + +export default class SecurityTab extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + setPrivacyMode: PropTypes.func, + privacyMode: PropTypes.bool, + displayWarning: PropTypes.func, + revealSeedConfirmation: PropTypes.func, + showClearApprovalModal: PropTypes.func, + warning: PropTypes.string, + history: PropTypes.object, + mobileSync: PropTypes.bool, + participateInMetaMetrics: PropTypes.bool, + setParticipateInMetaMetrics: PropTypes.func, + } + + renderStateLogs () { + const { t } = this.context + const { displayWarning } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('stateLogs') }</span> + <span className="settings-page__content-description"> + { t('stateLogsDescription') } + </span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="primary" + large + onClick={() => { + window.logStateString((err, result) => { + if (err) { + displayWarning(t('stateLogError')) + } else { + exportAsFile('MetaMask State Logs.json', result) + } + }) + }} + > + { t('downloadStateLogs') } + </Button> + </div> + </div> + </div> + ) + } + + renderClearApproval () { + const { t } = this.context + const { showClearApprovalModal } = this.props + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('approvalData') }</span> + <span className="settings-page__content-description"> + { t('approvalDataDescription') } + </span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="secondary" + large + className="settings-tab__button--orange" + onClick={event => { + event.preventDefault() + showClearApprovalModal() + }} + > + { t('clearApprovalData') } + </Button> + </div> + </div> + </div> + ) + } + + renderSeedWords () { + const { t } = this.context + const { history } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('revealSeedWords') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="secondary" + large + onClick={event => { + event.preventDefault() + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Reveal Seed Phrase', + name: 'Reveal Seed Phrase', + }, + }) + history.push(REVEAL_SEED_ROUTE) + }} + > + { t('revealSeedWords') } + </Button> + </div> + </div> + </div> + ) + } + + renderPrivacyOptIn () { + const { t } = this.context + const { privacyMode, setPrivacyMode } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('privacyMode') }</span> + <div className="settings-page__content-description"> + { t('privacyModeDescription') } + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={privacyMode} + onToggle={value => setPrivacyMode(!value)} + activeLabel="" + inactiveLabel="" + /> + </div> + </div> + </div> + ) + } + + renderMetaMetricsOptIn () { + const { t } = this.context + const { participateInMetaMetrics, setParticipateInMetaMetrics } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('participateInMetaMetrics') }</span> + <div className="settings-page__content-description"> + <span>{ t('participateInMetaMetricsDescription') }</span> + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={participateInMetaMetrics} + onToggle={value => setParticipateInMetaMetrics(!value)} + activeLabel="" + inactiveLabel="" + /> + </div> + </div> + </div> + ) + } + + renderContent () { + const { warning } = this.props + + return ( + <div className="settings-page__body"> + { warning && <div className="settings-tab__error">{ warning }</div> } + { this.renderPrivacyOptIn() } + { this.renderClearApproval() } + { this.renderSeedWords() } + { this.renderMetaMetricsOptIn() } + </div> + ) + } + + render () { + return this.renderContent() + } +} diff --git a/ui/app/pages/settings/security-tab/security-tab.container.js b/ui/app/pages/settings/security-tab/security-tab.container.js new file mode 100644 index 000000000..6036f4eda --- /dev/null +++ b/ui/app/pages/settings/security-tab/security-tab.container.js @@ -0,0 +1,42 @@ +import SecurityTab from './security-tab.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { + displayWarning, + revealSeedConfirmation, + setFeatureFlag, + showModal, + setParticipateInMetaMetrics, +} from '../../../store/actions' + +const mapStateToProps = state => { + const { appState: { warning }, metamask } = state + const { + featureFlags: { + privacyMode, + } = {}, + participateInMetaMetrics, + } = metamask + + return { + warning, + privacyMode, + participateInMetaMetrics, + } +} + +const mapDispatchToProps = dispatch => { + return { + displayWarning: warning => dispatch(displayWarning(warning)), + revealSeedConfirmation: () => dispatch(revealSeedConfirmation()), + setPrivacyMode: enabled => dispatch(setFeatureFlag('privacyMode', enabled)), + showClearApprovalModal: () => dispatch(showModal({ name: 'CLEAR_APPROVED_ORIGINS' })), + setParticipateInMetaMetrics: (val) => dispatch(setParticipateInMetaMetrics(val)), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(SecurityTab) diff --git a/ui/app/components/pages/settings/settings-tab/index.js b/ui/app/pages/settings/settings-tab/index.js index 9fdaafd3f..9fdaafd3f 100644 --- a/ui/app/components/pages/settings/settings-tab/index.js +++ b/ui/app/pages/settings/settings-tab/index.js diff --git a/ui/app/components/pages/settings/settings-tab/index.scss b/ui/app/pages/settings/settings-tab/index.scss index ef32b0e4c..ef32b0e4c 100644 --- a/ui/app/components/pages/settings/settings-tab/index.scss +++ b/ui/app/pages/settings/settings-tab/index.scss diff --git a/ui/app/pages/settings/settings-tab/settings-tab.component.js b/ui/app/pages/settings/settings-tab/settings-tab.component.js new file mode 100644 index 000000000..57e80be0d --- /dev/null +++ b/ui/app/pages/settings/settings-tab/settings-tab.component.js @@ -0,0 +1,200 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import infuraCurrencies from '../../../helpers/constants/infura-conversion.json' +import SimpleDropdown from '../../../components/app/dropdowns/simple-dropdown' +import ToggleButton from 'react-toggle-button' +import locales from '../../../../../app/_locales/index.json' + +const sortedCurrencies = infuraCurrencies.objects.sort((a, b) => { + return a.quote.name.toLocaleLowerCase().localeCompare(b.quote.name.toLocaleLowerCase()) +}) + +const infuraCurrencyOptions = sortedCurrencies.map(({ quote: { code, name } }) => { + return { + displayValue: `${code.toUpperCase()} - ${name}`, + key: code, + value: code, + } +}) + +const localeOptions = locales.map(locale => { + return { + displayValue: `${locale.name}`, + key: locale.code, + value: locale.code, + } +}) + +export default class SettingsTab extends PureComponent { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + setUseBlockie: PropTypes.func, + setCurrentCurrency: PropTypes.func, + displayWarning: PropTypes.func, + warning: PropTypes.string, + history: PropTypes.object, + updateCurrentLocale: PropTypes.func, + currentLocale: PropTypes.string, + useBlockie: PropTypes.bool, + currentCurrency: PropTypes.string, + conversionDate: PropTypes.number, + nativeCurrency: PropTypes.string, + useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, + setUseNativeCurrencyAsPrimaryCurrencyPreference: PropTypes.func, + } + + renderCurrentConversion () { + const { t } = this.context + const { currentCurrency, conversionDate, setCurrentCurrency } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('currencyConversion') }</span> + <span className="settings-page__content-description"> + { t('updatedWithDate', [Date(conversionDate)]) } + </span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <SimpleDropdown + placeholder={t('selectCurrency')} + options={infuraCurrencyOptions} + selectedOption={currentCurrency} + onSelect={newCurrency => setCurrentCurrency(newCurrency)} + /> + </div> + </div> + </div> + ) + } + + renderCurrentLocale () { + const { t } = this.context + const { updateCurrentLocale, currentLocale } = this.props + const currentLocaleMeta = locales.find(locale => locale.code === currentLocale) + const currentLocaleName = currentLocaleMeta ? currentLocaleMeta.name : '' + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span className="settings-page__content-label"> + { t('currentLanguage') } + </span> + <span className="settings-page__content-description"> + { currentLocaleName } + </span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <SimpleDropdown + placeholder={t('selectLocale')} + options={localeOptions} + selectedOption={currentLocale} + onSelect={async newLocale => updateCurrentLocale(newLocale)} + /> + </div> + </div> + </div> + ) + } + + + renderBlockieOptIn () { + const { useBlockie, setUseBlockie } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ this.context.t('blockiesIdenticon') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={useBlockie} + onToggle={value => setUseBlockie(!value)} + activeLabel="" + inactiveLabel="" + /> + </div> + </div> + </div> + ) + } + + renderUsePrimaryCurrencyOptions () { + const { t } = this.context + const { + nativeCurrency, + setUseNativeCurrencyAsPrimaryCurrencyPreference, + useNativeCurrencyAsPrimaryCurrency, + } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('primaryCurrencySetting') }</span> + <div className="settings-page__content-description"> + { t('primaryCurrencySettingDescription') } + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <div className="settings-tab__radio-buttons"> + <div className="settings-tab__radio-button"> + <input + type="radio" + id="native-primary-currency" + onChange={() => setUseNativeCurrencyAsPrimaryCurrencyPreference(true)} + checked={Boolean(useNativeCurrencyAsPrimaryCurrency)} + /> + <label + htmlFor="native-primary-currency" + className="settings-tab__radio-label" + > + { nativeCurrency } + </label> + </div> + <div className="settings-tab__radio-button"> + <input + type="radio" + id="fiat-primary-currency" + onChange={() => setUseNativeCurrencyAsPrimaryCurrencyPreference(false)} + checked={!useNativeCurrencyAsPrimaryCurrency} + /> + <label + htmlFor="fiat-primary-currency" + className="settings-tab__radio-label" + > + { t('fiat') } + </label> + </div> + </div> + </div> + </div> + </div> + ) + } + + renderContent () { + const { warning } = this.props + + return ( + <div className="settings-page__body"> + { warning && <div className="settings-tab__error">{ warning }</div> } + { this.renderCurrentConversion() } + { this.renderUsePrimaryCurrencyOptions() } + { this.renderCurrentLocale() } + { this.renderBlockieOptIn() } + </div> + ) + } + + render () { + return this.renderContent() + } +} diff --git a/ui/app/components/pages/settings/settings-tab/settings-tab.container.js b/ui/app/pages/settings/settings-tab/settings-tab.container.js index b6c33a5b2..d3d8457f0 100644 --- a/ui/app/components/pages/settings/settings-tab/settings-tab.container.js +++ b/ui/app/pages/settings/settings-tab/settings-tab.container.js @@ -4,16 +4,13 @@ import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' import { setCurrentCurrency, - setRpcTarget, displayWarning, - revealSeedConfirmation, setUseBlockie, updateCurrentLocale, - setFeatureFlag, - showModal, setUseNativeCurrencyAsPrimaryCurrencyPreference, -} from '../../../../actions' -import { preferencesSelector } from '../../../../selectors' + setParticipateInMetaMetrics, +} from '../../../store/actions' +import { preferencesSelector } from '../../../selectors/selectors' const mapStateToProps = state => { const { appState: { warning }, metamask } = state @@ -22,27 +19,17 @@ const mapStateToProps = state => { conversionDate, nativeCurrency, useBlockie, - featureFlags: { - sendHexData, - privacyMode, - } = {}, - provider = {}, - isMascara, currentLocale, } = metamask const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) return { warning, - isMascara, currentLocale, currentCurrency, conversionDate, nativeCurrency, useBlockie, - sendHexData, - privacyMode, - provider, useNativeCurrencyAsPrimaryCurrency, } } @@ -50,21 +37,13 @@ const mapStateToProps = state => { const mapDispatchToProps = dispatch => { return { setCurrentCurrency: currency => dispatch(setCurrentCurrency(currency)), - setRpcTarget: (newRpc, chainId, ticker, nickname) => dispatch(setRpcTarget(newRpc, chainId, ticker, nickname)), displayWarning: warning => dispatch(displayWarning(warning)), - revealSeedConfirmation: () => dispatch(revealSeedConfirmation()), setUseBlockie: value => dispatch(setUseBlockie(value)), updateCurrentLocale: key => dispatch(updateCurrentLocale(key)), - setFeatureFlagToBeta: () => { - return dispatch(setFeatureFlag('betaUI', false, 'OLD_UI_NOTIFICATION_MODAL')) - }, - setHexDataFeatureFlag: shouldShow => dispatch(setFeatureFlag('sendHexData', shouldShow)), - setPrivacyMode: enabled => dispatch(setFeatureFlag('privacyMode', enabled)), - showResetAccountConfirmationModal: () => dispatch(showModal({ name: 'CONFIRM_RESET_ACCOUNT' })), setUseNativeCurrencyAsPrimaryCurrencyPreference: value => { return dispatch(setUseNativeCurrencyAsPrimaryCurrencyPreference(value)) }, - showClearApprovalModal: () => dispatch(showModal({ name: 'CLEAR_APPROVED_ORIGINS' })), + setParticipateInMetaMetrics: (val) => dispatch(setParticipateInMetaMetrics(val)), } } diff --git a/ui/app/pages/settings/settings.component.js b/ui/app/pages/settings/settings.component.js new file mode 100644 index 000000000..3d415c6b8 --- /dev/null +++ b/ui/app/pages/settings/settings.component.js @@ -0,0 +1,137 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route, matchPath } from 'react-router-dom' +import { ENVIRONMENT_TYPE_POPUP } from '../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../app/scripts/lib/util' +import TabBar from '../../components/app/tab-bar' +import c from 'classnames' +import SettingsTab from './settings-tab' +import AdvancedTab from './advanced-tab' +import InfoTab from './info-tab' +import SecurityTab from './security-tab' +import { + DEFAULT_ROUTE, + ADVANCED_ROUTE, + SECURITY_ROUTE, + GENERAL_ROUTE, + ABOUT_US_ROUTE, + SETTINGS_ROUTE, +} from '../../helpers/constants/routes' + +const ROUTES_TO_I18N_KEYS = { + [GENERAL_ROUTE]: 'general', + [ADVANCED_ROUTE]: 'advanced', + [SECURITY_ROUTE]: 'securityAndPrivacy', + [ABOUT_US_ROUTE]: 'aboutUs', +} + +export default class SettingsPage extends PureComponent { + static propTypes = { + location: PropTypes.object, + history: PropTypes.object, + t: PropTypes.func, + } + + static contextTypes = { + t: PropTypes.func, + } + + isCurrentPath (pathname) { + return this.props.location.pathname === pathname + } + + render () { + const { t } = this.context + const { history, location } = this.props + + const pathnameI18nKey = ROUTES_TO_I18N_KEYS[location.pathname] + const isPopupView = getEnvironmentType(location.href) === ENVIRONMENT_TYPE_POPUP + + return ( + <div + className={c('main-container settings-page', { + 'settings-page--selected': !this.isCurrentPath(SETTINGS_ROUTE), + })} + > + <div className="settings-page__header"> + { + !this.isCurrentPath(SETTINGS_ROUTE) && ( + <div + className="settings-page__back-button" + onClick={() => history.push(SETTINGS_ROUTE)} + /> + ) + } + <div className="settings-page__header__title"> + {t(pathnameI18nKey && isPopupView ? pathnameI18nKey : 'settings')} + </div> + <div + className="settings-page__close-button" + onClick={() => history.push(DEFAULT_ROUTE)} + /> + </div> + <div className="settings-page__content"> + <div className="settings-page__content__tabs"> + { this.renderTabs() } + </div> + <div className="settings-page__content__modules"> + { this.renderContent() } + </div> + </div> + </div> + ) + } + + renderTabs () { + const { history, location } = this.props + const { t } = this.context + + return ( + <TabBar + tabs={[ + { content: t('general'), description: t('generalSettingsDescription'), key: GENERAL_ROUTE }, + { content: t('advanced'), description: t('advancedSettingsDescription'), key: ADVANCED_ROUTE }, + { content: t('securityAndPrivacy'), description: t('securitySettingsDescription'), key: SECURITY_ROUTE }, + { content: t('aboutUs'), key: ABOUT_US_ROUTE }, + ]} + isActive={key => { + if (key === GENERAL_ROUTE && this.isCurrentPath(SETTINGS_ROUTE)) { + return true + } + return matchPath(location.pathname, { path: key, exact: true }) + }} + onSelect={key => history.push(key)} + /> + ) + } + + renderContent () { + return ( + <Switch> + <Route + exact + path={GENERAL_ROUTE} + component={SettingsTab} + /> + <Route + exact + path={ABOUT_US_ROUTE} + component={InfoTab} + /> + <Route + exact + path={ADVANCED_ROUTE} + component={AdvancedTab} + /> + <Route + exact + path={SECURITY_ROUTE} + component={SecurityTab} + /> + <Route + component={SettingsTab} + /> + </Switch> + ) + } +} diff --git a/ui/app/components/pages/unlock-page/index.js b/ui/app/pages/unlock-page/index.js index be80cde4f..be80cde4f 100644 --- a/ui/app/components/pages/unlock-page/index.js +++ b/ui/app/pages/unlock-page/index.js diff --git a/ui/app/components/pages/unlock-page/index.scss b/ui/app/pages/unlock-page/index.scss index 6bd52282d..3d44bd037 100644 --- a/ui/app/components/pages/unlock-page/index.scss +++ b/ui/app/pages/unlock-page/index.scss @@ -14,7 +14,6 @@ align-self: stretch; justify-content: center; flex: 1 0 auto; - height: 100vh; } &__mascot-container { diff --git a/ui/app/components/pages/unlock-page/unlock-page.component.js b/ui/app/pages/unlock-page/unlock-page.component.js index 94915df76..3aeb2a59b 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ b/ui/app/pages/unlock-page/unlock-page.component.js @@ -1,26 +1,26 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import Button from '@material-ui/core/Button' -import TextField from '../../text-field' -import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' -import { getEnvironmentType } from '../../../../../app/scripts/lib/util' +import TextField from '../../components/ui/text-field' import getCaretCoordinates from 'textarea-caret' import { EventEmitter } from 'events' -import Mascot from '../../mascot' -import { DEFAULT_ROUTE, RESTORE_VAULT_ROUTE } from '../../../routes' +import Mascot from '../../components/ui/mascot' +import { DEFAULT_ROUTE } from '../../helpers/constants/routes' export default class UnlockPage extends Component { static contextTypes = { + metricsEvent: PropTypes.func, t: PropTypes.func, } static propTypes = { - forgotPassword: PropTypes.func, - tryUnlockMetamask: PropTypes.func, - markPasswordForgotten: PropTypes.func, history: PropTypes.object, isUnlocked: PropTypes.bool, - useOldInterface: PropTypes.func, + onImport: PropTypes.func, + onRestore: PropTypes.func, + onSubmit: PropTypes.func, + forceUpdateMetamaskState: PropTypes.func, + showOptInModal: PropTypes.func, } constructor (props) { @@ -43,12 +43,12 @@ export default class UnlockPage extends Component { } } - async handleSubmit (event) { + handleSubmit = async event => { event.preventDefault() event.stopPropagation() const { password } = this.state - const { tryUnlockMetamask, history } = this.props + const { onSubmit, forceUpdateMetamaskState, showOptInModal } = this.props if (password === '' || this.submitting) { return @@ -58,10 +58,36 @@ export default class UnlockPage extends Component { this.submitting = true try { - await tryUnlockMetamask(password) - this.submitting = false - history.push(DEFAULT_ROUTE) + await onSubmit(password) + const newState = await forceUpdateMetamaskState() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Unlock', + name: 'Success', + }, + isNewVisit: true, + }) + + if (newState.participateInMetaMetrics === null || newState.participateInMetaMetrics === undefined) { + showOptInModal() + } } catch ({ message }) { + if (message === 'Incorrect password') { + const newState = await forceUpdateMetamaskState() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Unlock', + name: 'Incorrect Passowrd', + }, + customVariables: { + numberOfTokens: newState.tokens.length, + numberOfAccounts: Object.keys(newState.accounts).length, + }, + }) + } + this.setState({ error: message }) this.submitting = false } @@ -99,7 +125,7 @@ export default class UnlockPage extends Component { fullWidth variant="raised" size="large" - onClick={event => this.handleSubmit(event)} + onClick={this.handleSubmit} disableRipple > { this.context.t('login') } @@ -110,7 +136,7 @@ export default class UnlockPage extends Component { render () { const { password, error } = this.state const { t } = this.context - const { markPasswordForgotten, history } = this.props + const { onImport, onRestore } = this.props return ( <div className="unlock-page__container"> @@ -128,7 +154,7 @@ export default class UnlockPage extends Component { <div>{ t('unlockMessage') }</div> <form className="unlock-page__form" - onSubmit={event => this.handleSubmit(event)} + onSubmit={this.handleSubmit} > <TextField id="password" @@ -147,27 +173,13 @@ export default class UnlockPage extends Component { <div className="unlock-page__links"> <div className="unlock-page__link" - onClick={() => { - markPasswordForgotten() - history.push(RESTORE_VAULT_ROUTE) - - if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { - global.platform.openExtensionInBrowser() - } - }} + onClick={() => onRestore()} > { t('restoreFromSeed') } </div> <div className="unlock-page__link unlock-page__link--import" - onClick={() => { - markPasswordForgotten() - history.push(RESTORE_VAULT_ROUTE) - - if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { - global.platform.openExtensionInBrowser() - } - }} + onClick={() => onImport()} > { t('importUsingSeed') } </div> diff --git a/ui/app/pages/unlock-page/unlock-page.container.js b/ui/app/pages/unlock-page/unlock-page.container.js new file mode 100644 index 000000000..b89392ab5 --- /dev/null +++ b/ui/app/pages/unlock-page/unlock-page.container.js @@ -0,0 +1,64 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import { getEnvironmentType } from '../../../../app/scripts/lib/util' +import { ENVIRONMENT_TYPE_POPUP } from '../../../../app/scripts/lib/enums' +import { DEFAULT_ROUTE, RESTORE_VAULT_ROUTE } from '../../helpers/constants/routes' +import { + tryUnlockMetamask, + forgotPassword, + markPasswordForgotten, + forceUpdateMetamaskState, + showModal, +} from '../../store/actions' +import UnlockPage from './unlock-page.component' + +const mapStateToProps = state => { + const { metamask: { isUnlocked } } = state + return { + isUnlocked, + } +} + +const mapDispatchToProps = dispatch => { + return { + forgotPassword: () => dispatch(forgotPassword()), + tryUnlockMetamask: password => dispatch(tryUnlockMetamask(password)), + markPasswordForgotten: () => dispatch(markPasswordForgotten()), + forceUpdateMetamaskState: () => forceUpdateMetamaskState(dispatch), + showOptInModal: () => dispatch(showModal({ name: 'METAMETRICS_OPT_IN_MODAL' })), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { markPasswordForgotten, tryUnlockMetamask, ...restDispatchProps } = dispatchProps + const { history, onSubmit: ownPropsSubmit, ...restOwnProps } = ownProps + + const onImport = () => { + markPasswordForgotten() + history.push(RESTORE_VAULT_ROUTE) + + if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { + global.platform.openExtensionInBrowser(RESTORE_VAULT_ROUTE) + } + } + + const onSubmit = async password => { + await tryUnlockMetamask(password) + history.push(DEFAULT_ROUTE) + } + + return { + ...stateProps, + ...restDispatchProps, + ...restOwnProps, + onImport, + onRestore: onImport, + onSubmit: ownPropsSubmit || onSubmit, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(UnlockPage) diff --git a/ui/app/root.js b/ui/app/root.js deleted file mode 100644 index 09deae1b1..000000000 --- a/ui/app/root.js +++ /dev/null @@ -1,23 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const { Provider } = require('react-redux') -const h = require('react-hyperscript') -const SelectedApp = require('./select-app') - -class Root extends Component { - render () { - const { store } = this.props - - return ( - h(Provider, { store }, [ - h(SelectedApp), - ]) - ) - } -} - -Root.propTypes = { - store: PropTypes.object, -} - -module.exports = Root diff --git a/ui/app/select-app.js b/ui/app/select-app.js deleted file mode 100644 index f2e8e8d10..000000000 --- a/ui/app/select-app.js +++ /dev/null @@ -1,72 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const { HashRouter } = require('react-router-dom') -const App = require('./app') -const OldApp = require('../../old-ui/app/app') -const { autoAddToBetaUI } = require('./selectors') -const { setFeatureFlag } = require('./actions') -const I18nProvider = require('./i18n-provider') - -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')) - }, - setFeatureFlagWithoutModal: () => { - return dispatch(setFeatureFlag('betaUI', true)) - }, - } -} -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 - - return betaUI || isMascara - ? h(HashRouter, { - hashType: 'noslash', - }, [ - h(I18nProvider, [ h(App) ]), - ]) - : h(OldApp) -} diff --git a/ui/app/selectors/confirm-transaction.js b/ui/app/selectors/confirm-transaction.js index 90924c036..9b5eda82f 100644 --- a/ui/app/selectors/confirm-transaction.js +++ b/ui/app/selectors/confirm-transaction.js @@ -1,7 +1,7 @@ import { createSelector } from 'reselect' import txHelper from '../../lib/tx-helper' -import { calcTokenAmount } from '../token-util' -import { roundExponential } from '../helpers/confirm-transaction/util' +import { calcTokenAmount } from '../helpers/utils/token-util' +import { roundExponential } from '../helpers/utils/confirm-tx.util' const unapprovedTxsSelector = state => state.metamask.unapprovedTxs const unapprovedMsgsSelector = state => state.metamask.unapprovedMsgs @@ -95,7 +95,7 @@ export const currentCurrencySelector = state => state.metamask.currentCurrency export const conversionRateSelector = state => state.metamask.conversionRate export const getNativeCurrency = state => state.metamask.nativeCurrency -const txDataSelector = state => state.confirmTransaction.txData +export const txDataSelector = state => state.confirmTransaction.txData const tokenDataSelector = state => state.confirmTransaction.tokenData const tokenPropsSelector = state => state.confirmTransaction.tokenProps @@ -137,11 +137,12 @@ export const tokenAmountAndToAddressSelector = createSelector( const valueParam = params.find(param => param.name === TOKEN_PARAM_VALUE) toAddress = toParam ? toParam.value : params[0].value const value = valueParam ? Number(valueParam.value) : Number(params[1].value) - tokenAmount = roundExponential(value) if (tokenDecimals) { - tokenAmount = calcTokenAmount(value, tokenDecimals) + tokenAmount = calcTokenAmount(value, tokenDecimals).toNumber() } + + tokenAmount = roundExponential(tokenAmount) } return { @@ -163,7 +164,7 @@ export const approveTokenAmountAndToAddressSelector = createSelector( const value = Number(params.find(param => param.name === TOKEN_PARAM_VALUE).value) if (tokenDecimals) { - tokenAmount = calcTokenAmount(value, tokenDecimals) + tokenAmount = calcTokenAmount(value, tokenDecimals).toNumber() } tokenAmount = roundExponential(tokenAmount) @@ -188,7 +189,7 @@ export const sendTokenTokenAmountAndToAddressSelector = createSelector( let value = Number(params.find(param => param.name === TOKEN_PARAM_VALUE).value) if (tokenDecimals) { - value = calcTokenAmount(value, tokenDecimals) + value = calcTokenAmount(value, tokenDecimals).toNumber() } tokenAmount = roundExponential(value) diff --git a/ui/app/selectors/custom-gas.js b/ui/app/selectors/custom-gas.js new file mode 100644 index 000000000..ecffb37ca --- /dev/null +++ b/ui/app/selectors/custom-gas.js @@ -0,0 +1,325 @@ +import { pipe, partialRight } from 'ramda' +import { + conversionUtil, + multiplyCurrencies, + conversionGreaterThan, +} from '../helpers/utils/conversion-util' +import { + getCurrentCurrency, getIsMainnet, preferencesSelector, +} from './selectors' +import { + formatCurrency, +} from '../helpers/utils/confirm-tx.util' +import { + decEthToConvertedCurrency as ethTotalToConvertedCurrency, +} from '../helpers/utils/conversions.util' +import { + formatETHFee, +} from '../helpers/utils/formatters' +import { + calcGasTotal, +} from '../components/app/send/send.utils' +import { addHexPrefix } from 'ethereumjs-util' + +const selectors = { + formatTimeEstimate, + getAveragePriceEstimateInHexWEI, + getFastPriceEstimateInHexWEI, + getBasicGasEstimateLoadingStatus, + getBasicGasEstimateBlockTime, + getCustomGasErrors, + getCustomGasLimit, + getCustomGasPrice, + getCustomGasTotal, + getDefaultActiveButtonIndex, + getEstimatedGasPrices, + getEstimatedGasTimes, + getGasEstimatesLoadingStatus, + getPriceAndTimeEstimates, + getRenderableBasicEstimateData, + getRenderableEstimateDataForSmallButtonsFromGWEI, + priceEstimateToWei, + getSafeLowEstimate, + isCustomPriceSafe, +} + +module.exports = selectors + +const NUMBER_OF_DECIMALS_SM_BTNS = 5 + +function getCustomGasErrors (state) { + return state.gas.errors +} + +function getCustomGasLimit (state) { + return state.gas.customData.limit +} + +function getCustomGasPrice (state) { + return state.gas.customData.price +} + +function getCustomGasTotal (state) { + return state.gas.customData.total +} + +function getBasicGasEstimateLoadingStatus (state) { + return state.gas.basicEstimateIsLoading +} + +function getGasEstimatesLoadingStatus (state) { + return state.gas.gasEstimatesLoading +} + +function getPriceAndTimeEstimates (state) { + return state.gas.priceAndTimeEstimates +} + +function getEstimatedGasPrices (state) { + return getPriceAndTimeEstimates(state).map(({ gasprice }) => gasprice) +} + +function getEstimatedGasTimes (state) { + return getPriceAndTimeEstimates(state).map(({ expectedTime }) => expectedTime) +} + +function getAveragePriceEstimateInHexWEI (state) { + const averagePriceEstimate = state.gas.basicEstimates.average + return getGasPriceInHexWei(averagePriceEstimate || '0x0') +} + +function getFastPriceEstimateInHexWEI (state) { + const fastPriceEstimate = state.gas.basicEstimates.fast + return getGasPriceInHexWei(fastPriceEstimate || '0x0') +} + +function getDefaultActiveButtonIndex (gasButtonInfo, customGasPriceInHex, gasPrice) { + return gasButtonInfo.findIndex(({ priceInHexWei }) => { + return priceInHexWei === addHexPrefix(customGasPriceInHex || gasPrice) + }) +} + +function getSafeLowEstimate (state) { + const { + gas: { + basicEstimates: { + safeLow, + }, + }, + } = state + + return safeLow +} + +function isCustomPriceSafe (state) { + const safeLow = getSafeLowEstimate(state) + const customGasPrice = getCustomGasPrice(state) + + if (!customGasPrice) { + return true + } + + const customPriceSafe = conversionGreaterThan( + { + value: customGasPrice, + fromNumericBase: 'hex', + fromDenomination: 'WEI', + toDenomination: 'GWEI', + }, + { value: safeLow, fromNumericBase: 'dec' } + ) + + return customPriceSafe +} + +function getBasicGasEstimateBlockTime (state) { + return state.gas.basicEstimates.blockTime +} + +function basicPriceEstimateToETHTotal (estimate, gasLimit, numberOfDecimals = 9) { + return conversionUtil(calcGasTotal(gasLimit, estimate), { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'GWEI', + numberOfDecimals, + }) +} + +function getRenderableEthFee (estimate, gasLimit, numberOfDecimals = 9) { + return pipe( + x => conversionUtil(x, { fromNumericBase: 'dec', toNumericBase: 'hex' }), + partialRight(basicPriceEstimateToETHTotal, [gasLimit, numberOfDecimals]), + formatETHFee + )(estimate, gasLimit) +} + + +function getRenderableConvertedCurrencyFee (estimate, gasLimit, convertedCurrency, conversionRate) { + return pipe( + x => conversionUtil(x, { fromNumericBase: 'dec', toNumericBase: 'hex' }), + partialRight(basicPriceEstimateToETHTotal, [gasLimit]), + partialRight(ethTotalToConvertedCurrency, [convertedCurrency, conversionRate]), + partialRight(formatCurrency, [convertedCurrency]) + )(estimate, gasLimit, convertedCurrency, conversionRate) +} + +function getTimeEstimateInSeconds (blockWaitEstimate) { + return multiplyCurrencies(blockWaitEstimate, 60, { + toNumericBase: 'dec', + multiplicandBase: 10, + multiplierBase: 10, + numberOfDecimals: 1, + }) +} + +function formatTimeEstimate (totalSeconds, greaterThanMax, lessThanMin) { + const minutes = Math.floor(totalSeconds / 60) + const seconds = Math.floor(totalSeconds % 60) + + if (!minutes && !seconds) { + return '...' + } + + let symbol = '~' + if (greaterThanMax) { + symbol = '< ' + } else if (lessThanMin) { + symbol = '> ' + } + + const formattedMin = `${minutes ? minutes + ' min' : ''}` + const formattedSec = `${seconds ? seconds + ' sec' : ''}` + const formattedCombined = formattedMin && formattedSec + ? `${symbol}${formattedMin} ${formattedSec}` + : symbol + [formattedMin, formattedSec].find(t => t) + + return formattedCombined +} + +function getRenderableTimeEstimate (blockWaitEstimate) { + return pipe( + getTimeEstimateInSeconds, + formatTimeEstimate + )(blockWaitEstimate) +} + +function priceEstimateToWei (priceEstimate) { + return conversionUtil(priceEstimate, { + fromNumericBase: 'hex', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + numberOfDecimals: 9, + }) +} + +function getGasPriceInHexWei (price) { + return pipe( + x => conversionUtil(x, { fromNumericBase: 'dec', toNumericBase: 'hex' }), + priceEstimateToWei, + addHexPrefix + )(price) +} + +function getRenderableBasicEstimateData (state, gasLimit) { + if (getBasicGasEstimateLoadingStatus(state)) { + return [] + } + + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const showFiat = (isMainnet || !!showFiatInTestnets) + const conversionRate = state.metamask.conversionRate + const currentCurrency = getCurrentCurrency(state) + const { + gas: { + basicEstimates: { + safeLow, + fast, + fastest, + safeLowWait, + fastestWait, + fastWait, + }, + }, + } = state + + return [ + { + labelKey: 'slow', + feeInPrimaryCurrency: getRenderableEthFee(safeLow, gasLimit), + feeInSecondaryCurrency: showFiat + ? getRenderableConvertedCurrencyFee(safeLow, gasLimit, currentCurrency, conversionRate) + : '', + timeEstimate: safeLowWait && getRenderableTimeEstimate(safeLowWait), + priceInHexWei: getGasPriceInHexWei(safeLow), + }, + { + labelKey: 'average', + feeInPrimaryCurrency: getRenderableEthFee(fast, gasLimit), + feeInSecondaryCurrency: showFiat + ? getRenderableConvertedCurrencyFee(fast, gasLimit, currentCurrency, conversionRate) + : '', + timeEstimate: fastWait && getRenderableTimeEstimate(fastWait), + priceInHexWei: getGasPriceInHexWei(fast), + }, + { + labelKey: 'fast', + feeInPrimaryCurrency: getRenderableEthFee(fastest, gasLimit), + feeInSecondaryCurrency: showFiat + ? getRenderableConvertedCurrencyFee(fastest, gasLimit, currentCurrency, conversionRate) + : '', + timeEstimate: fastestWait && getRenderableTimeEstimate(fastestWait), + priceInHexWei: getGasPriceInHexWei(fastest), + }, + ] +} + +function getRenderableEstimateDataForSmallButtonsFromGWEI (state) { + if (getBasicGasEstimateLoadingStatus(state)) { + return [] + } + + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const showFiat = (isMainnet || !!showFiatInTestnets) + const gasLimit = state.metamask.send.gasLimit || getCustomGasLimit(state) || '0x5208' + const conversionRate = state.metamask.conversionRate + const currentCurrency = getCurrentCurrency(state) + const { + gas: { + basicEstimates: { + safeLow, + fast, + fastest, + }, + }, + } = state + + return [ + { + labelKey: 'slow', + feeInSecondaryCurrency: showFiat + ? getRenderableConvertedCurrencyFee(safeLow, gasLimit, currentCurrency, conversionRate) + : '', + feeInPrimaryCurrency: getRenderableEthFee(safeLow, gasLimit, NUMBER_OF_DECIMALS_SM_BTNS, true), + priceInHexWei: getGasPriceInHexWei(safeLow, true), + }, + { + labelKey: 'average', + feeInSecondaryCurrency: showFiat + ? getRenderableConvertedCurrencyFee(fast, gasLimit, currentCurrency, conversionRate) + : '', + feeInPrimaryCurrency: getRenderableEthFee(fast, gasLimit, NUMBER_OF_DECIMALS_SM_BTNS, true), + priceInHexWei: getGasPriceInHexWei(fast, true), + }, + { + labelKey: 'fast', + feeInSecondaryCurrency: showFiat + ? getRenderableConvertedCurrencyFee(fastest, gasLimit, currentCurrency, conversionRate) + : '', + feeInPrimaryCurrency: getRenderableEthFee(fastest, gasLimit, NUMBER_OF_DECIMALS_SM_BTNS, true), + priceInHexWei: getGasPriceInHexWei(fastest, true), + }, + ] +} diff --git a/ui/app/selectors/custom-gas.test.js b/ui/app/selectors/custom-gas.test.js new file mode 100644 index 000000000..6df4a60c7 --- /dev/null +++ b/ui/app/selectors/custom-gas.test.js @@ -0,0 +1,595 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +const { + getCustomGasErrors, + getCustomGasLimit, + getCustomGasPrice, + getCustomGasTotal, + getEstimatedGasPrices, + getEstimatedGasTimes, + getPriceAndTimeEstimates, + getRenderableBasicEstimateData, + getRenderableEstimateDataForSmallButtonsFromGWEI, +} = proxyquire('./custom-gas', {}) + +describe('custom-gas selectors', () => { + + describe('getCustomGasPrice()', () => { + it('should return gas.customData.price', () => { + const mockState = { gas: { customData: { price: 'mockPrice' } } } + assert.equal(getCustomGasPrice(mockState), 'mockPrice') + }) + }) + + describe('getCustomGasLimit()', () => { + it('should return gas.customData.limit', () => { + const mockState = { gas: { customData: { limit: 'mockLimit' } } } + assert.equal(getCustomGasLimit(mockState), 'mockLimit') + }) + }) + + describe('getCustomGasTotal()', () => { + it('should return gas.customData.total', () => { + const mockState = { gas: { customData: { total: 'mockTotal' } } } + assert.equal(getCustomGasTotal(mockState), 'mockTotal') + }) + }) + + describe('getCustomGasErrors()', () => { + it('should return gas.errors', () => { + const mockState = { gas: { errors: 'mockErrors' } } + assert.equal(getCustomGasErrors(mockState), 'mockErrors') + }) + }) + + describe('getPriceAndTimeEstimates', () => { + it('should return price and time estimates', () => { + const mockState = { gas: { priceAndTimeEstimates: 'mockPriceAndTimeEstimates' } } + assert.equal(getPriceAndTimeEstimates(mockState), 'mockPriceAndTimeEstimates') + }) + }) + + describe('getEstimatedGasPrices', () => { + it('should return price and time estimates', () => { + const mockState = { gas: { priceAndTimeEstimates: [ + { gasprice: 12, somethingElse: 20 }, + { gasprice: 22, expectedTime: 30 }, + { gasprice: 32, somethingElse: 40 }, + ] } } + assert.deepEqual(getEstimatedGasPrices(mockState), [12, 22, 32]) + }) + }) + + describe('getEstimatedGasTimes', () => { + it('should return price and time estimates', () => { + const mockState = { gas: { priceAndTimeEstimates: [ + { somethingElse: 12, expectedTime: 20 }, + { gasPrice: 22, expectedTime: 30 }, + { somethingElse: 32, expectedTime: 40 }, + ] } } + assert.deepEqual(getEstimatedGasTimes(mockState), [20, 30, 40]) + }) + }) + + describe('getRenderableBasicEstimateData()', () => { + const tests = [ + { + expectedResult: [ + { + labelKey: 'slow', + feeInSecondaryCurrency: '$0.01', + feeInPrimaryCurrency: '0.0000525 ETH', + timeEstimate: '~6 min 36 sec', + priceInHexWei: '0x9502f900', + }, + { + labelKey: 'average', + feeInSecondaryCurrency: '$0.03', + feeInPrimaryCurrency: '0.000105 ETH', + timeEstimate: '~3 min 18 sec', + priceInHexWei: '0x12a05f200', + }, + { + labelKey: 'fast', + feeInSecondaryCurrency: '$0.05', + feeInPrimaryCurrency: '0.00021 ETH', + timeEstimate: '~30 sec', + priceInHexWei: '0x2540be400', + }, + ], + mockState: { + metamask: { + conversionRate: 255.71, + currentCurrency: 'usd', + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 2.5, + safeLowWait: 6.6, + fast: 5, + fastWait: 3.3, + fastest: 10, + fastestWait: 0.5, + }, + }, + }, + }, + { + expectedResult: [ + { + labelKey: 'slow', + feeInSecondaryCurrency: '$0.27', + feeInPrimaryCurrency: '0.000105 ETH', + timeEstimate: '~13 min 12 sec', + priceInHexWei: '0x12a05f200', + }, + { + labelKey: 'average', + feeInSecondaryCurrency: '$0.54', + feeInPrimaryCurrency: '0.00021 ETH', + timeEstimate: '~6 min 36 sec', + priceInHexWei: '0x2540be400', + }, + { + labelKey: 'fast', + feeInSecondaryCurrency: '$1.07', + feeInPrimaryCurrency: '0.00042 ETH', + timeEstimate: '~1 min', + priceInHexWei: '0x4a817c800', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 5, + safeLowWait: 13.2, + fast: 10, + fastWait: 6.6, + fastest: 20, + fastestWait: 1.0, + }, + }, + }, + }, + { + expectedResult: [ + { + labelKey: 'slow', + feeInSecondaryCurrency: '', + feeInPrimaryCurrency: '0.000105 ETH', + timeEstimate: '~13 min 12 sec', + priceInHexWei: '0x12a05f200', + }, + { + labelKey: 'average', + feeInSecondaryCurrency: '', + feeInPrimaryCurrency: '0.00021 ETH', + timeEstimate: '~6 min 36 sec', + priceInHexWei: '0x2540be400', + }, + { + labelKey: 'fast', + feeInSecondaryCurrency: '', + feeInPrimaryCurrency: '0.00042 ETH', + timeEstimate: '~1 min', + priceInHexWei: '0x4a817c800', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'rinkeby', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 5, + safeLowWait: 13.2, + fast: 10, + fastWait: 6.6, + fastest: 20, + fastestWait: 1.0, + }, + }, + }, + }, + { + expectedResult: [ + { + labelKey: 'slow', + feeInSecondaryCurrency: '$0.27', + feeInPrimaryCurrency: '0.000105 ETH', + timeEstimate: '~13 min 12 sec', + priceInHexWei: '0x12a05f200', + }, + { + labelKey: 'average', + feeInSecondaryCurrency: '$0.54', + feeInPrimaryCurrency: '0.00021 ETH', + timeEstimate: '~6 min 36 sec', + priceInHexWei: '0x2540be400', + }, + { + labelKey: 'fast', + feeInSecondaryCurrency: '$1.07', + feeInPrimaryCurrency: '0.00042 ETH', + timeEstimate: '~1 min', + priceInHexWei: '0x4a817c800', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'rinkeby', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 5, + safeLowWait: 13.2, + fast: 10, + fastWait: 6.6, + fastest: 20, + fastestWait: 1.0, + }, + }, + }, + }, + { + expectedResult: [ + { + labelKey: 'slow', + feeInSecondaryCurrency: '$0.27', + feeInPrimaryCurrency: '0.000105 ETH', + timeEstimate: '~13 min 12 sec', + priceInHexWei: '0x12a05f200', + }, + { + labelKey: 'average', + feeInSecondaryCurrency: '$0.54', + feeInPrimaryCurrency: '0.00021 ETH', + timeEstimate: '~6 min 36 sec', + priceInHexWei: '0x2540be400', + }, + { + labelKey: 'fast', + feeInSecondaryCurrency: '$1.07', + feeInPrimaryCurrency: '0.00042 ETH', + timeEstimate: '~1 min', + priceInHexWei: '0x4a817c800', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'mainnet', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 5, + safeLowWait: 13.2, + fast: 10, + fastWait: 6.6, + fastest: 20, + fastestWait: 1.0, + }, + }, + }, + }, + ] + it('should return renderable data about basic estimates', () => { + tests.forEach(test => { + assert.deepEqual( + getRenderableBasicEstimateData(test.mockState, '0x5208'), + test.expectedResult + ) + }) + }) + + }) + + describe('getRenderableEstimateDataForSmallButtonsFromGWEI()', () => { + const tests = [ + { + expectedResult: [ + { + feeInSecondaryCurrency: '$0.13', + feeInPrimaryCurrency: '0.00052 ETH', + labelKey: 'slow', + priceInHexWei: '0x5d21dba00', + }, + { + feeInSecondaryCurrency: '$0.27', + feeInPrimaryCurrency: '0.00105 ETH', + labelKey: 'average', + priceInHexWei: '0xba43b7400', + }, + { + feeInSecondaryCurrency: '$0.54', + feeInPrimaryCurrency: '0.0021 ETH', + labelKey: 'fast', + priceInHexWei: '0x174876e800', + }, + ], + mockState: { + metamask: { + conversionRate: 255.71, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 25, + safeLowWait: 6.6, + fast: 50, + fastWait: 3.3, + fastest: 100, + fastestWait: 0.5, + }, + }, + }, + }, + { + expectedResult: [ + { + feeInSecondaryCurrency: '$2.68', + feeInPrimaryCurrency: '0.00105 ETH', + labelKey: 'slow', + priceInHexWei: '0xba43b7400', + }, + { + feeInSecondaryCurrency: '$5.37', + feeInPrimaryCurrency: '0.0021 ETH', + labelKey: 'average', + priceInHexWei: '0x174876e800', + }, + { + feeInSecondaryCurrency: '$10.74', + feeInPrimaryCurrency: '0.0042 ETH', + labelKey: 'fast', + priceInHexWei: '0x2e90edd000', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'mainnet', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 50, + safeLowWait: 13.2, + fast: 100, + fastWait: 6.6, + fastest: 200, + fastestWait: 1.0, + }, + }, + }, + }, + { + expectedResult: [ + { + feeInSecondaryCurrency: '', + feeInPrimaryCurrency: '0.00105 ETH', + labelKey: 'slow', + priceInHexWei: '0xba43b7400', + }, + { + feeInSecondaryCurrency: '', + feeInPrimaryCurrency: '0.0021 ETH', + labelKey: 'average', + priceInHexWei: '0x174876e800', + }, + { + feeInSecondaryCurrency: '', + feeInPrimaryCurrency: '0.0042 ETH', + labelKey: 'fast', + priceInHexWei: '0x2e90edd000', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: false, + }, + provider: { + type: 'rinkeby', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 50, + safeLowWait: 13.2, + fast: 100, + fastWait: 6.6, + fastest: 200, + fastestWait: 1.0, + }, + }, + }, + }, + { + expectedResult: [ + { + feeInSecondaryCurrency: '$2.68', + feeInPrimaryCurrency: '0.00105 ETH', + labelKey: 'slow', + priceInHexWei: '0xba43b7400', + }, + { + feeInSecondaryCurrency: '$5.37', + feeInPrimaryCurrency: '0.0021 ETH', + labelKey: 'average', + priceInHexWei: '0x174876e800', + }, + { + feeInSecondaryCurrency: '$10.74', + feeInPrimaryCurrency: '0.0042 ETH', + labelKey: 'fast', + priceInHexWei: '0x2e90edd000', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'rinkeby', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 50, + safeLowWait: 13.2, + fast: 100, + fastWait: 6.6, + fastest: 200, + fastestWait: 1.0, + }, + }, + }, + }, + { + expectedResult: [ + { + feeInSecondaryCurrency: '$2.68', + feeInPrimaryCurrency: '0.00105 ETH', + labelKey: 'slow', + priceInHexWei: '0xba43b7400', + }, + { + feeInSecondaryCurrency: '$5.37', + feeInPrimaryCurrency: '0.0021 ETH', + labelKey: 'average', + priceInHexWei: '0x174876e800', + }, + { + feeInSecondaryCurrency: '$10.74', + feeInPrimaryCurrency: '0.0042 ETH', + labelKey: 'fast', + priceInHexWei: '0x2e90edd000', + }, + ], + mockState: { + metamask: { + conversionRate: 2557.1, + currentCurrency: 'usd', + send: { + gasLimit: '0x5208', + }, + preferences: { + showFiatInTestnets: true, + }, + provider: { + type: 'mainnet', + }, + }, + gas: { + basicEstimates: { + blockTime: 14.16326530612245, + safeLow: 50, + safeLowWait: 13.2, + fast: 100, + fastWait: 6.6, + fastest: 200, + fastestWait: 1.0, + }, + }, + }, + }, + ] + it('should return renderable data about basic estimates appropriate for buttons with less info', () => { + tests.forEach(test => { + assert.deepEqual( + getRenderableEstimateDataForSmallButtonsFromGWEI(test.mockState), + test.expectedResult + ) + }) + }) + + }) + +}) diff --git a/ui/app/selectors.js b/ui/app/selectors/selectors.js index 7209f19d1..bea2cea33 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors/selectors.js @@ -1,12 +1,13 @@ -const abi = require('human-standard-token-abi') +import { NETWORK_TYPES } from '../helpers/constants/common' +import { stripHexPrefix } from 'ethereumjs-util' +const abi = require('human-standard-token-abi') import { transactionsSelector, -} from './selectors/transactions' - +} from './transactions' const { multiplyCurrencies, -} = require('./conversion-util') +} = require('../helpers/utils/conversion-util') const selectors = { getSelectedAddress, @@ -30,17 +31,75 @@ const selectors = { getSendAmount, getSelectedTokenToFiatRate, getSelectedTokenContract, - autoAddToBetaUI, getSendMaxModeState, getCurrentViewContext, getTotalUnapprovedCount, preferencesSelector, + getMetaMaskAccounts, + getCurrentEthBalance, + getNetworkIdentifier, + isBalanceCached, + getAdvancedInlineGasShown, + getIsMainnet, + getCurrentNetworkId, + getSelectedAsset, + getCurrentKeyring, + getAccountType, + getNumberOfAccounts, + getNumberOfTokens, + isEthereumNetwork, } module.exports = selectors +function getNetworkIdentifier (state) { + const { metamask: { provider: { type, nickname, rpcTarget } } } = state + + return nickname || rpcTarget || type +} + +function getCurrentKeyring (state) { + const identity = getSelectedIdentity(state) + + if (!identity) { + return null + } + + const simpleAddress = stripHexPrefix(identity.address).toLowerCase() + + const keyring = state.metamask.keyrings.find((kr) => { + return kr.accounts.includes(simpleAddress) || + kr.accounts.includes(identity.address) + }) + + return keyring +} + +function getAccountType (state) { + const currentKeyring = getCurrentKeyring(state) + const type = currentKeyring && currentKeyring.type + + switch (type) { + case 'Trezor Hardware': + case 'Ledger Hardware': + return 'hardware' + case 'Simple Key Pair': + return 'imported' + default: + return 'default' + } +} + +function getSelectedAsset (state) { + return getSelectedToken(state) || 'ETH' +} + +function getCurrentNetworkId (state) { + return state.metamask.network +} + function getSelectedAddress (state) { - const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0] + const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0] return selectedAddress } @@ -52,8 +111,50 @@ function getSelectedIdentity (state) { return identities[selectedAddress] } +function getNumberOfAccounts (state) { + return Object.keys(state.metamask.accounts).length +} + +function getNumberOfTokens (state) { + const tokens = state.metamask.tokens + return tokens ? tokens.length : 0 +} + +function getMetaMaskAccounts (state) { + const currentAccounts = state.metamask.accounts + const cachedBalances = state.metamask.cachedBalances[state.metamask.network] + const selectedAccounts = {} + + Object.keys(currentAccounts).forEach(accountID => { + const account = currentAccounts[accountID] + if (account && account.balance === null || account.balance === undefined) { + selectedAccounts[accountID] = { + ...account, + balance: cachedBalances && cachedBalances[accountID], + } + } else { + selectedAccounts[accountID] = account + } + }) + return selectedAccounts +} + +function isBalanceCached (state) { + const selectedAccountBalance = state.metamask.accounts[getSelectedAddress(state)].balance + const cachedBalance = getSelectedAccountCachedBalance(state) + + return Boolean(!selectedAccountBalance && cachedBalance) +} + +function getSelectedAccountCachedBalance (state) { + const cachedBalances = state.metamask.cachedBalances[state.metamask.network] + const selectedAddress = getSelectedAddress(state) + + return cachedBalances && cachedBalances[selectedAddress] +} + function getSelectedAccount (state) { - const accounts = state.metamask.accounts + const accounts = getMetaMaskAccounts(state) const selectedAddress = getSelectedAddress(state) return accounts[selectedAddress] @@ -101,10 +202,8 @@ function getAddressBook (state) { } function accountsWithSendEtherInfoSelector (state) { - const { - accounts, - identities, - } = state.metamask + const accounts = getMetaMaskAccounts(state) + const { identities } = state.metamask const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { return Object.assign({}, account, identities[key]) @@ -120,6 +219,10 @@ function getCurrentAccountWithSendEtherInfo (state) { return accounts.find(({ address }) => address === currentAddress) } +function getCurrentEthBalance (state) { + return getCurrentAccountWithSendEtherInfo(state).balance +} + function getGasIsLoading (state) { return state.appState.gasIsLoading } @@ -168,23 +271,6 @@ function getSelectedTokenContract (state) { : 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 @@ -202,6 +288,26 @@ function getTotalUnapprovedCount ({ metamask }) { unapprovedTypedMessagesCount } +function getIsMainnet (state) { + const networkType = getNetworkIdentifier(state) + return networkType === NETWORK_TYPES.MAINNET +} + +function isEthereumNetwork (state) { + const networkType = getNetworkIdentifier(state) + const { + KOVAN, + MAINNET, + RINKEBY, + ROPSTEN, + } = NETWORK_TYPES + return [ KOVAN, MAINNET, RINKEBY, ROPSTEN].includes(type => type === networkType) +} + function preferencesSelector ({ metamask }) { return metamask.preferences } + +function getAdvancedInlineGasShown (state) { + return Boolean(state.metamask.featureFlags.advancedInlineGas) +} diff --git a/ui/app/selectors/transactions.js b/ui/app/selectors/transactions.js index 479002794..b1d27b333 100644 --- a/ui/app/selectors/transactions.js +++ b/ui/app/selectors/transactions.js @@ -1,16 +1,44 @@ import { createSelector } from 'reselect' -import { valuesFor } from '../util' import { UNAPPROVED_STATUS, APPROVED_STATUS, SUBMITTED_STATUS, -} from '../constants/transactions' + CONFIRMED_STATUS, +} from '../helpers/constants/transactions' +import { + TRANSACTION_TYPE_CANCEL, + TRANSACTION_TYPE_RETRY, +} from '../../../app/scripts/controllers/transactions/enums' +import { hexToDecimal } from '../helpers/utils/conversions.util' import { selectedTokenAddressSelector } from './tokens' +import txHelper from '../../lib/tx-helper' export const shapeShiftTxListSelector = state => state.metamask.shapeShiftTxList export const unapprovedMsgsSelector = state => state.metamask.unapprovedMsgs export const selectedAddressTxListSelector = state => state.metamask.selectedAddressTxList +export const unapprovedPersonalMsgsSelector = state => state.metamask.unapprovedPersonalMsgs +export const unapprovedTypedMessagesSelector = state => state.metamask.unapprovedTypedMessages +export const networkSelector = state => state.metamask.network + +export const unapprovedMessagesSelector = createSelector( + unapprovedMsgsSelector, + unapprovedPersonalMsgsSelector, + unapprovedTypedMessagesSelector, + networkSelector, + ( + unapprovedMsgs = {}, + unapprovedPersonalMsgs = {}, + unapprovedTypedMessages = {}, + network + ) => txHelper( + {}, + unapprovedMsgs, + unapprovedPersonalMsgs, + unapprovedTypedMessages, + network + ) || [] +) const pendingStatusHash = { [UNAPPROVED_STATUS]: true, @@ -18,14 +46,18 @@ const pendingStatusHash = { [SUBMITTED_STATUS]: true, } +const priorityStatusHash = { + ...pendingStatusHash, + [CONFIRMED_STATUS]: true, +} + export const transactionsSelector = createSelector( selectedTokenAddressSelector, - unapprovedMsgsSelector, + unapprovedMessagesSelector, shapeShiftTxListSelector, selectedAddressTxListSelector, - (selectedTokenAddress, unapprovedMsgs = {}, shapeShiftTxList = [], transactions = []) => { - const unapprovedMsgsList = valuesFor(unapprovedMsgs) - const txsToRender = transactions.concat(unapprovedMsgsList, shapeShiftTxList) + (selectedTokenAddress, unapprovedMessages = [], shapeShiftTxList = [], transactions = []) => { + const txsToRender = transactions.concat(unapprovedMessages, shapeShiftTxList) return selectedTokenAddress ? txsToRender @@ -36,23 +68,219 @@ export const transactionsSelector = createSelector( } ) -export const pendingTransactionsSelector = createSelector( +/** + * @name insertOrderedNonce + * @private + * @description Inserts (mutates) a nonce into an array of ordered nonces, sorted in ascending + * order. + * @param {string[]} nonces - Array of nonce strings in hex + * @param {string} nonceToInsert - Nonce string in hex to be inserted into the array of nonces. + * @returns {string[]} + */ +const insertOrderedNonce = (nonces, nonceToInsert) => { + let insertIndex = nonces.length + + for (let i = 0; i < nonces.length; i++) { + const nonce = nonces[i] + + if (Number(hexToDecimal(nonce)) > Number(hexToDecimal(nonceToInsert))) { + insertIndex = i + break + } + } + + nonces.splice(insertIndex, 0, nonceToInsert) +} + +/** + * @name insertTransactionByTime + * @private + * @description Inserts (mutates) a transaction object into an array of ordered transactions, sorted + * in ascending order by time. + * @param {Object[]} transactions - Array of transaction objects. + * @param {Object} transaction - Transaction object to be inserted into the array of transactions. + * @returns {Object[]} + */ +const insertTransactionByTime = (transactions, transaction) => { + const { time } = transaction + + let insertIndex = transactions.length + + for (let i = 0; i < transactions.length; i++) { + const tx = transactions[i] + + if (tx.time > time) { + insertIndex = i + break + } + } + + transactions.splice(insertIndex, 0, transaction) +} + +/** + * Contains transactions and properties associated with those transactions of the same nonce. + * @typedef {Object} transactionGroup + * @property {string} nonce - The nonce that the transactions within this transactionGroup share. + * @property {Object[]} transactions - An array of transaction (txMeta) objects. + * @property {Object} initialTransaction - The transaction (txMeta) with the lowest "time". + * @property {Object} primaryTransaction - Either the latest transaction or the confirmed + * transaction. + * @property {boolean} hasRetried - True if a transaction in the group was a retry transaction. + * @property {boolean} hasCancelled - True if a transaction in the group was a cancel transaction. + */ + +/** + * @name insertTransactionGroupByTime + * @private + * @description Inserts (mutates) a transactionGroup object into an array of ordered + * transactionGroups, sorted in ascending order by nonce. + * @param {transactionGroup[]} transactionGroups - Array of transactionGroup objects. + * @param {transactionGroup} transactionGroup - transactionGroup object to be inserted into the + * array of transactionGroups. + */ +const insertTransactionGroupByTime = (transactionGroups, transactionGroup) => { + const { primaryTransaction: { time: groupToInsertTime } = {} } = transactionGroup + + let insertIndex = transactionGroups.length + + for (let i = 0; i < transactionGroups.length; i++) { + const txGroup = transactionGroups[i] + const { primaryTransaction: { time } = {} } = txGroup + + if (time > groupToInsertTime) { + insertIndex = i + break + } + } + + transactionGroups.splice(insertIndex, 0, transactionGroup) +} + +/** + * @name mergeShapeshiftTransactionGroups + * @private + * @description Inserts (mutates) shapeshift transactionGroups into an array of nonce-ordered + * transactionGroups by time. Shapeshift transactionGroups need to be sorted by time within the list + * of transactions as they do not have nonces. + * @param {transactionGroup[]} orderedTransactionGroups - Array of transactionGroups ordered by + * nonce. + * @param {transactionGroup[]} shapeshiftTransactionGroups - Array of shapeshift transactionGroups + */ +const mergeShapeshiftTransactionGroups = (orderedTransactionGroups, shapeshiftTransactionGroups) => { + shapeshiftTransactionGroups.forEach(shapeshiftGroup => { + insertTransactionGroupByTime(orderedTransactionGroups, shapeshiftGroup) + }) +} + +/** + * @name nonceSortedTransactionsSelector + * @description Returns an array of transactionGroups sorted by nonce in ascending order. + * @returns {transactionGroup[]} + */ +export const nonceSortedTransactionsSelector = createSelector( transactionsSelector, + (transactions = []) => { + const unapprovedTransactionGroups = [] + const shapeshiftTransactionGroups = [] + const orderedNonces = [] + const nonceToTransactionsMap = {} + + transactions.forEach(transaction => { + const { txParams: { nonce } = {}, status, type, time: txTime, key } = transaction + + if (typeof nonce === 'undefined') { + const transactionGroup = { + transactions: [transaction], + initialTransaction: transaction, + primaryTransaction: transaction, + hasRetried: false, + hasCancelled: false, + } + + if (key === 'shapeshift') { + shapeshiftTransactionGroups.push(transactionGroup) + } else { + insertTransactionGroupByTime(unapprovedTransactionGroups, transactionGroup) + } + } else if (nonce in nonceToTransactionsMap) { + const nonceProps = nonceToTransactionsMap[nonce] + insertTransactionByTime(nonceProps.transactions, transaction) + + if (status in priorityStatusHash) { + const { primaryTransaction: { time: primaryTxTime = 0 } = {} } = nonceProps + + if (status === CONFIRMED_STATUS || txTime > primaryTxTime) { + nonceProps.primaryTransaction = transaction + } + } + + const { initialTransaction: { time: initialTxTime = 0 } = {} } = nonceProps + + // Used to display the transaction action, since we don't want to overwrite the action if + // it was replaced with a cancel attempt transaction. + if (txTime < initialTxTime) { + nonceProps.initialTransaction = transaction + } + + if (type === TRANSACTION_TYPE_RETRY) { + nonceProps.hasRetried = true + } + + if (type === TRANSACTION_TYPE_CANCEL) { + nonceProps.hasCancelled = true + } + } else { + nonceToTransactionsMap[nonce] = { + nonce, + transactions: [transaction], + initialTransaction: transaction, + primaryTransaction: transaction, + hasRetried: transaction.type === TRANSACTION_TYPE_RETRY, + hasCancelled: transaction.type === TRANSACTION_TYPE_CANCEL, + } + + insertOrderedNonce(orderedNonces, nonce) + } + }) + + const orderedTransactionGroups = orderedNonces.map(nonce => nonceToTransactionsMap[nonce]) + mergeShapeshiftTransactionGroups(orderedTransactionGroups, shapeshiftTransactionGroups) + return unapprovedTransactionGroups.concat(orderedTransactionGroups) + } +) + +/** + * @name nonceSortedPendingTransactionsSelector + * @description Returns an array of transactionGroups where transactions are still pending sorted by + * nonce in descending order. + * @returns {transactionGroup[]} + */ +export const nonceSortedPendingTransactionsSelector = createSelector( + nonceSortedTransactionsSelector, (transactions = []) => ( - transactions.filter(transaction => transaction.status in pendingStatusHash).reverse() + transactions.filter(({ primaryTransaction }) => primaryTransaction.status in pendingStatusHash) ) ) -export const submittedPendingTransactionsSelector = createSelector( - transactionsSelector, +/** + * @name nonceSortedCompletedTransactionsSelector + * @description Returns an array of transactionGroups where transactions are confirmed sorted by + * nonce in descending order. + * @returns {transactionGroup[]} + */ +export const nonceSortedCompletedTransactionsSelector = createSelector( + nonceSortedTransactionsSelector, (transactions = []) => ( - transactions.filter(transaction => transaction.status === SUBMITTED_STATUS) + transactions + .filter(({ primaryTransaction }) => !(primaryTransaction.status in pendingStatusHash)) + .reverse() ) ) -export const completedTransactionsSelector = createSelector( +export const submittedPendingTransactionsSelector = createSelector( transactionsSelector, (transactions = []) => ( - transactions.filter(transaction => !(transaction.status in pendingStatusHash)) + transactions.filter(transaction => transaction.status === SUBMITTED_STATUS) ) ) diff --git a/ui/app/actions.js b/ui/app/store/actions.js index 501fef76d..b2aa28c93 100644 --- a/ui/app/actions.js +++ b/ui/app/store/actions.js @@ -1,18 +1,18 @@ const abi = require('human-standard-token-abi') const pify = require('pify') -const getBuyEthUrl = require('../../app/scripts/lib/buy-eth-url') -const { getTokenAddressFromTokenObject } = require('./util') +const getBuyEthUrl = require('../../../app/scripts/lib/buy-eth-url') +const { getTokenAddressFromTokenObject } = require('../helpers/utils/util') const { - calcGasTotal, calcTokenBalance, estimateGas, -} = require('./components/send/send.utils') +} = require('../components/app/send/send.utils') const ethUtil = require('ethereumjs-util') -const { fetchLocale } = require('../i18n-helper') +const { fetchLocale } = require('../helpers/utils/i18n-helper') const log = require('loglevel') -const { ENVIRONMENT_TYPE_NOTIFICATION } = require('../../app/scripts/lib/enums') -const { hasUnconfirmedTransactions } = require('./helpers/confirm-transaction/util') -const WebcamUtils = require('../lib/webcam-utils') +const { ENVIRONMENT_TYPE_NOTIFICATION } = require('../../../app/scripts/lib/enums') +const { hasUnconfirmedTransactions } = require('../helpers/utils/confirm-tx.util') +const gasDuck = require('../ducks/gas/gas.duck') +const WebcamUtils = require('../../lib/webcam-utils') var actions = { _setBackgroundConnection: _setBackgroundConnection, @@ -63,6 +63,7 @@ var actions = { CREATE_NEW_VAULT_IN_PROGRESS: 'CREATE_NEW_VAULT_IN_PROGRESS', SHOW_CREATE_VAULT: 'SHOW_CREATE_VAULT', SHOW_RESTORE_VAULT: 'SHOW_RESTORE_VAULT', + fetchInfoToSync, FORGOT_PASSWORD: 'FORGOT_PASSWORD', forgotPassword: forgotPassword, markPasswordForgotten, @@ -85,6 +86,8 @@ var actions = { createNewVaultAndKeychain: createNewVaultAndKeychain, createNewVaultAndRestore: createNewVaultAndRestore, createNewVaultInProgress: createNewVaultInProgress, + createNewVaultAndGetSeedPhrase, + unlockAndGetSeedPhrase, addNewKeyring, importNewAccount, addNewAccount, @@ -189,6 +192,7 @@ var actions = { UPDATE_SEND_AMOUNT: 'UPDATE_SEND_AMOUNT', UPDATE_SEND_MEMO: 'UPDATE_SEND_MEMO', UPDATE_SEND_ERRORS: 'UPDATE_SEND_ERRORS', + UPDATE_SEND_WARNINGS: 'UPDATE_SEND_WARNINGS', UPDATE_MAX_MODE: 'UPDATE_MAX_MODE', UPDATE_SEND: 'UPDATE_SEND', CLEAR_SEND: 'CLEAR_SEND', @@ -202,7 +206,6 @@ var actions = { setGasTotal, setSendTokenBalance, updateSendTokenBalance, - updateSendFrom, updateSendHexData, updateSendTo, updateSendAmount, @@ -210,6 +213,7 @@ var actions = { setMaxModeTo, updateSend, updateSendErrors, + updateSendWarnings, clearSend, setSelectedAddress, gasLoadingStarted, @@ -226,6 +230,7 @@ var actions = { SET_RPC_TARGET: 'SET_RPC_TARGET', SET_DEFAULT_RPC_TARGET: 'SET_DEFAULT_RPC_TARGET', SET_PROVIDER_TYPE: 'SET_PROVIDER_TYPE', + SET_PREVIOUS_PROVIDER: 'SET_PREVIOUS_PROVIDER', showConfigPage, SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE', SHOW_ADD_SUGGESTED_TOKEN_PAGE: 'SHOW_ADD_SUGGESTED_TOKEN_PAGE', @@ -236,7 +241,9 @@ var actions = { removeToken, updateTokens, removeSuggestedTokens, + addKnownMethodData, UPDATE_TOKENS: 'UPDATE_TOKENS', + updateAndSetCustomRpc: updateAndSetCustomRpc, setRpcTarget: setRpcTarget, delRpcTarget: delRpcTarget, setProviderType: setProviderType, @@ -293,6 +300,11 @@ var actions = { SET_USE_BLOCKIE: 'SET_USE_BLOCKIE', setUseBlockie, + SET_PARTICIPATE_IN_METAMETRICS: 'SET_PARTICIPATE_IN_METAMETRICS', + SET_METAMETRICS_SEND_COUNT: 'SET_METAMETRICS_SEND_COUNT', + setParticipateInMetaMetrics, + setMetaMetricsSendCount, + // locale SET_CURRENT_LOCALE: 'SET_CURRENT_LOCALE', SET_LOCALE_MESSAGES: 'SET_LOCALE_MESSAGES', @@ -310,6 +322,17 @@ var actions = { updatePreferences, UPDATE_PREFERENCES: 'UPDATE_PREFERENCES', setUseNativeCurrencyAsPrimaryCurrencyPreference, + setShowFiatConversionOnTestnetsPreference, + + // Migration of users to new UI + setCompletedUiMigration, + completeUiMigration, + COMPLETE_UI_MIGRATION: 'COMPLETE_UI_MIGRATION', + + // Onboarding + setCompletedOnboarding, + completeOnboarding, + COMPLETE_ONBOARDING: 'COMPLETE_ONBOARDING', setMouseUserState, SET_MOUSE_USER_STATE: 'SET_MOUSE_USER_STATE', @@ -325,9 +348,14 @@ var actions = { clearPendingTokens, createCancelTransaction, + createSpeedUpTransaction, + approveProviderRequest, rejectProviderRequest, clearApprovedOrigins, + + setFirstTimeFlowType, + SET_FIRST_TIME_FLOW_TYPE: 'SET_FIRST_TIME_FLOW_TYPE', } module.exports = actions @@ -448,6 +476,7 @@ function createNewVaultAndRestore (password, seed) { .catch(err => { dispatch(actions.displayWarning(err.message)) dispatch(actions.hideLoadingIndication()) + return Promise.reject(err) }) } } @@ -482,12 +511,71 @@ function createNewVaultAndKeychain (password) { } } +function createNewVaultAndGetSeedPhrase (password) { + return async dispatch => { + dispatch(actions.showLoadingIndication()) + + try { + await createNewVault(password) + const seedWords = await verifySeedPhrase() + dispatch(actions.hideLoadingIndication()) + return seedWords + } catch (error) { + dispatch(actions.hideLoadingIndication()) + dispatch(actions.displayWarning(error.message)) + throw new Error(error.message) + } + } +} + +function unlockAndGetSeedPhrase (password) { + return async dispatch => { + dispatch(actions.showLoadingIndication()) + + try { + await submitPassword(password) + const seedWords = await verifySeedPhrase() + await forceUpdateMetamaskState(dispatch) + dispatch(actions.hideLoadingIndication()) + return seedWords + } catch (error) { + dispatch(actions.hideLoadingIndication()) + dispatch(actions.displayWarning(error.message)) + throw new Error(error.message) + } + } +} + function revealSeedConfirmation () { return { type: this.REVEAL_SEED_CONFIRMATION, } } +function submitPassword (password) { + return new Promise((resolve, reject) => { + background.submitPassword(password, error => { + if (error) { + return reject(error) + } + + resolve() + }) + }) +} + +function createNewVault (password) { + return new Promise((resolve, reject) => { + background.createNewVaultAndKeychain(password, error => { + if (error) { + return reject(error) + } + + resolve(true) + }) + }) +} + function verifyPassword (password) { return new Promise((resolve, reject) => { background.submitPassword(password, error => { @@ -557,6 +645,21 @@ function requestRevealSeedWords (password) { } } +function fetchInfoToSync () { + return dispatch => { + log.debug(`background.fetchInfoToSync`) + return new Promise((resolve, reject) => { + background.fetchInfoToSync((err, result) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + resolve(result) + }) + }) + } +} + function resetAccount () { return dispatch => { dispatch(actions.showLoadingIndication()) @@ -799,6 +902,7 @@ function signMsg (msgData) { log.debug('action - signMsg') return (dispatch, getState) => { dispatch(actions.showLoadingIndication()) + window.onbeforeunload = null return new Promise((resolve, reject) => { log.debug(`actions calling background.signMessage`) @@ -830,7 +934,7 @@ function signPersonalMsg (msgData) { log.debug('action - signPersonalMsg') return (dispatch, getState) => { dispatch(actions.showLoadingIndication()) - + window.onbeforeunload = null return new Promise((resolve, reject) => { log.debug(`actions calling background.signPersonalMessage`) background.signPersonalMessage(msgData, (err, newState) => { @@ -861,7 +965,7 @@ function signTypedMsg (msgData) { log.debug('action - signTypedMsg') return (dispatch, getState) => { dispatch(actions.showLoadingIndication()) - + window.onbeforeunload = null return new Promise((resolve, reject) => { log.debug(`actions calling background.signTypedMessage`) background.signTypedMessage(msgData, (err, newState) => { @@ -921,6 +1025,7 @@ function setGasTotal (gasTotal) { } function updateGasData ({ + gasPrice, blockGasLimit, recentBlocks, selectedAddress, @@ -931,34 +1036,19 @@ function updateGasData ({ }) { return (dispatch) => { dispatch(actions.gasLoadingStarted()) - return new Promise((resolve, reject) => { - background.getGasPrice((err, data) => { - if (err) return reject(err) - return resolve(data) - }) - }) - .then(estimateGasPrice => { - return Promise.all([ - Promise.resolve(estimateGasPrice), - estimateGas({ - estimateGasMethod: background.estimateGas, - blockGasLimit, - selectedAddress, - selectedToken, - to, - value, - estimateGasPrice, - data, - }), - ]) - }) - .then(([gasPrice, gas]) => { - dispatch(actions.setGasPrice(gasPrice)) + return estimateGas({ + estimateGasMethod: background.estimateGas, + blockGasLimit, + selectedAddress, + selectedToken, + to, + value, + estimateGasPrice: gasPrice, + data, + }) + .then(gas => { dispatch(actions.setGasLimit(gas)) - return calcGasTotal(gas, gasPrice) - }) - .then((gasEstimate) => { - dispatch(actions.setGasTotal(gasEstimate)) + dispatch(gasDuck.setCustomGasLimit(gas)) dispatch(updateSendErrors({ gasLoadingError: null })) dispatch(actions.gasLoadingFinished()) }) @@ -995,7 +1085,7 @@ function updateSendTokenBalance ({ .then(usersToken => { if (usersToken) { const newTokenBalance = calcTokenBalance({ selectedToken, usersToken }) - dispatch(setSendTokenBalance(newTokenBalance.toString(10))) + dispatch(setSendTokenBalance(newTokenBalance)) } }) .catch(err => { @@ -1012,17 +1102,17 @@ function updateSendErrors (errorObject) { } } -function setSendTokenBalance (tokenBalance) { +function updateSendWarnings (warningObject) { return { - type: actions.UPDATE_SEND_TOKEN_BALANCE, - value: tokenBalance, + type: actions.UPDATE_SEND_WARNINGS, + value: warningObject, } } -function updateSendFrom (from) { +function setSendTokenBalance (tokenBalance) { return { - type: actions.UPDATE_SEND_FROM, - value: from, + type: actions.UPDATE_SEND_TOKEN_BALANCE, + value: tokenBalance, } } @@ -1079,6 +1169,7 @@ function sendTx (txData) { log.info(`actions - sendTx: ${JSON.stringify(txData.txParams)}`) return (dispatch, getState) => { log.debug(`actions calling background.approveTransaction`) + window.onbeforeunload = null background.approveTransaction(txData.id, (err) => { if (err) { dispatch(actions.txError(err)) @@ -1141,7 +1232,7 @@ function updateAndApproveTx (txData) { return (dispatch, getState) => { log.debug(`actions calling background.updateAndApproveTx`) dispatch(actions.showLoadingIndication()) - + window.onbeforeunload = null return new Promise((resolve, reject) => { background.updateAndApproveTransaction(txData, err => { dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) @@ -1203,7 +1294,7 @@ function txError (err) { function cancelMsg (msgData) { return (dispatch, getState) => { dispatch(actions.showLoadingIndication()) - + window.onbeforeunload = null return new Promise((resolve, reject) => { log.debug(`background.cancelMessage`) background.cancelMessage(msgData.id, (err, newState) => { @@ -1230,7 +1321,7 @@ function cancelMsg (msgData) { function cancelPersonalMsg (msgData) { return (dispatch, getState) => { dispatch(actions.showLoadingIndication()) - + window.onbeforeunload = null return new Promise((resolve, reject) => { const id = msgData.id background.cancelPersonalMessage(id, (err, newState) => { @@ -1257,7 +1348,7 @@ function cancelPersonalMsg (msgData) { function cancelTypedMsg (msgData) { return (dispatch, getState) => { dispatch(actions.showLoadingIndication()) - + window.onbeforeunload = null return new Promise((resolve, reject) => { const id = msgData.id background.cancelTypedMessage(id, (err, newState) => { @@ -1285,7 +1376,7 @@ function cancelTx (txData) { return (dispatch, getState) => { log.debug(`background.cancelTransaction`) dispatch(actions.showLoadingIndication()) - + window.onbeforeunload = null return new Promise((resolve, reject) => { background.cancelTransaction(txData.id, err => { if (err) { @@ -1319,6 +1410,7 @@ function cancelTx (txData) { */ function cancelTxs (txDataList) { return async (dispatch, getState) => { + window.onbeforeunload = null dispatch(actions.showLoadingIndication()) const txIds = txDataList.map(({id}) => id) const cancellations = txIds.map((id) => new Promise((resolve, reject) => { @@ -1509,7 +1601,6 @@ const backgroundSetLocked = () => { if (error) { return reject(error) } - resolve() }) }) @@ -1722,6 +1813,7 @@ function addTokens (tokens) { function removeSuggestedTokens () { return (dispatch) => { dispatch(actions.showLoadingIndication()) + window.onbeforeunload = null return new Promise((resolve, reject) => { background.removeSuggestedTokens((err, suggestedTokens) => { dispatch(actions.hideLoadingIndication()) @@ -1740,6 +1832,12 @@ function removeSuggestedTokens () { } } +function addKnownMethodData (fourBytePrefix, methodData) { + return (dispatch) => { + background.addKnownMethodData(fourBytePrefix, methodData) + } +} + function updateTokens (newTokens) { return { type: actions.UPDATE_TOKENS, @@ -1805,13 +1903,13 @@ function markAccountsFound () { return callBackgroundThenUpdate(background.markAccountsFound) } -function retryTransaction (txId) { +function retryTransaction (txId, gasPrice) { log.debug(`background.retryTransaction`) let newTxId - return (dispatch) => { + return dispatch => { return new Promise((resolve, reject) => { - background.retryTransaction(txId, (err, newState) => { + background.retryTransaction(txId, gasPrice, (err, newState) => { if (err) { dispatch(actions.displayWarning(err.message)) reject(err) @@ -1851,18 +1949,42 @@ function createCancelTransaction (txId, customGasPrice) { } } +function createSpeedUpTransaction (txId, customGasPrice) { + log.debug('background.createSpeedUpTransaction') + let newTx + + return dispatch => { + return new Promise((resolve, reject) => { + background.createSpeedUpTransaction(txId, customGasPrice, (err, newState) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + + const { selectedAddressTxList } = newState + newTx = selectedAddressTxList[selectedAddressTxList.length - 1] + resolve(newState) + }) + }) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => newTx) + } +} + // // config // function setProviderType (type) { - return (dispatch) => { + return (dispatch, getState) => { + const { type: currentProviderType } = getState().metamask.provider log.debug(`background.setProviderType`, type) background.setProviderType(type, (err, result) => { if (err) { log.error(err) return dispatch(actions.displayWarning('Had a problem changing networks!')) } + dispatch(setPreviousProvider(currentProviderType)) dispatch(actions.updateProviderType(type)) dispatch(actions.setSelectedToken()) }) @@ -1877,10 +1999,33 @@ function updateProviderType (type) { } } -function setRpcTarget (newRpc, chainId, ticker = 'ETH', nickname = '') { +function setPreviousProvider (type) { + return { + type: actions.SET_PREVIOUS_PROVIDER, + value: type, + } +} + +function updateAndSetCustomRpc (newRpc, chainId, ticker = 'ETH', nickname) { + return (dispatch) => { + log.debug(`background.updateAndSetCustomRpc: ${newRpc} ${chainId} ${ticker} ${nickname}`) + background.updateAndSetCustomRpc(newRpc, chainId, ticker, nickname || newRpc, (err, result) => { + if (err) { + log.error(err) + return dispatch(actions.displayWarning('Had a problem changing networks!')) + } + dispatch({ + type: actions.SET_RPC_TARGET, + value: newRpc, + }) + }) + } +} + +function setRpcTarget (newRpc, chainId, ticker = 'ETH', nickname) { return (dispatch) => { log.debug(`background.setRpcTarget: ${newRpc} ${chainId} ${ticker} ${nickname}`) - background.setCustomRpc(newRpc, chainId, ticker, nickname, (err, result) => { + background.setCustomRpc(newRpc, chainId, ticker, nickname || newRpc, (err, result) => { if (err) { log.error(err) return dispatch(actions.displayWarning('Had a problem changing networks!')) @@ -1951,12 +2096,13 @@ function hideModal (payload) { } } -function showSidebar ({ transitionName, type }) { +function showSidebar ({ transitionName, type, props }) { return { type: actions.SIDEBAR_OPEN, value: { transitionName, type, + props, }, } } @@ -2337,6 +2483,60 @@ function setUseNativeCurrencyAsPrimaryCurrencyPreference (value) { return setPreference('useNativeCurrencyAsPrimaryCurrency', value) } +function setShowFiatConversionOnTestnetsPreference (value) { + return setPreference('showFiatInTestnets', value) +} + +function setCompletedOnboarding () { + return dispatch => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.completeOnboarding(err => { + dispatch(actions.hideLoadingIndication()) + + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.completeOnboarding()) + resolve() + }) + }) + } +} + +function completeOnboarding () { + return { + type: actions.COMPLETE_ONBOARDING, + } +} + +function setCompletedUiMigration () { + return dispatch => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.completeUiMigration(err => { + dispatch(actions.hideLoadingIndication()) + + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.completeUiMigration()) + resolve() + }) + }) + } +} + +function completeUiMigration () { + return { + type: actions.COMPLETE_UI_MIGRATION, + } +} + function setNetworkNonce (networkNonce) { return { type: actions.SET_NETWORK_NONCE, @@ -2419,6 +2619,49 @@ function toggleAccountMenu () { } } +function setParticipateInMetaMetrics (val) { + return (dispatch) => { + log.debug(`background.setParticipateInMetaMetrics`) + return new Promise((resolve, reject) => { + background.setParticipateInMetaMetrics(val, (err, metaMetricsId) => { + log.debug(err) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch({ + type: actions.SET_PARTICIPATE_IN_METAMETRICS, + value: val, + }) + + resolve([val, metaMetricsId]) + }) + }) + } +} + +function setMetaMetricsSendCount (val) { + return (dispatch) => { + log.debug(`background.setMetaMetricsSendCount`) + return new Promise((resolve, reject) => { + background.setMetaMetricsSendCount(val, (err) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch({ + type: actions.SET_METAMETRICS_SEND_COUNT, + value: val, + }) + + resolve(val) + }) + }) + } +} + function setUseBlockie (val) { return (dispatch) => { dispatch(actions.showLoadingIndication()) @@ -2488,15 +2731,15 @@ function setPendingTokens (pendingTokens) { } } -function approveProviderRequest (origin) { +function approveProviderRequest (tabID) { return (dispatch) => { - background.approveProviderRequest(origin) + background.approveProviderRequest(tabID) } } -function rejectProviderRequest (origin) { +function rejectProviderRequest (tabID) { return (dispatch) => { - background.rejectProviderRequest(origin) + background.rejectProviderRequest(tabID) } } @@ -2505,3 +2748,18 @@ function clearApprovedOrigins () { background.clearApprovedOrigins() } } + +function setFirstTimeFlowType (type) { + return (dispatch) => { + log.debug(`background.setFirstTimeFlowType`) + background.setFirstTimeFlowType(type, (err) => { + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + }) + dispatch({ + type: actions.SET_FIRST_TIME_FLOW_TYPE, + value: type, + }) + } +} diff --git a/ui/app/store.js b/ui/app/store/store.js index feebbabc0..9f12f469e 100644 --- a/ui/app/store.js +++ b/ui/app/store/store.js @@ -1,7 +1,7 @@ const createStore = require('redux').createStore const applyMiddleware = require('redux').applyMiddleware const thunkMiddleware = require('redux-thunk').default -const rootReducer = require('./reducers') +const rootReducer = require('../ducks') const createLogger = require('redux-logger').createLogger global.METAMASK_DEBUG = process.env.METAMASK_DEBUG diff --git a/ui/app/welcome-screen.js b/ui/app/welcome-screen.js deleted file mode 100644 index 63512cd50..000000000 --- a/ui/app/welcome-screen.js +++ /dev/null @@ -1,83 +0,0 @@ -import EventEmitter from 'events' -import h from 'react-hyperscript' -import { Component } from 'react' -import PropTypes from 'prop-types' -import {connect} from 'react-redux' -import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' -import {closeWelcomeScreen} from './actions' -import Mascot from './components/mascot' -import { INITIALIZE_CREATE_PASSWORD_ROUTE } from './routes' - -class WelcomeScreen extends Component { - static propTypes = { - closeWelcomeScreen: PropTypes.func.isRequired, - welcomeScreenSeen: PropTypes.bool, - history: PropTypes.object, - t: PropTypes.func, - } - - static contextTypes = { - t: PropTypes.func, - } - - constructor (props) { - super(props) - this.animationEventEmitter = new EventEmitter() - } - - componentWillMount () { - const { history, welcomeScreenSeen } = this.props - - if (welcomeScreenSeen) { - history.push(INITIALIZE_CREATE_PASSWORD_ROUTE) - } - } - - initiateAccountCreation = () => { - this.props.closeWelcomeScreen() - this.props.history.push(INITIALIZE_CREATE_PASSWORD_ROUTE) - } - - render () { - return h('div.welcome-screen', [ - - h('div.welcome-screen__info', [ - - h(Mascot, { - animationEventEmitter: this.animationEventEmitter, - width: '225', - height: '225', - }), - - h('div.welcome-screen__info__header', this.context.t('welcomeBeta')), - - h('div.welcome-screen__info__copy', this.context.t('metamaskDescription')), - - h('div.welcome-screen__info__copy', this.context.t('holdEther')), - - h('button.welcome-screen__button', { - onClick: this.initiateAccountCreation, - }, this.context.t('continue')), - - ]), - - ]) - } -} - -const mapStateToProps = ({ metamask: { welcomeScreenSeen } }) => { - return { - welcomeScreenSeen, - } -} - -export default compose( - withRouter, - connect( - mapStateToProps, - dispatch => ({ - closeWelcomeScreen: () => dispatch(closeWelcomeScreen()), - }) - ) -)(WelcomeScreen) |