diff options
author | Chi Kei Chan <chikeichan@gmail.com> | 2019-03-22 07:03:30 +0800 |
---|---|---|
committer | Dan J Miller <danjm.com@gmail.com> | 2019-03-22 07:03:30 +0800 |
commit | 31175625b446cb5d18b17db23018bca8b14d280c (patch) | |
tree | f54e159883deef003fb281267025edf796eb8004 /ui | |
parent | 7287133e15fab22299e07704206e85bc855d1064 (diff) | |
download | tangerine-wallet-browser-31175625b446cb5d18b17db23018bca8b14d280c.tar tangerine-wallet-browser-31175625b446cb5d18b17db23018bca8b14d280c.tar.gz tangerine-wallet-browser-31175625b446cb5d18b17db23018bca8b14d280c.tar.bz2 tangerine-wallet-browser-31175625b446cb5d18b17db23018bca8b14d280c.tar.lz tangerine-wallet-browser-31175625b446cb5d18b17db23018bca8b14d280c.tar.xz tangerine-wallet-browser-31175625b446cb5d18b17db23018bca8b14d280c.tar.zst tangerine-wallet-browser-31175625b446cb5d18b17db23018bca8b14d280c.zip |
Folder restructure (#6304)
* Remove ui/app/keychains/
* Remove ui/app/img/ (unused images)
* Move conversion-util to helpers/utils/
* Move token-util to helpers/utils/
* Move /helpers/*.js inside /helpers/utils/
* Move util tests inside /helpers/utils/
* Renameand move confirm-transaction/util.js to helpers/utils/
* Move higher-order-components to helpers/higher-order-components/
* Move infura-conversion.json to helpers/constants/
* Move all utility functions to helpers/utils/
* Move pages directory to top-level
* Move all constants to helpers/constants/
* Move metametrics inside helpers/
* Move app and root inside pages/
* Move routes inside helpers/
* Re-organize ducks/
* Move reducers to ducks/
* Move selectors inside selectors/
* Move test out of test folder
* Move action, reducer, store inside store/
* Move ui components inside ui/
* Move UI components inside ui/
* Move connected components inside components/app/
* Move i18n-helper inside helpers/
* Fix unit tests
* Fix unit test
* Move pages components
* Rename routes component
* Move reducers to ducks/index
* Fix bad path in unit test
Diffstat (limited to 'ui')
962 files changed, 37181 insertions, 37612 deletions
diff --git a/ui/.gitignore b/ui/.gitignore deleted file mode 100644 index c6b1254b5..000000000 --- a/ui/.gitignore +++ /dev/null @@ -1,66 +0,0 @@ - -# Created by https://www.gitignore.io/api/osx,node - -### OSX ### -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon
- -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - - -### Node ### -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directory -# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git -node_modules - -# Optional npm cache directory -.npm - -# Optional REPL history -.node_repl_history - 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/actions.js b/ui/app/actions.js deleted file mode 100644 index d8363eba6..000000000 --- a/ui/app/actions.js +++ /dev/null @@ -1,2761 +0,0 @@ -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 { - calcTokenBalance, - estimateGas, -} = require('./components/send/send.utils') -const ethUtil = require('ethereumjs-util') -const { fetchLocale } = require('../i18n-helper') -const log = require('loglevel') -const { ENVIRONMENT_TYPE_NOTIFICATION } = require('../../app/scripts/lib/enums') -const { hasUnconfirmedTransactions } = require('./helpers/confirm-transaction/util') -const gasDuck = require('./ducks/gas.duck') -const WebcamUtils = require('../lib/webcam-utils') - -var actions = { - _setBackgroundConnection: _setBackgroundConnection, - - GO_HOME: 'GO_HOME', - goHome: goHome, - // modal state - MODAL_OPEN: 'UI_MODAL_OPEN', - MODAL_CLOSE: 'UI_MODAL_CLOSE', - showModal: showModal, - hideModal: hideModal, - // sidebar state - SIDEBAR_OPEN: 'UI_SIDEBAR_OPEN', - SIDEBAR_CLOSE: 'UI_SIDEBAR_CLOSE', - showSidebar: showSidebar, - hideSidebar: hideSidebar, - // sidebar state - ALERT_OPEN: 'UI_ALERT_OPEN', - ALERT_CLOSE: 'UI_ALERT_CLOSE', - showAlert: showAlert, - hideAlert: hideAlert, - QR_CODE_DETECTED: 'UI_QR_CODE_DETECTED', - qrCodeDetected, - // network dropdown open - NETWORK_DROPDOWN_OPEN: 'UI_NETWORK_DROPDOWN_OPEN', - NETWORK_DROPDOWN_CLOSE: 'UI_NETWORK_DROPDOWN_CLOSE', - showNetworkDropdown: showNetworkDropdown, - hideNetworkDropdown: hideNetworkDropdown, - // menu state/ - getNetworkStatus: 'getNetworkStatus', - // transition state - TRANSITION_FORWARD: 'TRANSITION_FORWARD', - TRANSITION_BACKWARD: 'TRANSITION_BACKWARD', - transitionForward, - transitionBackward, - // remote state - UPDATE_METAMASK_STATE: 'UPDATE_METAMASK_STATE', - updateMetamaskState: updateMetamaskState, - // notices - MARK_NOTICE_READ: 'MARK_NOTICE_READ', - markNoticeRead: markNoticeRead, - SHOW_NOTICE: 'SHOW_NOTICE', - showNotice: showNotice, - CLEAR_NOTICES: 'CLEAR_NOTICES', - clearNotices: clearNotices, - markAccountsFound, - // intialize screen - 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, - unMarkPasswordForgotten, - SHOW_INIT_MENU: 'SHOW_INIT_MENU', - SHOW_NEW_VAULT_SEED: 'SHOW_NEW_VAULT_SEED', - SHOW_INFO_PAGE: 'SHOW_INFO_PAGE', - SHOW_IMPORT_PAGE: 'SHOW_IMPORT_PAGE', - SHOW_NEW_ACCOUNT_PAGE: 'SHOW_NEW_ACCOUNT_PAGE', - SET_NEW_ACCOUNT_FORM: 'SET_NEW_ACCOUNT_FORM', - unlockMetamask: unlockMetamask, - unlockFailed: unlockFailed, - unlockSucceeded, - showCreateVault: showCreateVault, - showRestoreVault: showRestoreVault, - showInitializeMenu: showInitializeMenu, - showImportPage, - showNewAccountPage, - setNewAccountForm, - createNewVaultAndKeychain: createNewVaultAndKeychain, - createNewVaultAndRestore: createNewVaultAndRestore, - createNewVaultInProgress: createNewVaultInProgress, - createNewVaultAndGetSeedPhrase, - unlockAndGetSeedPhrase, - addNewKeyring, - importNewAccount, - addNewAccount, - connectHardware, - checkHardwareStatus, - forgetDevice, - unlockHardwareWalletAccount, - NEW_ACCOUNT_SCREEN: 'NEW_ACCOUNT_SCREEN', - navigateToNewAccountScreen, - resetAccount, - removeAccount, - showNewVaultSeed: showNewVaultSeed, - showInfoPage: showInfoPage, - CLOSE_WELCOME_SCREEN: 'CLOSE_WELCOME_SCREEN', - closeWelcomeScreen, - // seed recovery actions - REVEAL_SEED_CONFIRMATION: 'REVEAL_SEED_CONFIRMATION', - revealSeedConfirmation: revealSeedConfirmation, - requestRevealSeed: requestRevealSeed, - requestRevealSeedWords, - // unlock screen - UNLOCK_IN_PROGRESS: 'UNLOCK_IN_PROGRESS', - UNLOCK_FAILED: 'UNLOCK_FAILED', - UNLOCK_SUCCEEDED: 'UNLOCK_SUCCEEDED', - UNLOCK_METAMASK: 'UNLOCK_METAMASK', - LOCK_METAMASK: 'LOCK_METAMASK', - tryUnlockMetamask: tryUnlockMetamask, - lockMetamask: lockMetamask, - unlockInProgress: unlockInProgress, - // error handling - displayWarning: displayWarning, - DISPLAY_WARNING: 'DISPLAY_WARNING', - HIDE_WARNING: 'HIDE_WARNING', - hideWarning: hideWarning, - // accounts screen - SET_SELECTED_ACCOUNT: 'SET_SELECTED_ACCOUNT', - SET_SELECTED_TOKEN: 'SET_SELECTED_TOKEN', - setSelectedToken, - SHOW_ACCOUNT_DETAIL: 'SHOW_ACCOUNT_DETAIL', - SHOW_ACCOUNTS_PAGE: 'SHOW_ACCOUNTS_PAGE', - SHOW_CONF_TX_PAGE: 'SHOW_CONF_TX_PAGE', - SHOW_CONF_MSG_PAGE: 'SHOW_CONF_MSG_PAGE', - SET_CURRENT_FIAT: 'SET_CURRENT_FIAT', - showQrScanner, - setCurrentCurrency, - setCurrentAccountTab, - // account detail screen - SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', - showSendPage: showSendPage, - SHOW_SEND_TOKEN_PAGE: 'SHOW_SEND_TOKEN_PAGE', - showSendTokenPage, - ADD_TO_ADDRESS_BOOK: 'ADD_TO_ADDRESS_BOOK', - addToAddressBook: addToAddressBook, - REQUEST_ACCOUNT_EXPORT: 'REQUEST_ACCOUNT_EXPORT', - requestExportAccount: requestExportAccount, - EXPORT_ACCOUNT: 'EXPORT_ACCOUNT', - exportAccount: exportAccount, - SHOW_PRIVATE_KEY: 'SHOW_PRIVATE_KEY', - showPrivateKey: showPrivateKey, - exportAccountComplete, - SET_ACCOUNT_LABEL: 'SET_ACCOUNT_LABEL', - setAccountLabel, - updateNetworkNonce, - SET_NETWORK_NONCE: 'SET_NETWORK_NONCE', - // tx conf screen - COMPLETED_TX: 'COMPLETED_TX', - TRANSACTION_ERROR: 'TRANSACTION_ERROR', - NEXT_TX: 'NEXT_TX', - PREVIOUS_TX: 'PREV_TX', - EDIT_TX: 'EDIT_TX', - signMsg: signMsg, - cancelMsg: cancelMsg, - signPersonalMsg, - cancelPersonalMsg, - signTypedMsg, - cancelTypedMsg, - sendTx: sendTx, - signTx: signTx, - signTokenTx: signTokenTx, - updateTransaction, - updateAndApproveTx, - cancelTx: cancelTx, - cancelTxs, - completedTx: completedTx, - txError: txError, - nextTx: nextTx, - editTx, - previousTx: previousTx, - cancelAllTx: cancelAllTx, - viewPendingTx: viewPendingTx, - VIEW_PENDING_TX: 'VIEW_PENDING_TX', - updateTransactionParams, - UPDATE_TRANSACTION_PARAMS: 'UPDATE_TRANSACTION_PARAMS', - // send screen - UPDATE_GAS_LIMIT: 'UPDATE_GAS_LIMIT', - UPDATE_GAS_PRICE: 'UPDATE_GAS_PRICE', - UPDATE_GAS_TOTAL: 'UPDATE_GAS_TOTAL', - UPDATE_SEND_FROM: 'UPDATE_SEND_FROM', - UPDATE_SEND_HEX_DATA: 'UPDATE_SEND_HEX_DATA', - UPDATE_SEND_TOKEN_BALANCE: 'UPDATE_SEND_TOKEN_BALANCE', - UPDATE_SEND_TO: 'UPDATE_SEND_TO', - UPDATE_SEND_AMOUNT: 'UPDATE_SEND_AMOUNT', - UPDATE_SEND_MEMO: 'UPDATE_SEND_MEMO', - UPDATE_SEND_ERRORS: 'UPDATE_SEND_ERRORS', - UPDATE_SEND_WARNINGS: 'UPDATE_SEND_WARNINGS', - UPDATE_MAX_MODE: 'UPDATE_MAX_MODE', - UPDATE_SEND: 'UPDATE_SEND', - CLEAR_SEND: 'CLEAR_SEND', - OPEN_FROM_DROPDOWN: 'OPEN_FROM_DROPDOWN', - CLOSE_FROM_DROPDOWN: 'CLOSE_FROM_DROPDOWN', - GAS_LOADING_STARTED: 'GAS_LOADING_STARTED', - GAS_LOADING_FINISHED: 'GAS_LOADING_FINISHED', - setGasLimit, - setGasPrice, - updateGasData, - setGasTotal, - setSendTokenBalance, - updateSendTokenBalance, - updateSendHexData, - updateSendTo, - updateSendAmount, - updateSendMemo, - setMaxModeTo, - updateSend, - updateSendErrors, - updateSendWarnings, - clearSend, - setSelectedAddress, - gasLoadingStarted, - gasLoadingFinished, - // app messages - confirmSeedWords: confirmSeedWords, - showAccountDetail: showAccountDetail, - BACK_TO_ACCOUNT_DETAIL: 'BACK_TO_ACCOUNT_DETAIL', - backToAccountDetail: backToAccountDetail, - showAccountsPage: showAccountsPage, - showConfTxPage: showConfTxPage, - // config screen - SHOW_CONFIG_PAGE: 'SHOW_CONFIG_PAGE', - 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', - showAddTokenPage, - showAddSuggestedTokenPage, - addToken, - addTokens, - removeToken, - updateTokens, - removeSuggestedTokens, - addKnownMethodData, - UPDATE_TOKENS: 'UPDATE_TOKENS', - updateAndSetCustomRpc: updateAndSetCustomRpc, - setRpcTarget: setRpcTarget, - delRpcTarget: delRpcTarget, - setProviderType: setProviderType, - SET_HARDWARE_WALLET_DEFAULT_HD_PATH: 'SET_HARDWARE_WALLET_DEFAULT_HD_PATH', - setHardwareWalletDefaultHdPath, - updateProviderType, - // loading overlay - SHOW_LOADING: 'SHOW_LOADING_INDICATION', - HIDE_LOADING: 'HIDE_LOADING_INDICATION', - showLoadingIndication: showLoadingIndication, - hideLoadingIndication: hideLoadingIndication, - // buy Eth with coinbase - onboardingBuyEthView, - ONBOARDING_BUY_ETH_VIEW: 'ONBOARDING_BUY_ETH_VIEW', - BUY_ETH: 'BUY_ETH', - buyEth: buyEth, - buyEthView: buyEthView, - buyWithShapeShift, - BUY_ETH_VIEW: 'BUY_ETH_VIEW', - COINBASE_SUBVIEW: 'COINBASE_SUBVIEW', - coinBaseSubview: coinBaseSubview, - SHAPESHIFT_SUBVIEW: 'SHAPESHIFT_SUBVIEW', - shapeShiftSubview: shapeShiftSubview, - PAIR_UPDATE: 'PAIR_UPDATE', - pairUpdate: pairUpdate, - coinShiftRquest: coinShiftRquest, - SHOW_SUB_LOADING_INDICATION: 'SHOW_SUB_LOADING_INDICATION', - showSubLoadingIndication: showSubLoadingIndication, - HIDE_SUB_LOADING_INDICATION: 'HIDE_SUB_LOADING_INDICATION', - hideSubLoadingIndication: hideSubLoadingIndication, -// QR STUFF: - SHOW_QR: 'SHOW_QR', - showQrView: showQrView, - reshowQrCode: reshowQrCode, - SHOW_QR_VIEW: 'SHOW_QR_VIEW', -// FORGOT PASSWORD: - BACK_TO_INIT_MENU: 'BACK_TO_INIT_MENU', - goBackToInitView: goBackToInitView, - RECOVERY_IN_PROGRESS: 'RECOVERY_IN_PROGRESS', - BACK_TO_UNLOCK_VIEW: 'BACK_TO_UNLOCK_VIEW', - backToUnlockView: backToUnlockView, - // SHOWING KEYCHAIN - SHOW_NEW_KEYCHAIN: 'SHOW_NEW_KEYCHAIN', - showNewKeychain: showNewKeychain, - - callBackgroundThenUpdate, - forceUpdateMetamaskState, - - TOGGLE_ACCOUNT_MENU: 'TOGGLE_ACCOUNT_MENU', - toggleAccountMenu, - - useEtherscanProvider, - - 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', - setCurrentLocale, - updateCurrentLocale, - setLocaleMessages, - // - // Feature Flags - setFeatureFlag, - updateFeatureFlags, - UPDATE_FEATURE_FLAGS: 'UPDATE_FEATURE_FLAGS', - - // Preferences - setPreference, - 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', - - // Network - updateNetworkEndpointType, - UPDATE_NETWORK_ENDPOINT_TYPE: 'UPDATE_NETWORK_ENDPOINT_TYPE', - - retryTransaction, - SET_PENDING_TOKENS: 'SET_PENDING_TOKENS', - CLEAR_PENDING_TOKENS: 'CLEAR_PENDING_TOKENS', - setPendingTokens, - clearPendingTokens, - - createCancelTransaction, - createSpeedUpTransaction, - - approveProviderRequest, - rejectProviderRequest, - clearApprovedOrigins, - - setFirstTimeFlowType, - SET_FIRST_TIME_FLOW_TYPE: 'SET_FIRST_TIME_FLOW_TYPE', -} - -module.exports = actions - -var background = null -function _setBackgroundConnection (backgroundConnection) { - background = backgroundConnection -} - -function goHome () { - return { - type: actions.GO_HOME, - } -} - -// async actions - -function tryUnlockMetamask (password) { - return dispatch => { - dispatch(actions.showLoadingIndication()) - dispatch(actions.unlockInProgress()) - log.debug(`background.submitPassword`) - - return new Promise((resolve, reject) => { - background.submitPassword(password, error => { - if (error) { - return reject(error) - } - - resolve() - }) - }) - .then(() => { - dispatch(actions.unlockSucceeded()) - return forceUpdateMetamaskState(dispatch) - }) - .then(() => { - return new Promise((resolve, reject) => { - background.verifySeedPhrase(err => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - resolve() - }) - }) - }) - .then(() => { - dispatch(actions.transitionForward()) - dispatch(actions.hideLoadingIndication()) - }) - .catch(err => { - dispatch(actions.unlockFailed(err.message)) - dispatch(actions.hideLoadingIndication()) - return Promise.reject(err) - }) - } -} - -function transitionForward () { - return { - type: this.TRANSITION_FORWARD, - } -} - -function transitionBackward () { - return { - type: this.TRANSITION_BACKWARD, - } -} - -function confirmSeedWords () { - return dispatch => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.clearSeedWordCache`) - return new Promise((resolve, reject) => { - background.clearSeedWordCache((err, account) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - log.info('Seed word cache cleared. ' + account) - dispatch(actions.showAccountsPage()) - resolve(account) - }) - }) - } -} - -function createNewVaultAndRestore (password, seed) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.createNewVaultAndRestore`) - - return new Promise((resolve, reject) => { - background.clearSeedWordCache((err) => { - if (err) { - return reject(err) - } - - background.createNewVaultAndRestore(password, seed, (err) => { - if (err) { - return reject(err) - } - - resolve() - }) - }) - }) - .then(() => dispatch(actions.unMarkPasswordForgotten())) - .then(() => { - dispatch(actions.showAccountsPage()) - dispatch(actions.hideLoadingIndication()) - }) - .catch(err => { - dispatch(actions.displayWarning(err.message)) - dispatch(actions.hideLoadingIndication()) - return Promise.reject(err) - }) - } -} - -function createNewVaultAndKeychain (password) { - return dispatch => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.createNewVaultAndKeychain`) - - return new Promise((resolve, reject) => { - background.createNewVaultAndKeychain(password, err => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - log.debug(`background.placeSeedWords`) - - background.placeSeedWords((err) => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - resolve() - }) - }) - }) - .then(() => forceUpdateMetamaskState(dispatch)) - .then(() => dispatch(actions.hideLoadingIndication())) - .catch(() => dispatch(actions.hideLoadingIndication())) - } -} - -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 => { - if (error) { - return reject(error) - } - - resolve(true) - }) - }) -} - -function verifySeedPhrase () { - return new Promise((resolve, reject) => { - background.verifySeedPhrase((error, seedWords) => { - if (error) { - return reject(error) - } - - resolve(seedWords) - }) - }) -} - -function requestRevealSeed (password) { - return dispatch => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.submitPassword`) - return new Promise((resolve, reject) => { - background.submitPassword(password, err => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - log.debug(`background.placeSeedWords`) - background.placeSeedWords((err, result) => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.showNewVaultSeed(result)) - dispatch(actions.hideLoadingIndication()) - resolve() - }) - }) - }) - } -} - -function requestRevealSeedWords (password) { - return async dispatch => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.submitPassword`) - - try { - await verifyPassword(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 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()) - - return new Promise((resolve, reject) => { - background.resetAccount((err, account) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - log.info('Transaction history reset for ' + account) - dispatch(actions.showAccountsPage()) - resolve(account) - }) - }) - } -} - -function removeAccount (address) { - return dispatch => { - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - background.removeAccount(address, (err, account) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - log.info('Account removed: ' + account) - dispatch(actions.showAccountsPage()) - resolve() - }) - }) - } -} - -function addNewKeyring (type, opts) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.addNewKeyring`) - background.addNewKeyring(type, opts, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) return dispatch(actions.displayWarning(err.message)) - dispatch(actions.showAccountsPage()) - }) - } -} - -function importNewAccount (strategy, args) { - return async (dispatch) => { - let newState - dispatch(actions.showLoadingIndication('This may take a while, please be patient.')) - try { - log.debug(`background.importAccountWithStrategy`) - await pify(background.importAccountWithStrategy).call(background, strategy, args) - log.debug(`background.getState`) - newState = await pify(background.getState).call(background) - } catch (err) { - dispatch(actions.hideLoadingIndication()) - dispatch(actions.displayWarning(err.message)) - throw err - } - dispatch(actions.hideLoadingIndication()) - dispatch(actions.updateMetamaskState(newState)) - if (newState.selectedAddress) { - dispatch({ - type: actions.SHOW_ACCOUNT_DETAIL, - value: newState.selectedAddress, - }) - } - return newState - } -} - -function navigateToNewAccountScreen () { - return { - type: this.NEW_ACCOUNT_SCREEN, - } -} - -function addNewAccount () { - log.debug(`background.addNewAccount`) - return (dispatch, getState) => { - const oldIdentities = getState().metamask.identities - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.addNewAccount((err, { identities: newIdentities}) => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - const newAccountAddress = Object.keys(newIdentities).find(address => !oldIdentities[address]) - - dispatch(actions.hideLoadingIndication()) - - forceUpdateMetamaskState(dispatch) - return resolve(newAccountAddress) - }) - }) - } -} - -function checkHardwareStatus (deviceName, hdPath) { - log.debug(`background.checkHardwareStatus`, deviceName, hdPath) - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.checkHardwareStatus(deviceName, hdPath, (err, unlocked) => { - if (err) { - log.error(err) - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.hideLoadingIndication()) - - forceUpdateMetamaskState(dispatch) - return resolve(unlocked) - }) - }) - } -} - -function forgetDevice (deviceName) { - log.debug(`background.forgetDevice`, deviceName) - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.forgetDevice(deviceName, (err, response) => { - if (err) { - log.error(err) - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.hideLoadingIndication()) - - forceUpdateMetamaskState(dispatch) - return resolve() - }) - }) - } -} - -function connectHardware (deviceName, page, hdPath) { - log.debug(`background.connectHardware`, deviceName, page, hdPath) - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.connectHardware(deviceName, page, hdPath, (err, accounts) => { - if (err) { - log.error(err) - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.hideLoadingIndication()) - - forceUpdateMetamaskState(dispatch) - return resolve(accounts) - }) - }) - } -} - -function unlockHardwareWalletAccount (index, deviceName, hdPath) { - log.debug(`background.unlockHardwareWalletAccount`, index, deviceName, hdPath) - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.unlockHardwareWalletAccount(index, deviceName, hdPath, (err, accounts) => { - if (err) { - log.error(err) - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.hideLoadingIndication()) - return resolve() - }) - }) - } -} - -function showInfoPage () { - return { - type: actions.SHOW_INFO_PAGE, - } -} - -function showQrScanner (ROUTE) { - return (dispatch, getState) => { - return WebcamUtils.checkStatus() - .then(status => { - if (!status.environmentReady) { - // We need to switch to fullscreen mode to ask for permission - global.platform.openExtensionInBrowser(`${ROUTE}`, `scan=true`) - } else { - dispatch(actions.showModal({ - name: 'QR_SCANNER', - })) - } - }).catch(e => { - dispatch(actions.showModal({ - name: 'QR_SCANNER', - error: true, - errorType: e.type, - })) - }) - } -} - -function setCurrentCurrency (currencyCode) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.setCurrentCurrency`) - background.setCurrentCurrency(currencyCode, (err, data) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - log.error(err.stack) - return dispatch(actions.displayWarning(err.message)) - } - dispatch({ - type: actions.SET_CURRENT_FIAT, - value: { - currentCurrency: data.currentCurrency, - conversionRate: data.conversionRate, - conversionDate: data.conversionDate, - }, - }) - }) - } -} - -function signMsg (msgData) { - log.debug('action - signMsg') - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - log.debug(`actions calling background.signMessage`) - background.signMessage(msgData, (err, newState) => { - log.debug('signMessage called back') - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.hideLoadingIndication()) - - if (err) { - log.error(err) - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.completedTx(msgData.metamaskId)) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return resolve(msgData) - }) - }) - } -} - -function signPersonalMsg (msgData) { - log.debug('action - signPersonalMsg') - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - log.debug(`actions calling background.signPersonalMessage`) - background.signPersonalMessage(msgData, (err, newState) => { - log.debug('signPersonalMessage called back') - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.hideLoadingIndication()) - - if (err) { - log.error(err) - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.completedTx(msgData.metamaskId)) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return resolve(msgData) - }) - }) - } -} - -function signTypedMsg (msgData) { - log.debug('action - signTypedMsg') - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - log.debug(`actions calling background.signTypedMessage`) - background.signTypedMessage(msgData, (err, newState) => { - log.debug('signTypedMessage called back') - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.hideLoadingIndication()) - - if (err) { - log.error(err) - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.completedTx(msgData.metamaskId)) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return resolve(msgData) - }) - }) - } -} - -function signTx (txData) { - return (dispatch) => { - global.ethQuery.sendTransaction(txData, (err, data) => { - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - }) - dispatch(actions.showConfTxPage({})) - } -} - -function setGasLimit (gasLimit) { - return { - type: actions.UPDATE_GAS_LIMIT, - value: gasLimit, - } -} - -function setGasPrice (gasPrice) { - return { - type: actions.UPDATE_GAS_PRICE, - value: gasPrice, - } -} - -function setGasTotal (gasTotal) { - return { - type: actions.UPDATE_GAS_TOTAL, - value: gasTotal, - } -} - -function updateGasData ({ - gasPrice, - blockGasLimit, - recentBlocks, - selectedAddress, - selectedToken, - to, - value, - data, -}) { - return (dispatch) => { - dispatch(actions.gasLoadingStarted()) - return estimateGas({ - estimateGasMethod: background.estimateGas, - blockGasLimit, - selectedAddress, - selectedToken, - to, - value, - estimateGasPrice: gasPrice, - data, - }) - .then(gas => { - dispatch(actions.setGasLimit(gas)) - dispatch(gasDuck.setCustomGasLimit(gas)) - dispatch(updateSendErrors({ gasLoadingError: null })) - dispatch(actions.gasLoadingFinished()) - }) - .catch(err => { - log.error(err) - dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' })) - dispatch(actions.gasLoadingFinished()) - }) - } -} - -function gasLoadingStarted () { - return { - type: actions.GAS_LOADING_STARTED, - } -} - -function gasLoadingFinished () { - return { - type: actions.GAS_LOADING_FINISHED, - } -} - -function updateSendTokenBalance ({ - selectedToken, - tokenContract, - address, -}) { - return (dispatch) => { - const tokenBalancePromise = tokenContract - ? tokenContract.balanceOf(address) - : Promise.resolve() - return tokenBalancePromise - .then(usersToken => { - if (usersToken) { - const newTokenBalance = calcTokenBalance({ selectedToken, usersToken }) - dispatch(setSendTokenBalance(newTokenBalance)) - } - }) - .catch(err => { - log.error(err) - updateSendErrors({ tokenBalance: 'tokenBalanceError' }) - }) - } -} - -function updateSendErrors (errorObject) { - return { - type: actions.UPDATE_SEND_ERRORS, - value: errorObject, - } -} - -function updateSendWarnings (warningObject) { - return { - type: actions.UPDATE_SEND_WARNINGS, - value: warningObject, - } -} - -function setSendTokenBalance (tokenBalance) { - return { - type: actions.UPDATE_SEND_TOKEN_BALANCE, - value: tokenBalance, - } -} - -function updateSendHexData (value) { - return { - type: actions.UPDATE_SEND_HEX_DATA, - value, - } -} - -function updateSendTo (to, nickname = '') { - return { - type: actions.UPDATE_SEND_TO, - value: { to, nickname }, - } -} - -function updateSendAmount (amount) { - return { - type: actions.UPDATE_SEND_AMOUNT, - value: amount, - } -} - -function updateSendMemo (memo) { - return { - type: actions.UPDATE_SEND_MEMO, - value: memo, - } -} - -function setMaxModeTo (bool) { - return { - type: actions.UPDATE_MAX_MODE, - value: bool, - } -} - -function updateSend (newSend) { - return { - type: actions.UPDATE_SEND, - value: newSend, - } -} - -function clearSend () { - return { - type: actions.CLEAR_SEND, - } -} - - -function sendTx (txData) { - log.info(`actions - sendTx: ${JSON.stringify(txData.txParams)}`) - return (dispatch, getState) => { - log.debug(`actions calling background.approveTransaction`) - background.approveTransaction(txData.id, (err) => { - if (err) { - dispatch(actions.txError(err)) - return log.error(err.message) - } - dispatch(actions.completedTx(txData.id)) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - }) - } -} - -function signTokenTx (tokenAddress, toAddress, amount, txData) { - return dispatch => { - dispatch(actions.showLoadingIndication()) - const token = global.eth.contract(abi).at(tokenAddress) - token.transfer(toAddress, ethUtil.addHexPrefix(amount), txData) - .catch(err => { - dispatch(actions.hideLoadingIndication()) - dispatch(actions.displayWarning(err.message)) - }) - dispatch(actions.showConfTxPage({})) - } -} - -function updateTransaction (txData) { - log.info('actions: updateTx: ' + JSON.stringify(txData)) - return dispatch => { - log.debug(`actions calling background.updateTx`) - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - background.updateTransaction(txData, (err) => { - dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) - if (err) { - dispatch(actions.txError(err)) - dispatch(actions.goHome()) - log.error(err.message) - return reject(err) - } - - resolve(txData) - }) - }) - .then(() => updateMetamaskStateFromBackground()) - .then(newState => dispatch(actions.updateMetamaskState(newState))) - .then(() => { - dispatch(actions.showConfTxPage({ id: txData.id })) - dispatch(actions.hideLoadingIndication()) - return txData - }) - } -} - -function updateAndApproveTx (txData) { - log.info('actions: updateAndApproveTx: ' + JSON.stringify(txData)) - return (dispatch, getState) => { - log.debug(`actions calling background.updateAndApproveTx`) - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - background.updateAndApproveTransaction(txData, err => { - dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) - dispatch(actions.clearSend()) - - if (err) { - dispatch(actions.txError(err)) - dispatch(actions.goHome()) - log.error(err.message) - reject(err) - } - - resolve(txData) - }) - }) - .then(() => updateMetamaskStateFromBackground()) - .then(newState => dispatch(actions.updateMetamaskState(newState))) - .then(() => { - dispatch(actions.clearSend()) - dispatch(actions.completedTx(txData.id)) - dispatch(actions.hideLoadingIndication()) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return txData - }) - .catch((err) => { - dispatch(actions.hideLoadingIndication()) - return Promise.reject(err) - }) - } -} - -function completedTx (id) { - return { - type: actions.COMPLETED_TX, - value: id, - } -} - -function updateTransactionParams (id, txParams) { - return { - type: actions.UPDATE_TRANSACTION_PARAMS, - id, - value: txParams, - } -} - -function txError (err) { - return { - type: actions.TRANSACTION_ERROR, - message: err.message, - } -} - -function cancelMsg (msgData) { - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - log.debug(`background.cancelMessage`) - background.cancelMessage(msgData.id, (err, newState) => { - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.hideLoadingIndication()) - - if (err) { - return reject(err) - } - - dispatch(actions.completedTx(msgData.id)) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return resolve(msgData) - }) - }) - } -} - -function cancelPersonalMsg (msgData) { - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - const id = msgData.id - background.cancelPersonalMessage(id, (err, newState) => { - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.hideLoadingIndication()) - - if (err) { - return reject(err) - } - - dispatch(actions.completedTx(id)) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return resolve(msgData) - }) - }) - } -} - -function cancelTypedMsg (msgData) { - return (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - const id = msgData.id - background.cancelTypedMessage(id, (err, newState) => { - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.hideLoadingIndication()) - - if (err) { - return reject(err) - } - - dispatch(actions.completedTx(id)) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return resolve(msgData) - }) - }) - } -} - -function cancelTx (txData) { - return (dispatch, getState) => { - log.debug(`background.cancelTransaction`) - dispatch(actions.showLoadingIndication()) - - return new Promise((resolve, reject) => { - background.cancelTransaction(txData.id, err => { - if (err) { - return reject(err) - } - - resolve() - }) - }) - .then(() => updateMetamaskStateFromBackground()) - .then(newState => dispatch(actions.updateMetamaskState(newState))) - .then(() => { - dispatch(actions.clearSend()) - dispatch(actions.completedTx(txData.id)) - dispatch(actions.hideLoadingIndication()) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && - !hasUnconfirmedTransactions(getState())) { - return global.platform.closeCurrentWindow() - } - - return txData - }) - } -} - -/** - * Cancels all of the given transactions - * @param {Array<object>} txDataList a list of tx data objects - * @return {function(*): Promise<void>} - */ -function cancelTxs (txDataList) { - return async (dispatch, getState) => { - dispatch(actions.showLoadingIndication()) - const txIds = txDataList.map(({id}) => id) - const cancellations = txIds.map((id) => new Promise((resolve, reject) => { - background.cancelTransaction(id, (err) => { - if (err) { - return reject(err) - } - - resolve() - }) - })) - - await Promise.all(cancellations) - const newState = await updateMetamaskStateFromBackground() - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.clearSend()) - - txIds.forEach((id) => { - dispatch(actions.completedTx(id)) - }) - - dispatch(actions.hideLoadingIndication()) - - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { - return global.platform.closeCurrentWindow() - } - } -} - -/** - * @deprecated - * @param {Array<object>} txsData - * @return {Function} - */ -function cancelAllTx (txsData) { - return (dispatch) => { - txsData.forEach((txData, i) => { - background.cancelTransaction(txData.id, () => { - dispatch(actions.completedTx(txData.id)) - i === txsData.length - 1 ? dispatch(actions.goHome()) : null - }) - }) - } -} -// -// initialize screen -// - -function showCreateVault () { - return { - type: actions.SHOW_CREATE_VAULT, - } -} - -function showRestoreVault () { - return { - type: actions.SHOW_RESTORE_VAULT, - } -} - -function markPasswordForgotten () { - return (dispatch) => { - return background.markPasswordForgotten(() => { - dispatch(actions.hideLoadingIndication()) - dispatch(actions.forgotPassword()) - forceUpdateMetamaskState(dispatch) - }) - } -} - -function unMarkPasswordForgotten () { - return dispatch => { - return new Promise(resolve => { - background.unMarkPasswordForgotten(() => { - dispatch(actions.forgotPassword(false)) - resolve() - }) - }) - .then(() => forceUpdateMetamaskState(dispatch)) - } -} - -function forgotPassword (forgotPasswordState = true) { - return { - type: actions.FORGOT_PASSWORD, - value: forgotPasswordState, - } -} - -function showInitializeMenu () { - return { - type: actions.SHOW_INIT_MENU, - } -} - -function showImportPage () { - return { - type: actions.SHOW_IMPORT_PAGE, - } -} - -function showNewAccountPage (formToSelect) { - return { - type: actions.SHOW_NEW_ACCOUNT_PAGE, - formToSelect, - } -} - -function setNewAccountForm (formToSelect) { - return { - type: actions.SET_NEW_ACCOUNT_FORM, - formToSelect, - } -} - -function createNewVaultInProgress () { - return { - type: actions.CREATE_NEW_VAULT_IN_PROGRESS, - } -} - -function showNewVaultSeed (seed) { - return { - type: actions.SHOW_NEW_VAULT_SEED, - value: seed, - } -} - -function closeWelcomeScreen () { - return { - type: actions.CLOSE_WELCOME_SCREEN, - } -} - -function backToUnlockView () { - return { - type: actions.BACK_TO_UNLOCK_VIEW, - } -} - -function showNewKeychain () { - return { - type: actions.SHOW_NEW_KEYCHAIN, - } -} - -// -// unlock screen -// - -function unlockInProgress () { - return { - type: actions.UNLOCK_IN_PROGRESS, - } -} - -function unlockFailed (message) { - return { - type: actions.UNLOCK_FAILED, - value: message, - } -} - -function unlockSucceeded (message) { - return { - type: actions.UNLOCK_SUCCEEDED, - value: message, - } -} - -function unlockMetamask (account) { - return { - type: actions.UNLOCK_METAMASK, - value: account, - } -} - -function updateMetamaskState (newState) { - return { - type: actions.UPDATE_METAMASK_STATE, - value: newState, - } -} - -const backgroundSetLocked = () => { - return new Promise((resolve, reject) => { - background.setLocked(error => { - if (error) { - return reject(error) - } - resolve() - }) - }) -} - -const updateMetamaskStateFromBackground = () => { - log.debug(`background.getState`) - - return new Promise((resolve, reject) => { - background.getState((error, newState) => { - if (error) { - return reject(error) - } - - resolve(newState) - }) - }) -} - -function lockMetamask () { - log.debug(`background.setLocked`) - - return dispatch => { - dispatch(actions.showLoadingIndication()) - - return backgroundSetLocked() - .then(() => updateMetamaskStateFromBackground()) - .catch(error => { - dispatch(actions.displayWarning(error.message)) - return Promise.reject(error) - }) - .then(newState => { - dispatch(actions.updateMetamaskState(newState)) - dispatch(actions.hideLoadingIndication()) - dispatch({ type: actions.LOCK_METAMASK }) - }) - .catch(() => { - dispatch(actions.hideLoadingIndication()) - dispatch({ type: actions.LOCK_METAMASK }) - }) - } -} - -function setCurrentAccountTab (newTabName) { - log.debug(`background.setCurrentAccountTab: ${newTabName}`) - return callBackgroundThenUpdateNoSpinner(background.setCurrentAccountTab, newTabName) -} - -function setSelectedToken (tokenAddress) { - return { - type: actions.SET_SELECTED_TOKEN, - value: tokenAddress || null, - } -} - -function setSelectedAddress (address) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.setSelectedAddress`) - background.setSelectedAddress(address, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - }) - } -} - -function showAccountDetail (address) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.setSelectedAddress`) - background.setSelectedAddress(address, (err, tokens) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - dispatch(updateTokens(tokens)) - dispatch({ - type: actions.SHOW_ACCOUNT_DETAIL, - value: address, - }) - dispatch(actions.setSelectedToken()) - }) - } -} - -function backToAccountDetail (address) { - return { - type: actions.BACK_TO_ACCOUNT_DETAIL, - value: address, - } -} - -function showAccountsPage () { - return { - type: actions.SHOW_ACCOUNTS_PAGE, - } -} - -function showConfTxPage ({transForward = true, id}) { - return { - type: actions.SHOW_CONF_TX_PAGE, - transForward, - id, - } -} - -function nextTx () { - return { - type: actions.NEXT_TX, - } -} - -function viewPendingTx (txId) { - return { - type: actions.VIEW_PENDING_TX, - value: txId, - } -} - -function previousTx () { - return { - type: actions.PREVIOUS_TX, - } -} - -function editTx (txId) { - return { - type: actions.EDIT_TX, - value: txId, - } -} - -function showConfigPage (transitionForward = true) { - return { - type: actions.SHOW_CONFIG_PAGE, - value: transitionForward, - } -} - -function showAddTokenPage (transitionForward = true) { - return { - type: actions.SHOW_ADD_TOKEN_PAGE, - value: transitionForward, - } -} - -function showAddSuggestedTokenPage (transitionForward = true) { - return { - type: actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE, - value: transitionForward, - } -} - -function addToken (address, symbol, decimals, image) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.addToken(address, symbol, decimals, image, (err, tokens) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - reject(err) - } - dispatch(actions.updateTokens(tokens)) - resolve(tokens) - }) - }) - } -} - -function removeToken (address) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.removeToken(address, (err, tokens) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - reject(err) - } - dispatch(actions.updateTokens(tokens)) - resolve(tokens) - }) - }) - } -} - -function addTokens (tokens) { - return dispatch => { - if (Array.isArray(tokens)) { - dispatch(actions.setSelectedToken(getTokenAddressFromTokenObject(tokens[0]))) - return Promise.all(tokens.map(({ address, symbol, decimals }) => ( - dispatch(addToken(address, symbol, decimals)) - ))) - } else { - dispatch(actions.setSelectedToken(getTokenAddressFromTokenObject(tokens))) - return Promise.all( - Object - .entries(tokens) - .map(([_, { address, symbol, decimals }]) => ( - dispatch(addToken(address, symbol, decimals)) - )) - ) - } - } -} - -function removeSuggestedTokens () { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.removeSuggestedTokens((err, suggestedTokens) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - } - dispatch(actions.clearPendingTokens()) - if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { - return global.platform.closeCurrentWindow() - } - resolve(suggestedTokens) - }) - }) - .then(() => updateMetamaskStateFromBackground()) - .then(suggestedTokens => dispatch(actions.updateMetamaskState({...suggestedTokens}))) - } -} - -function addKnownMethodData (fourBytePrefix, methodData) { - return (dispatch) => { - background.addKnownMethodData(fourBytePrefix, methodData) - } -} - -function updateTokens (newTokens) { - return { - type: actions.UPDATE_TOKENS, - newTokens, - } -} - -function clearPendingTokens () { - return { - type: actions.CLEAR_PENDING_TOKENS, - } -} - -function goBackToInitView () { - return { - type: actions.BACK_TO_INIT_MENU, - } -} - -// -// notice -// - -function markNoticeRead (notice) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.markNoticeRead`) - return new Promise((resolve, reject) => { - background.markNoticeRead(notice, (err, notice) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - if (notice) { - dispatch(actions.showNotice(notice)) - resolve(true) - } else { - dispatch(actions.clearNotices()) - resolve(false) - } - }) - }) - } -} - -function showNotice (notice) { - return { - type: actions.SHOW_NOTICE, - value: notice, - } -} - -function clearNotices () { - return { - type: actions.CLEAR_NOTICES, - } -} - -function markAccountsFound () { - log.debug(`background.markAccountsFound`) - return callBackgroundThenUpdate(background.markAccountsFound) -} - -function retryTransaction (txId, gasPrice) { - log.debug(`background.retryTransaction`) - let newTxId - - return dispatch => { - return new Promise((resolve, reject) => { - background.retryTransaction(txId, gasPrice, (err, newState) => { - if (err) { - dispatch(actions.displayWarning(err.message)) - reject(err) - } - - const { selectedAddressTxList } = newState - const { id } = selectedAddressTxList[selectedAddressTxList.length - 1] - newTxId = id - resolve(newState) - }) - }) - .then(newState => dispatch(actions.updateMetamaskState(newState))) - .then(() => newTxId) - } -} - -function createCancelTransaction (txId, customGasPrice) { - log.debug('background.cancelTransaction') - let newTxId - - return dispatch => { - return new Promise((resolve, reject) => { - background.createCancelTransaction(txId, customGasPrice, (err, newState) => { - if (err) { - dispatch(actions.displayWarning(err.message)) - reject(err) - } - - const { selectedAddressTxList } = newState - const { id } = selectedAddressTxList[selectedAddressTxList.length - 1] - newTxId = id - resolve(newState) - }) - }) - .then(newState => dispatch(actions.updateMetamaskState(newState))) - .then(() => newTxId) - } -} - -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, 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()) - }) - - } -} - -function updateProviderType (type) { - return { - type: actions.SET_PROVIDER_TYPE, - value: type, - } -} - -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 || newRpc, (err, result) => { - if (err) { - log.error(err) - return dispatch(actions.displayWarning('Had a problem changing networks!')) - } - dispatch(actions.setSelectedToken()) - }) - } -} - -function delRpcTarget (oldRpc) { - return (dispatch) => { - log.debug(`background.delRpcTarget: ${oldRpc}`) - background.delCustomRpc(oldRpc, (err, result) => { - if (err) { - log.error(err) - return dispatch(self.displayWarning('Had a problem removing network!')) - } - dispatch(actions.setSelectedToken()) - }) - } -} - -// Calls the addressBookController to add a new address. -function addToAddressBook (recipient, nickname = '') { - log.debug(`background.addToAddressBook`) - return (dispatch) => { - background.setAddressBook(recipient, nickname, (err, result) => { - if (err) { - log.error(err) - return dispatch(self.displayWarning('Address book failed to update')) - } - }) - } -} - -function useEtherscanProvider () { - log.debug(`background.useEtherscanProvider`) - background.useEtherscanProvider() - return { - type: actions.USE_ETHERSCAN_PROVIDER, - } -} - -function showNetworkDropdown () { - return { - type: actions.NETWORK_DROPDOWN_OPEN, - } -} - -function hideNetworkDropdown () { - return { - type: actions.NETWORK_DROPDOWN_CLOSE, - } -} - - -function showModal (payload) { - return { - type: actions.MODAL_OPEN, - payload, - } -} - -function hideModal (payload) { - return { - type: actions.MODAL_CLOSE, - payload, - } -} - -function showSidebar ({ transitionName, type, props }) { - return { - type: actions.SIDEBAR_OPEN, - value: { - transitionName, - type, - props, - }, - } -} - -function hideSidebar () { - return { - type: actions.SIDEBAR_CLOSE, - } -} - -function showAlert (msg) { - return { - type: actions.ALERT_OPEN, - value: msg, - } -} - -function hideAlert () { - return { - type: actions.ALERT_CLOSE, - } -} - -/** - * This action will receive two types of values via qrCodeData - * an object with the following structure {type, values} - * or null (used to clear the previous value) - */ -function qrCodeDetected (qrCodeData) { - return { - type: actions.QR_CODE_DETECTED, - value: qrCodeData, - } -} - -function showLoadingIndication (message) { - return { - type: actions.SHOW_LOADING, - value: message, - } -} - -function setHardwareWalletDefaultHdPath ({ device, path }) { - return { - type: actions.SET_HARDWARE_WALLET_DEFAULT_HD_PATH, - value: {device, path}, - } -} - -function hideLoadingIndication () { - return { - type: actions.HIDE_LOADING, - } -} - -function showSubLoadingIndication () { - return { - type: actions.SHOW_SUB_LOADING_INDICATION, - } -} - -function hideSubLoadingIndication () { - return { - type: actions.HIDE_SUB_LOADING_INDICATION, - } -} - -function displayWarning (text) { - return { - type: actions.DISPLAY_WARNING, - value: text, - } -} - -function hideWarning () { - return { - type: actions.HIDE_WARNING, - } -} - -function requestExportAccount () { - return { - type: actions.REQUEST_ACCOUNT_EXPORT, - } -} - -function exportAccount (password, address) { - var self = this - - return function (dispatch) { - dispatch(self.showLoadingIndication()) - - log.debug(`background.submitPassword`) - return new Promise((resolve, reject) => { - background.submitPassword(password, function (err) { - if (err) { - log.error('Error in submiting password.') - dispatch(self.hideLoadingIndication()) - dispatch(self.displayWarning('Incorrect Password.')) - return reject(err) - } - log.debug(`background.exportAccount`) - return background.exportAccount(address, function (err, result) { - dispatch(self.hideLoadingIndication()) - - if (err) { - log.error(err) - dispatch(self.displayWarning('Had a problem exporting the account.')) - return reject(err) - } - - // dispatch(self.exportAccountComplete()) - dispatch(self.showPrivateKey(result)) - - return resolve(result) - }) - }) - }) - } -} - -function exportAccountComplete () { - return { - type: actions.EXPORT_ACCOUNT, - } -} - -function showPrivateKey (key) { - return { - type: actions.SHOW_PRIVATE_KEY, - value: key, - } -} - -function setAccountLabel (account, label) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - log.debug(`background.setAccountLabel`) - - return new Promise((resolve, reject) => { - background.setAccountLabel(account, label, (err) => { - dispatch(actions.hideLoadingIndication()) - - if (err) { - dispatch(actions.displayWarning(err.message)) - reject(err) - } - - dispatch({ - type: actions.SET_ACCOUNT_LABEL, - value: { account, label }, - }) - - resolve(account) - }) - }) - } -} - -function showSendPage () { - return { - type: actions.SHOW_SEND_PAGE, - } -} - -function showSendTokenPage () { - return { - type: actions.SHOW_SEND_TOKEN_PAGE, - } -} - -function buyEth (opts) { - return (dispatch) => { - const url = getBuyEthUrl(opts) - global.platform.openWindow({ url }) - dispatch({ - type: actions.BUY_ETH, - }) - } -} - -function onboardingBuyEthView (address) { - return { - type: actions.ONBOARDING_BUY_ETH_VIEW, - value: address, - } -} - -function buyEthView (address) { - return { - type: actions.BUY_ETH_VIEW, - value: address, - } -} - -function coinBaseSubview () { - return { - type: actions.COINBASE_SUBVIEW, - } -} - -function pairUpdate (coin) { - return (dispatch) => { - dispatch(actions.showSubLoadingIndication()) - dispatch(actions.hideWarning()) - shapeShiftRequest('marketinfo', {pair: `${coin.toLowerCase()}_eth`}, (mktResponse) => { - dispatch(actions.hideSubLoadingIndication()) - if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) - dispatch({ - type: actions.PAIR_UPDATE, - value: { - marketinfo: mktResponse, - }, - }) - }) - } -} - -function shapeShiftSubview (network) { - var pair = 'btc_eth' - return (dispatch) => { - dispatch(actions.showSubLoadingIndication()) - shapeShiftRequest('marketinfo', {pair}, (mktResponse) => { - shapeShiftRequest('getcoins', {}, (response) => { - dispatch(actions.hideSubLoadingIndication()) - if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) - dispatch({ - type: actions.SHAPESHIFT_SUBVIEW, - value: { - marketinfo: mktResponse, - coinOptions: response, - }, - }) - }) - }) - } -} - -function coinShiftRquest (data, marketData) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - shapeShiftRequest('shift', { method: 'POST', data}, (response) => { - dispatch(actions.hideLoadingIndication()) - if (response.error) return dispatch(actions.displayWarning(response.error)) - var message = ` - Deposit your ${response.depositType} to the address below:` - log.debug(`background.createShapeShiftTx`) - background.createShapeShiftTx(response.deposit, response.depositType) - dispatch(actions.showQrView(response.deposit, [message].concat(marketData))) - }) - } -} - -function buyWithShapeShift (data) { - return dispatch => new Promise((resolve, reject) => { - shapeShiftRequest('shift', { method: 'POST', data}, (response) => { - if (response.error) { - return reject(response.error) - } - background.createShapeShiftTx(response.deposit, response.depositType) - return resolve(response) - }) - }) -} - -function showQrView (data, message) { - return { - type: actions.SHOW_QR_VIEW, - value: { - message: message, - data: data, - }, - } -} -function reshowQrCode (data, coin) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - shapeShiftRequest('marketinfo', {pair: `${coin.toLowerCase()}_eth`}, (mktResponse) => { - if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) - - var message = [ - `Deposit your ${coin} to the address below:`, - `Deposit Limit: ${mktResponse.limit}`, - `Deposit Minimum:${mktResponse.minimum}`, - ] - - dispatch(actions.hideLoadingIndication()) - return dispatch(actions.showQrView(data, message)) - // return dispatch(actions.showModal({ - // name: 'SHAPESHIFT_DEPOSIT_TX', - // Qr: { data, message }, - // })) - }) - } -} - -function shapeShiftRequest (query, options, cb) { - var queryResponse, method - !options ? options = {} : null - options.method ? method = options.method : method = 'GET' - - var requestListner = function (request) { - try { - queryResponse = JSON.parse(this.responseText) - cb ? cb(queryResponse) : null - return queryResponse - } catch (e) { - cb ? cb({error: e}) : null - return e - } - } - - var shapShiftReq = new XMLHttpRequest() - shapShiftReq.addEventListener('load', requestListner) - shapShiftReq.open(method, `https://shapeshift.io/${query}/${options.pair ? options.pair : ''}`, true) - - if (options.method === 'POST') { - var jsonObj = JSON.stringify(options.data) - shapShiftReq.setRequestHeader('Content-Type', 'application/json') - return shapShiftReq.send(jsonObj) - } else { - return shapShiftReq.send() - } -} - -function setFeatureFlag (feature, activated, notificationType) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.setFeatureFlag(feature, activated, (err, updatedFeatureFlags) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - dispatch(actions.updateFeatureFlags(updatedFeatureFlags)) - notificationType && dispatch(actions.showModal({ name: notificationType })) - resolve(updatedFeatureFlags) - }) - }) - } -} - -function updateFeatureFlags (updatedFeatureFlags) { - return { - type: actions.UPDATE_FEATURE_FLAGS, - value: updatedFeatureFlags, - } -} - -function setPreference (preference, value) { - return dispatch => { - dispatch(actions.showLoadingIndication()) - return new Promise((resolve, reject) => { - background.setPreference(preference, value, (err, updatedPreferences) => { - dispatch(actions.hideLoadingIndication()) - - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.updatePreferences(updatedPreferences)) - resolve(updatedPreferences) - }) - }) - } -} - -function updatePreferences (value) { - return { - type: actions.UPDATE_PREFERENCES, - value, - } -} - -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, - value: networkNonce, - } -} - -function updateNetworkNonce (address) { - return (dispatch) => { - return new Promise((resolve, reject) => { - global.ethQuery.getTransactionCount(address, (err, data) => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - dispatch(setNetworkNonce(data)) - resolve(data) - }) - }) - } -} - -function setMouseUserState (isMouseUser) { - return { - type: actions.SET_MOUSE_USER_STATE, - value: isMouseUser, - } -} - -// Call Background Then Update -// -// A function generator for a common pattern wherein: -// We show loading indication. -// We call a background method. -// We hide loading indication. -// If it errored, we show a warning. -// If it didn't, we update the state. -function callBackgroundThenUpdateNoSpinner (method, ...args) { - return (dispatch) => { - method.call(background, ...args, (err) => { - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - forceUpdateMetamaskState(dispatch) - }) - } -} - -function callBackgroundThenUpdate (method, ...args) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - method.call(background, ...args, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - forceUpdateMetamaskState(dispatch) - }) - } -} - -function forceUpdateMetamaskState (dispatch) { - log.debug(`background.getState`) - return new Promise((resolve, reject) => { - background.getState((err, newState) => { - if (err) { - dispatch(actions.displayWarning(err.message)) - return reject(err) - } - - dispatch(actions.updateMetamaskState(newState)) - resolve(newState) - }) - }) -} - -function toggleAccountMenu () { - return { - type: actions.TOGGLE_ACCOUNT_MENU, - } -} - -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()) - log.debug(`background.setUseBlockie`) - background.setUseBlockie(val, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - }) - dispatch({ - type: actions.SET_USE_BLOCKIE, - value: val, - }) - } -} - -function updateCurrentLocale (key) { - return (dispatch) => { - dispatch(actions.showLoadingIndication()) - return fetchLocale(key) - .then((localeMessages) => { - log.debug(`background.setCurrentLocale`) - background.setCurrentLocale(key, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - dispatch(actions.setCurrentLocale(key)) - dispatch(actions.setLocaleMessages(localeMessages)) - }) - }) - } -} - -function setCurrentLocale (key) { - return { - type: actions.SET_CURRENT_LOCALE, - value: key, - } -} - -function setLocaleMessages (localeMessages) { - return { - type: actions.SET_LOCALE_MESSAGES, - value: localeMessages, - } -} - -function updateNetworkEndpointType (networkEndpointType) { - return { - type: actions.UPDATE_NETWORK_ENDPOINT_TYPE, - value: networkEndpointType, - } -} - -function setPendingTokens (pendingTokens) { - const { customToken = {}, selectedTokens = {} } = pendingTokens - const { address, symbol, decimals } = customToken - const tokens = address && symbol && decimals - ? { ...selectedTokens, [address]: { ...customToken, isCustom: true } } - : selectedTokens - - return { - type: actions.SET_PENDING_TOKENS, - payload: tokens, - } -} - -function approveProviderRequest (tabID) { - return (dispatch) => { - background.approveProviderRequest(tabID) - } -} - -function rejectProviderRequest (tabID) { - return (dispatch) => { - background.rejectProviderRequest(tabID) - } -} - -function clearApprovedOrigins () { - return (dispatch) => { - 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/app.js b/ui/app/app.js deleted file mode 100644 index efec4e49a..000000000 --- a/ui/app/app.js +++ /dev/null @@ -1,441 +0,0 @@ -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 './actions' -import log from 'loglevel' -import { getMetaMaskAccounts, getNetworkIdentifier } from './selectors' - -// init -import FirstTimeFlow from './components/pages/first-time-flow' -// accounts -const SendTransactionScreen = require('./components/send/send.container') -const ConfirmTransaction = require('./components/pages/confirm-transaction') - -// slideout menu -const Sidebar = require('./components/sidebars').default -const { WALLET_VIEW_SIDEBAR } = require('./components/sidebars/sidebar.constants') - -// other views -import Home from './components/pages/home' -import Settings from './components/pages/settings' -import Authenticated from './higher-order-components/authenticated' -import Initialized from './higher-order-components/initialized' -import Lock from './components/pages/lock' -import UiMigrationAnnouncement from './components/ui-migration-annoucement' -const RestoreVaultPage = require('./components/pages/keychains/restore-vault').default -const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed') -const MobileSyncPage = require('./components/pages/mobile-sync') -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 LoadingNetwork = require('./components/loading-network-screen').default -const NetworkDropdown = require('./components/dropdowns/network-dropdown') -import AccountMenu from './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' - -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 './routes' - -// enums -import { - ENVIRONMENT_TYPE_NOTIFICATION, - ENVIRONMENT_TYPE_POPUP, -} from '../../app/scripts/lib/enums' - -class App 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 - } -} - -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, - 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)), - } -} - -App.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(App) diff --git a/ui/app/components/account-dropdown-mini/account-dropdown-mini.component.js b/ui/app/components/account-dropdown-mini/account-dropdown-mini.component.js deleted file mode 100644 index 8a171d0c6..000000000 --- a/ui/app/components/account-dropdown-mini/account-dropdown-mini.component.js +++ /dev/null @@ -1,84 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import AccountListItem from '../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/account-dropdown-mini/tests/account-dropdown-mini.component.test.js b/ui/app/components/account-dropdown-mini/tests/account-dropdown-mini.component.test.js deleted file mode 100644 index abd2f7c75..000000000 --- a/ui/app/components/account-dropdown-mini/tests/account-dropdown-mini.component.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import AccountDropdownMini from '../account-dropdown-mini.component' -import AccountListItem from '../../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/account-dropdowns.js b/ui/app/components/account-dropdowns.js deleted file mode 100644 index b05ba219c..000000000 --- a/ui/app/components/account-dropdowns.js +++ /dev/null @@ -1,338 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const actions = require('../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') - -import Identicon from './identicon' - -class AccountDropdowns extends Component { - constructor (props) { - super(props) - this.state = { - accountSelectorActive: false, - optionsMenuActive: false, - } - this.accountSelectorToggleClassName = 'accounts-selector' - this.optionsMenuToggleClassName = 'fa-ellipsis-h' - } - - renderAccounts () { - const { identities, selected, keyrings } = this.props - - return Object.keys(identities).map((key, index) => { - const identity = identities[key] - const isSelected = identity.address === selected - - const simpleAddress = identity.address.substring(2).toLowerCase() - - const keyring = keyrings.find((kr) => { - return kr.accounts.includes(simpleAddress) || - kr.accounts.includes(identity.address) - }) - - return h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - this.props.actions.showAccountDetail(identity.address) - }, - style: { - marginTop: index === 0 ? '5px' : '', - fontSize: '24px', - }, - }, - [ - h( - Identicon, - { - address: identity.address, - diameter: 32, - style: { - marginLeft: '10px', - }, - }, - ), - this.indicateIfLoose(keyring), - h('span', { - style: { - marginLeft: '20px', - fontSize: '24px', - maxWidth: '145px', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - }, identity.name || ''), - h('span', { style: { marginLeft: '20px', fontSize: '24px' } }, isSelected ? h('.check', '✓') : null), - ] - ) - }) - } - - indicateIfLoose (keyring) { - try { // Sometimes keyrings aren't loaded yet: - const type = keyring.type - const isLoose = type !== 'HD Key Tree' - return isLoose ? h('.keyring-label.allcaps', this.context.t('loose')) : null - } catch (e) { return } - } - - renderAccountSelector () { - const { actions } = this.props - const { accountSelectorActive } = this.state - - return h( - Dropdown, - { - useCssTransition: true, // Hardcoded because account selector is temporarily in app-header - style: { - marginLeft: '-238px', - marginTop: '38px', - minWidth: '180px', - overflowY: 'auto', - maxHeight: '300px', - width: '300px', - }, - innerStyle: { - padding: '8px 25px', - }, - isOpen: accountSelectorActive, - onClickOutside: (event) => { - const { classList } = event.target - const isNotToggleElement = !classList.contains(this.accountSelectorToggleClassName) - if (accountSelectorActive && isNotToggleElement) { - this.setState({ accountSelectorActive: false }) - } - }, - }, - [ - ...this.renderAccounts(), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => actions.addNewAccount(), - }, - [ - h( - Identicon, - { - style: { - marginLeft: '10px', - }, - diameter: 32, - }, - ), - h('span', { style: { marginLeft: '20px', fontSize: '24px' } }, this.context.t('createAccount')), - ], - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => actions.showImportPage(), - }, - [ - h( - Identicon, - { - style: { - marginLeft: '10px', - }, - diameter: 32, - }, - ), - h('span', { - style: { - marginLeft: '20px', - fontSize: '24px', - marginBottom: '5px', - }, - }, this.context.t('importAccount')), - ] - ), - ] - ) - } - - renderAccountOptions () { - const { actions } = this.props - const { optionsMenuActive } = this.state - - return h( - Dropdown, - { - style: { - marginLeft: '-215px', - minWidth: '180px', - }, - isOpen: optionsMenuActive, - onClickOutside: (event) => { - const { classList } = event.target - const isNotToggleElement = !classList.contains(this.optionsMenuToggleClassName) - if (optionsMenuActive && isNotToggleElement) { - this.setState({ optionsMenuActive: false }) - } - }, - }, - [ - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - const { selected, network } = this.props - const url = genAccountLink(selected, network) - global.platform.openWindow({ url }) - }, - }, - this.context.t('etherscanView'), - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - const { selected, identities } = this.props - var identity = identities[selected] - actions.showQrView(selected, identity ? identity.name : '') - }, - }, - this.context.t('showQRCode'), - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - const { selected } = this.props - copyToClipboard(checksumAddress(selected)) - }, - }, - this.context.t('copyAddress'), - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - actions.requestAccountExport() - }, - }, - this.context.t('exportPrivateKey'), - ), - ] - ) - } - - render () { - const { metricsEvent } = this.context - const { style, enableAccountsSelector, enableAccountOptions } = this.props - const { optionsMenuActive, accountSelectorActive } = this.state - - return h( - 'span', - { - style: style, - }, - [ - enableAccountsSelector && h( - // 'i.fa.fa-angle-down', - 'div.cursor-pointer.color-orange.accounts-selector', - { - style: { - // fontSize: '1.8em', - background: 'url(images/switch_acc.svg) white center center no-repeat', - height: '25px', - width: '25px', - transform: 'scale(0.75)', - marginRight: '3px', - }, - onClick: (event) => { - event.stopPropagation() - this.setState({ - accountSelectorActive: !accountSelectorActive, - optionsMenuActive: false, - }) - }, - }, - this.renderAccountSelector(), - ), - enableAccountOptions && h( - 'i.fa.fa-ellipsis-h', - { - style: { - marginRight: '0.5em', - fontSize: '1.8em', - }, - onClick: (event) => { - metricsEvent({ - eventOpts: { - category: 'Accounts', - action: 'userClick', - name: 'accountsOpenedMenu', - }, - pageOpts: { - section: 'header', - component: 'accountDropdownIcon', - }, - }) - event.stopPropagation() - this.setState({ - accountSelectorActive: false, - optionsMenuActive: !optionsMenuActive, - }) - }, - }, - this.renderAccountOptions() - ), - ] - ) - } -} - -AccountDropdowns.defaultProps = { - enableAccountsSelector: false, - enableAccountOptions: false, -} - -AccountDropdowns.propTypes = { - identities: PropTypes.objectOf(PropTypes.object), - selected: PropTypes.string, - keyrings: PropTypes.array, - actions: PropTypes.objectOf(PropTypes.func), - network: PropTypes.string, - style: PropTypes.object, - enableAccountOptions: PropTypes.bool, - enableAccountsSelector: PropTypes.bool, - t: PropTypes.func, -} - -const mapDispatchToProps = (dispatch) => { - return { - actions: { - showConfigPage: () => dispatch(actions.showConfigPage()), - requestAccountExport: () => dispatch(actions.requestExportAccount()), - showAccountDetail: (address) => dispatch(actions.showAccountDetail(address)), - addNewAccount: () => dispatch(actions.addNewAccount()), - showImportPage: () => dispatch(actions.showImportPage()), - showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), - }, - } -} - -AccountDropdowns.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = { - AccountDropdowns: connect(null, mapDispatchToProps)(AccountDropdowns), -} diff --git a/ui/app/components/account-menu/account-menu.component.js b/ui/app/components/account-menu/account-menu.component.js deleted file mode 100644 index f7c962874..000000000 --- a/ui/app/components/account-menu/account-menu.component.js +++ /dev/null @@ -1,340 +0,0 @@ -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 '../tooltip' -import Identicon from '../identicon' -import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' -import { PRIMARY } from '../../constants/common' -import { - SETTINGS_ROUTE, - INFO_ROUTE, - NEW_ACCOUNT_ROUTE, - IMPORT_ACCOUNT_ROUTE, - CONNECT_HARDWARE_ROUTE, - DEFAULT_ROUTE, -} from '../../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/account-menu/account-menu.container.js b/ui/app/components/account-menu/account-menu.container.js deleted file mode 100644 index 93246ec72..000000000 --- a/ui/app/components/account-menu/account-menu.container.js +++ /dev/null @@ -1,62 +0,0 @@ -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 '../../actions' -import { getMetaMaskAccounts } from '../../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/account-panel.js b/ui/app/components/account-panel.js deleted file mode 100644 index a379ed3ac..000000000 --- a/ui/app/components/account-panel.js +++ /dev/null @@ -1,86 +0,0 @@ -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 - -module.exports = AccountPanel - - -inherits(AccountPanel, Component) -function AccountPanel () { - Component.call(this) -} - -AccountPanel.prototype.render = function () { - var state = this.props - var identity = state.identity || {} - var account = state.account || {} - var isFauceting = state.isFauceting - - var panelState = { - key: `accountPanel${identity.address}`, - identiconKey: identity.address, - identiconLabel: identity.name || '', - attributes: [ - { - key: 'ADDRESS', - value: addressSummary(identity.address), - }, - balanceOrFaucetingIndication(account, isFauceting), - ], - } - - return ( - - h('.identity-panel.flex-row.flex-space-between', { - style: { - flex: '1 0 auto', - cursor: panelState.onClick ? 'pointer' : undefined, - }, - onClick: panelState.onClick, - }, [ - - // account identicon - h('.identicon-wrapper.flex-column.select-none', [ - h(Identicon, { - address: panelState.identiconKey, - imageify: state.imageifyIdenticons, - }), - h('span.font-small', panelState.identiconLabel.substring(0, 7) + '...'), - ]), - - // account address, balance - h('.identity-data.flex-column.flex-justify-center.flex-grow.select-none', [ - - panelState.attributes.map((attr) => { - return h('.flex-row.flex-space-between', { - key: '' + Math.round(Math.random() * 1000000), - }, [ - h('label.font-small.no-select', attr.key), - h('span.font-small', attr.value), - ]) - }), - ]), - - ]) - - ) -} - -function balanceOrFaucetingIndication (account, isFauceting) { - // Temporarily deactivating isFauceting indication - // because it shows fauceting for empty restored accounts. - if (/* isFauceting*/ false) { - return { - key: 'Account is auto-funding.', - value: 'Please wait.', - } - } else { - return { - key: 'BALANCE', - value: formatBalance(account.balance), - } - } -} diff --git a/ui/app/components/app-header/app-header.component.js b/ui/app/components/app-header/app-header.component.js deleted file mode 100644 index 14f8b9f30..000000000 --- a/ui/app/components/app-header/app-header.component.js +++ /dev/null @@ -1,127 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import Identicon from '../identicon' -import { DEFAULT_ROUTE } from '../../routes' -const NetworkIndicator = require('../network') - -export default class AppHeader extends PureComponent { - static propTypes = { - history: PropTypes.object, - network: PropTypes.string, - provider: PropTypes.object, - networkDropdownOpen: PropTypes.bool, - showNetworkDropdown: PropTypes.func, - hideNetworkDropdown: PropTypes.func, - 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) { - event.preventDefault() - event.stopPropagation() - - const { networkDropdownOpen, showNetworkDropdown, hideNetworkDropdown } = this.props - - if (networkDropdownOpen === false) { - this.context.metricsEvent({ - eventOpts: { - category: 'Navigation', - action: 'Home', - name: 'Opened Network Menu', - }, - }) - showNetworkDropdown() - } else { - hideNetworkDropdown() - } - } - - renderAccountMenu () { - const { isUnlocked, toggleAccountMenu, selectedAddress, disabled, isAccountMenuOpen } = this.props - - return isUnlocked && ( - <div - className={classnames('account-menu__icon', { - 'account-menu__icon--disabled': disabled, - })} - onClick={() => { - if (!disabled) { - !isAccountMenuOpen && this.context.metricsEvent({ - eventOpts: { - category: 'Navigation', - action: 'Home', - name: 'Opened Main Menu', - }, - }) - toggleAccountMenu() - } - }} - > - <Identicon - address={selectedAddress} - diameter={32} - /> - </div> - ) - } - - render () { - const { - history, - network, - provider, - isUnlocked, - hideNetworkIndicator, - disabled, - } = this.props - - return ( - <div - className={classnames('app-header', { 'app-header--back-drop': isUnlocked })}> - <div className="app-header__contents"> - <div - className="app-header__logo-container" - onClick={() => history.push(DEFAULT_ROUTE)} - > - <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="app-header__account-menu-container"> - { - !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> - </div> - ) - } -} diff --git a/ui/app/components/app-header/app-header.container.js b/ui/app/components/app-header/app-header.container.js deleted file mode 100644 index 1abc2afeb..000000000 --- a/ui/app/components/app-header/app-header.container.js +++ /dev/null @@ -1,40 +0,0 @@ -import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' - -import AppHeader from './app-header.component' -const actions = require('../../actions') - -const mapStateToProps = state => { - const { appState, metamask } = state - const { networkDropdownOpen } = appState - const { - network, - provider, - selectedAddress, - isUnlocked, - isAccountMenuOpen, - } = metamask - - return { - networkDropdownOpen, - network, - provider, - selectedAddress, - isUnlocked, - isAccountMenuOpen, - } -} - -const mapDispatchToProps = dispatch => { - return { - showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), - hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), - toggleAccountMenu: () => dispatch(actions.toggleAccountMenu()), - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(AppHeader) diff --git a/ui/app/components/app/account-dropdowns.js b/ui/app/components/app/account-dropdowns.js new file mode 100644 index 000000000..e02d17e54 --- /dev/null +++ b/ui/app/components/app/account-dropdowns.js @@ -0,0 +1,338 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +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('../../helpers/utils/util') + +import Identicon from '../ui/identicon' + +class AccountDropdowns extends Component { + constructor (props) { + super(props) + this.state = { + accountSelectorActive: false, + optionsMenuActive: false, + } + this.accountSelectorToggleClassName = 'accounts-selector' + this.optionsMenuToggleClassName = 'fa-ellipsis-h' + } + + renderAccounts () { + const { identities, selected, keyrings } = this.props + + return Object.keys(identities).map((key, index) => { + const identity = identities[key] + const isSelected = identity.address === selected + + const simpleAddress = identity.address.substring(2).toLowerCase() + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(simpleAddress) || + kr.accounts.includes(identity.address) + }) + + return h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + this.props.actions.showAccountDetail(identity.address) + }, + style: { + marginTop: index === 0 ? '5px' : '', + fontSize: '24px', + }, + }, + [ + h( + Identicon, + { + address: identity.address, + diameter: 32, + style: { + marginLeft: '10px', + }, + }, + ), + this.indicateIfLoose(keyring), + h('span', { + style: { + marginLeft: '20px', + fontSize: '24px', + maxWidth: '145px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, identity.name || ''), + h('span', { style: { marginLeft: '20px', fontSize: '24px' } }, isSelected ? h('.check', '✓') : null), + ] + ) + }) + } + + indicateIfLoose (keyring) { + try { // Sometimes keyrings aren't loaded yet: + const type = keyring.type + const isLoose = type !== 'HD Key Tree' + return isLoose ? h('.keyring-label.allcaps', this.context.t('loose')) : null + } catch (e) { return } + } + + renderAccountSelector () { + const { actions } = this.props + const { accountSelectorActive } = this.state + + return h( + Dropdown, + { + useCssTransition: true, // Hardcoded because account selector is temporarily in app-header + style: { + marginLeft: '-238px', + marginTop: '38px', + minWidth: '180px', + overflowY: 'auto', + maxHeight: '300px', + width: '300px', + }, + innerStyle: { + padding: '8px 25px', + }, + isOpen: accountSelectorActive, + onClickOutside: (event) => { + const { classList } = event.target + const isNotToggleElement = !classList.contains(this.accountSelectorToggleClassName) + if (accountSelectorActive && isNotToggleElement) { + this.setState({ accountSelectorActive: false }) + } + }, + }, + [ + ...this.renderAccounts(), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => actions.addNewAccount(), + }, + [ + h( + Identicon, + { + style: { + marginLeft: '10px', + }, + diameter: 32, + }, + ), + h('span', { style: { marginLeft: '20px', fontSize: '24px' } }, this.context.t('createAccount')), + ], + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => actions.showImportPage(), + }, + [ + h( + Identicon, + { + style: { + marginLeft: '10px', + }, + diameter: 32, + }, + ), + h('span', { + style: { + marginLeft: '20px', + fontSize: '24px', + marginBottom: '5px', + }, + }, this.context.t('importAccount')), + ] + ), + ] + ) + } + + renderAccountOptions () { + const { actions } = this.props + const { optionsMenuActive } = this.state + + return h( + Dropdown, + { + style: { + marginLeft: '-215px', + minWidth: '180px', + }, + isOpen: optionsMenuActive, + onClickOutside: (event) => { + const { classList } = event.target + const isNotToggleElement = !classList.contains(this.optionsMenuToggleClassName) + if (optionsMenuActive && isNotToggleElement) { + this.setState({ optionsMenuActive: false }) + } + }, + }, + [ + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + const { selected, network } = this.props + const url = genAccountLink(selected, network) + global.platform.openWindow({ url }) + }, + }, + this.context.t('etherscanView'), + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + const { selected, identities } = this.props + var identity = identities[selected] + actions.showQrView(selected, identity ? identity.name : '') + }, + }, + this.context.t('showQRCode'), + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + const { selected } = this.props + copyToClipboard(checksumAddress(selected)) + }, + }, + this.context.t('copyAddress'), + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + actions.requestAccountExport() + }, + }, + this.context.t('exportPrivateKey'), + ), + ] + ) + } + + render () { + const { metricsEvent } = this.context + const { style, enableAccountsSelector, enableAccountOptions } = this.props + const { optionsMenuActive, accountSelectorActive } = this.state + + return h( + 'span', + { + style: style, + }, + [ + enableAccountsSelector && h( + // 'i.fa.fa-angle-down', + 'div.cursor-pointer.color-orange.accounts-selector', + { + style: { + // fontSize: '1.8em', + background: 'url(images/switch_acc.svg) white center center no-repeat', + height: '25px', + width: '25px', + transform: 'scale(0.75)', + marginRight: '3px', + }, + onClick: (event) => { + event.stopPropagation() + this.setState({ + accountSelectorActive: !accountSelectorActive, + optionsMenuActive: false, + }) + }, + }, + this.renderAccountSelector(), + ), + enableAccountOptions && h( + 'i.fa.fa-ellipsis-h', + { + style: { + marginRight: '0.5em', + fontSize: '1.8em', + }, + onClick: (event) => { + metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'userClick', + name: 'accountsOpenedMenu', + }, + pageOpts: { + section: 'header', + component: 'accountDropdownIcon', + }, + }) + event.stopPropagation() + this.setState({ + accountSelectorActive: false, + optionsMenuActive: !optionsMenuActive, + }) + }, + }, + this.renderAccountOptions() + ), + ] + ) + } +} + +AccountDropdowns.defaultProps = { + enableAccountsSelector: false, + enableAccountOptions: false, +} + +AccountDropdowns.propTypes = { + identities: PropTypes.objectOf(PropTypes.object), + selected: PropTypes.string, + keyrings: PropTypes.array, + actions: PropTypes.objectOf(PropTypes.func), + network: PropTypes.string, + style: PropTypes.object, + enableAccountOptions: PropTypes.bool, + enableAccountsSelector: PropTypes.bool, + t: PropTypes.func, +} + +const mapDispatchToProps = (dispatch) => { + return { + actions: { + showConfigPage: () => dispatch(actions.showConfigPage()), + requestAccountExport: () => dispatch(actions.requestExportAccount()), + showAccountDetail: (address) => dispatch(actions.showAccountDetail(address)), + addNewAccount: () => dispatch(actions.addNewAccount()), + showImportPage: () => dispatch(actions.showImportPage()), + showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), + }, + } +} + +AccountDropdowns.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +module.exports = { + AccountDropdowns: connect(null, mapDispatchToProps)(AccountDropdowns), +} 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/account-menu/index.js b/ui/app/components/app/account-menu/index.js index b2b4e4c6f..b2b4e4c6f 100644 --- a/ui/app/components/account-menu/index.js +++ b/ui/app/components/app/account-menu/index.js diff --git a/ui/app/components/account-menu/index.scss b/ui/app/components/app/account-menu/index.scss index 9a61bf887..9a61bf887 100644 --- a/ui/app/components/account-menu/index.scss +++ b/ui/app/components/app/account-menu/index.scss diff --git a/ui/app/components/app/account-panel.js b/ui/app/components/app/account-panel.js new file mode 100644 index 000000000..79882f34a --- /dev/null +++ b/ui/app/components/app/account-panel.js @@ -0,0 +1,86 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +import Identicon from '../ui/identicon' +const formatBalance = require('../../helpers/utils/util').formatBalance +const addressSummary = require('../../helpers/utils/util').addressSummary + +module.exports = AccountPanel + + +inherits(AccountPanel, Component) +function AccountPanel () { + Component.call(this) +} + +AccountPanel.prototype.render = function () { + var state = this.props + var identity = state.identity || {} + var account = state.account || {} + var isFauceting = state.isFauceting + + var panelState = { + key: `accountPanel${identity.address}`, + identiconKey: identity.address, + identiconLabel: identity.name || '', + attributes: [ + { + key: 'ADDRESS', + value: addressSummary(identity.address), + }, + balanceOrFaucetingIndication(account, isFauceting), + ], + } + + return ( + + h('.identity-panel.flex-row.flex-space-between', { + style: { + flex: '1 0 auto', + cursor: panelState.onClick ? 'pointer' : undefined, + }, + onClick: panelState.onClick, + }, [ + + // account identicon + h('.identicon-wrapper.flex-column.select-none', [ + h(Identicon, { + address: panelState.identiconKey, + imageify: state.imageifyIdenticons, + }), + h('span.font-small', panelState.identiconLabel.substring(0, 7) + '...'), + ]), + + // account address, balance + h('.identity-data.flex-column.flex-justify-center.flex-grow.select-none', [ + + panelState.attributes.map((attr) => { + return h('.flex-row.flex-space-between', { + key: '' + Math.round(Math.random() * 1000000), + }, [ + h('label.font-small.no-select', attr.key), + h('span.font-small', attr.value), + ]) + }), + ]), + + ]) + + ) +} + +function balanceOrFaucetingIndication (account, isFauceting) { + // Temporarily deactivating isFauceting indication + // because it shows fauceting for empty restored accounts. + if (/* isFauceting*/ false) { + return { + key: 'Account is auto-funding.', + value: 'Please wait.', + } + } else { + return { + key: 'BALANCE', + value: formatBalance(account.balance), + } + } +} 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/app-header/app-header.component.js b/ui/app/components/app/app-header/app-header.component.js new file mode 100644 index 000000000..343e0daab --- /dev/null +++ b/ui/app/components/app/app-header/app-header.component.js @@ -0,0 +1,127 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +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, + network: PropTypes.string, + provider: PropTypes.object, + networkDropdownOpen: PropTypes.bool, + showNetworkDropdown: PropTypes.func, + hideNetworkDropdown: PropTypes.func, + 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) { + event.preventDefault() + event.stopPropagation() + + const { networkDropdownOpen, showNetworkDropdown, hideNetworkDropdown } = this.props + + if (networkDropdownOpen === false) { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Network Menu', + }, + }) + showNetworkDropdown() + } else { + hideNetworkDropdown() + } + } + + renderAccountMenu () { + const { isUnlocked, toggleAccountMenu, selectedAddress, disabled, isAccountMenuOpen } = this.props + + return isUnlocked && ( + <div + className={classnames('account-menu__icon', { + 'account-menu__icon--disabled': disabled, + })} + onClick={() => { + if (!disabled) { + !isAccountMenuOpen && this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Main Menu', + }, + }) + toggleAccountMenu() + } + }} + > + <Identicon + address={selectedAddress} + diameter={32} + /> + </div> + ) + } + + render () { + const { + history, + network, + provider, + isUnlocked, + hideNetworkIndicator, + disabled, + } = this.props + + return ( + <div + className={classnames('app-header', { 'app-header--back-drop': isUnlocked })}> + <div className="app-header__contents"> + <div + className="app-header__logo-container" + onClick={() => history.push(DEFAULT_ROUTE)} + > + <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="app-header__account-menu-container"> + { + !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> + </div> + ) + } +} diff --git a/ui/app/components/app/app-header/app-header.container.js b/ui/app/components/app/app-header/app-header.container.js new file mode 100644 index 000000000..b67338245 --- /dev/null +++ b/ui/app/components/app/app-header/app-header.container.js @@ -0,0 +1,40 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' + +import AppHeader from './app-header.component' +const actions = require('../../../store/actions') + +const mapStateToProps = state => { + const { appState, metamask } = state + const { networkDropdownOpen } = appState + const { + network, + provider, + selectedAddress, + isUnlocked, + isAccountMenuOpen, + } = metamask + + return { + networkDropdownOpen, + network, + provider, + selectedAddress, + isUnlocked, + isAccountMenuOpen, + } +} + +const mapDispatchToProps = dispatch => { + return { + showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), + hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), + toggleAccountMenu: () => dispatch(actions.toggleAccountMenu()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(AppHeader) 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/app/coinbase-form.js b/ui/app/components/app/coinbase-form.js new file mode 100644 index 000000000..24d287604 --- /dev/null +++ b/ui/app/components/app/coinbase-form.js @@ -0,0 +1,69 @@ +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 +const actions = require('../../store/actions') + +CoinbaseForm.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps)(CoinbaseForm) + + +function mapStateToProps (state) { + return { + warning: state.appState.warning, + } +} + +inherits(CoinbaseForm, Component) + +function CoinbaseForm () { + Component.call(this) +} + +CoinbaseForm.prototype.render = function () { + var props = this.props + + return h('.flex-column', { + style: { + marginTop: '35px', + padding: '25px', + width: '100%', + }, + }, [ + h('.flex-row', { + style: { + justifyContent: 'space-around', + margin: '33px', + marginTop: '0px', + }, + }, [ + h('button.btn-green', { + onClick: this.toCoinbase.bind(this), + }, this.context.t('continueToCoinbase')), + + h('button.btn-red', { + onClick: () => props.dispatch(actions.goHome()), + }, this.context.t('cancel')), + ]), + ]) +} + +CoinbaseForm.prototype.toCoinbase = function () { + const props = this.props + const address = props.buyView.buyAddress + props.dispatch(actions.buyEth({ network: '1', address, amount: 0 })) +} + +CoinbaseForm.prototype.renderLoading = function () { + return h('img', { + style: { + width: '27px', + marginRight: '-27px', + }, + src: 'images/loading.svg', + }) +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..18571eccb --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js @@ -0,0 +1,84 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' +import { PRIMARY, SECONDARY } from '../../../../helpers/constants/common' + +const ConfirmDetailRow = props => { + const { + label, + primaryText, + secondaryText, + onHeaderClick, + primaryValueTextColor, + headerText, + headerTextClassName, + value, + } = props + + return ( + <div className="confirm-detail-row"> + <div className="confirm-detail-row__label"> + { label } + </div> + <div className="confirm-detail-row__details"> + <div + className={classnames('confirm-detail-row__header-text', headerTextClassName)} + onClick={() => onHeaderClick && onHeaderClick()} + > + { headerText } + </div> + { + primaryText + ? ( + <div + className="confirm-detail-row__primary" + style={{ color: primaryValueTextColor }} + > + { primaryText } + </div> + ) : ( + <UserPreferencedCurrencyDisplay + className="confirm-detail-row__primary" + type={PRIMARY} + value={value} + showEthLogo + ethLogoHeight="18" + style={{ color: primaryValueTextColor }} + hideLabel + /> + ) + } + { + secondaryText + ? ( + <div className="confirm-detail-row__secondary"> + { secondaryText } + </div> + ) : ( + <UserPreferencedCurrencyDisplay + className="confirm-detail-row__secondary" + type={SECONDARY} + value={value} + showEthLogo + hideLabel + /> + ) + } + </div> + </div> + ) +} + +ConfirmDetailRow.propTypes = { + headerText: PropTypes.string, + headerTextClassName: PropTypes.string, + label: PropTypes.string, + onHeaderClick: PropTypes.func, + primaryValueTextColor: PropTypes.string, + primaryText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + secondaryText: PropTypes.string, + value: PropTypes.string, +} + +export default ConfirmDetailRow 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 1672ef8c6..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 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/app/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 new file mode 100644 index 000000000..8a5f90c76 --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -0,0 +1,110 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { Tabs, Tab } from '../../../ui/tabs' +import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.' +import ErrorMessage from '../../../ui/error-message' + +export default class ConfirmPageContainerContent extends Component { + static propTypes = { + action: PropTypes.string, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + hideSubtitle: PropTypes.bool, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, + assetImage: PropTypes.string, + subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + subtitleComponent: PropTypes.node, + summaryComponent: PropTypes.node, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + titleComponent: PropTypes.node, + warning: PropTypes.string, + } + + renderContent () { + const { detailsComponent, dataComponent } = this.props + + if (detailsComponent && dataComponent) { + return this.renderTabs() + } else { + return detailsComponent || dataComponent + } + } + + renderTabs () { + const { detailsComponent, dataComponent } = this.props + + return ( + <Tabs> + <Tab name="Details"> + { detailsComponent } + </Tab> + <Tab name="Data"> + { dataComponent } + </Tab> + </Tabs> + ) + } + + render () { + const { + action, + errorKey, + errorMessage, + title, + titleComponent, + subtitle, + subtitleComponent, + hideSubtitle, + identiconAddress, + nonce, + assetImage, + summaryComponent, + detailsComponent, + dataComponent, + warning, + } = this.props + + return ( + <div className="confirm-page-container-content"> + { + warning && ( + <ConfirmPageContainerWarning warning={warning} /> + ) + } + { + summaryComponent || ( + <ConfirmPageContainerSummary + className={classnames({ + 'confirm-page-container-summary--border': !detailsComponent || !dataComponent, + })} + action={action} + title={title} + titleComponent={titleComponent} + subtitle={subtitle} + subtitleComponent={subtitleComponent} + hideSubtitle={hideSubtitle} + identiconAddress={identiconAddress} + nonce={nonce} + assetImage={assetImage} + /> + ) + } + { this.renderContent() } + { + (errorKey || errorMessage) && ( + <div className="confirm-page-container-content__error-container"> + <ErrorMessage + errorMessage={errorMessage} + errorKey={errorKey} + /> + </div> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..0cc4d8262 --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js @@ -0,0 +1,71 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Identicon from '../../../../ui/identicon' + +const ConfirmPageContainerSummary = props => { + const { + action, + title, + titleComponent, + subtitle, + subtitleComponent, + hideSubtitle, + className, + identiconAddress, + nonce, + assetImage, + } = props + + return ( + <div className={classnames('confirm-page-container-summary', className)}> + <div className="confirm-page-container-summary__action-row"> + <div className="confirm-page-container-summary__action"> + { action } + </div> + { + nonce && ( + <div className="confirm-page-container-summary__nonce"> + { `#${nonce}` } + </div> + ) + } + </div> + <div className="confirm-page-container-summary__title"> + { + identiconAddress && ( + <Identicon + className="confirm-page-container-summary__identicon" + diameter={36} + address={identiconAddress} + image={assetImage} + /> + ) + } + <div className="confirm-page-container-summary__title-text"> + { titleComponent || title } + </div> + </div> + { + hideSubtitle || <div className="confirm-page-container-summary__subtitle"> + { subtitleComponent || subtitle } + </div> + } + </div> + ) +} + +ConfirmPageContainerSummary.propTypes = { + action: PropTypes.string, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + titleComponent: PropTypes.node, + subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + subtitleComponent: PropTypes.node, + hideSubtitle: PropTypes.bool, + className: PropTypes.string, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, + assetImage: PropTypes.string, +} + +export default ConfirmPageContainerSummary 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/app/confirm-page-container/confirm-page-container-content/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss new file mode 100644 index 000000000..602a46848 --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss @@ -0,0 +1,68 @@ +@import 'confirm-page-container-warning/index'; + +@import 'confirm-page-container-summary/index'; + +.confirm-page-container-content { + overflow-y: auto; + flex: 1; + + &__error-container { + padding: 0 16px 16px 16px; + } + + &__details { + box-sizing: border-box; + padding: 0 24px; + } + + &__data { + padding: 16px; + color: $oslo-gray; + } + + &__data-box { + background-color: #f9fafa; + padding: 12px; + font-size: .75rem; + margin-bottom: 16px; + word-wrap: break-word; + max-height: 200px; + overflow-y: auto; + + &-label { + text-transform: uppercase; + padding: 8px 0 12px; + font-size: 12px; + } + } + + &__data-field { + display: flex; + flex-direction: row; + + &-label { + font-weight: 500; + padding-right: 16px; + } + + &:not(:last-child) { + margin-bottom: 5px; + } + } + + &__gas-fee { + border-bottom: 1px solid $geyser; + + .advanced-gas-inputs__gas-edit-rows { + margin-bottom: 16px; + } + } + + &__function-type { + font-size: .875rem; + font-weight: 500; + text-transform: capitalize; + color: $black; + padding-left: 5px; + } +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..84ca40da5 --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { + ENVIRONMENT_TYPE_POPUP, + ENVIRONMENT_TYPE_NOTIFICATION, +} from '../../../../../../app/scripts/lib/enums' +import NetworkDisplay from '../../network-display' + +export default class ConfirmPageContainer extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + showEdit: PropTypes.bool, + onEdit: PropTypes.func, + children: PropTypes.node, + } + + renderTop () { + const { onEdit, showEdit } = this.props + const windowType = window.METAMASK_UI_TYPE + const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && + windowType !== ENVIRONMENT_TYPE_POPUP + + if (!showEdit && isFullScreen) { + return null + } + + return ( + <div className="confirm-page-container-header__row"> + <div + className="confirm-page-container-header__back-button-container" + style={{ + visibility: showEdit ? 'initial' : 'hidden', + }} + > + <img + src="/images/caret-left.svg" + /> + <span + className="confirm-page-container-header__back-button" + onClick={() => onEdit()} + > + { this.context.t('edit') } + </span> + </div> + { !isFullScreen && <NetworkDisplay /> } + </div> + ) + } + + render () { + const { children } = this.props + + return ( + <div className="confirm-page-container-header"> + { this.renderTop() } + { children } + </div> + ) + } +} 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 be77edbdf..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 diff --git a/ui/app/components/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 index 8327f997b..8327f997b 100755 --- a/ui/app/components/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 diff --git a/ui/app/components/confirm-page-container/confirm-page-container-navigation/index.js b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.js index d97c1b447..d97c1b447 100755 --- a/ui/app/components/confirm-page-container/confirm-page-container-navigation/index.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.js diff --git a/ui/app/components/confirm-page-container/confirm-page-container-navigation/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.scss index 0cf184c60..0cf184c60 100755 --- a/ui/app/components/confirm-page-container/confirm-page-container-navigation/index.scss +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-navigation/index.scss diff --git a/ui/app/components/app/confirm-page-container/confirm-page-container.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js new file mode 100644 index 000000000..326e4f83e --- /dev/null +++ b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js @@ -0,0 +1,170 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +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 = { + t: PropTypes.func, + } + + static propTypes = { + // Header + action: PropTypes.string, + hideSubtitle: PropTypes.bool, + onEdit: PropTypes.func, + showEdit: PropTypes.bool, + subtitle: PropTypes.string, + subtitleComponent: PropTypes.node, + title: PropTypes.string, + titleComponent: PropTypes.node, + // Sender to Recipient + fromAddress: PropTypes.string, + fromName: PropTypes.string, + toAddress: PropTypes.string, + toName: PropTypes.string, + // Content + contentComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + fiatTransactionAmount: PropTypes.string, + fiatTransactionFee: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + ethTransactionAmount: PropTypes.string, + ethTransactionFee: PropTypes.string, + ethTransactionTotal: PropTypes.string, + onEditGas: PropTypes.func, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, + assetImage: PropTypes.string, + 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, + onSubmit: PropTypes.func, + disabled: PropTypes.bool, + } + + render () { + const { + showEdit, + onEdit, + fromName, + fromAddress, + toName, + toAddress, + disabled, + errorKey, + errorMessage, + contentComponent, + action, + title, + titleComponent, + subtitle, + subtitleComponent, + hideSubtitle, + summaryComponent, + detailsComponent, + dataComponent, + onCancelAll, + onCancel, + onSubmit, + identiconAddress, + nonce, + 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()} + > + <SenderToRecipient + senderName={fromName} + senderAddress={fromAddress} + recipientName={toName} + recipientAddress={toAddress} + assetImage={renderAssetImage ? assetImage : undefined} + /> + </ConfirmPageContainerHeader> + { + contentComponent || ( + <ConfirmPageContainerContent + action={action} + title={title} + titleComponent={titleComponent} + subtitle={subtitle} + subtitleComponent={subtitleComponent} + hideSubtitle={hideSubtitle} + summaryComponent={summaryComponent} + detailsComponent={detailsComponent} + dataComponent={dataComponent} + errorMessage={errorMessage} + errorKey={errorKey} + identiconAddress={identiconAddress} + nonce={nonce} + assetImage={assetImage} + warning={warning} + /> + ) + } + <PageContainerFooter + onCancel={() => onCancel()} + cancelText={this.context.t('reject')} + onSubmit={() => onSubmit()} + submitText={this.context.t('confirm')} + submitButtonType="confirm" + disabled={disabled} + > + {unapprovedTxCount > 1 && ( + <a onClick={() => onCancelAll()}> + {this.context.t('rejectTxsN', [unapprovedTxCount])} + </a> + )} + </PageContainerFooter> + </div> + ) + } +} diff --git a/ui/app/components/confirm-page-container/index.js b/ui/app/components/app/confirm-page-container/index.js index 28b17614e..28b17614e 100644 --- a/ui/app/components/confirm-page-container/index.js +++ b/ui/app/components/app/confirm-page-container/index.js 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/app/copyable.js b/ui/app/components/app/copyable.js new file mode 100644 index 000000000..6869d674d --- /dev/null +++ b/ui/app/components/app/copyable.js @@ -0,0 +1,53 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const inherits = require('util').inherits + +const Tooltip = require('../ui/tooltip') +const copyToClipboard = require('copy-to-clipboard') +const connect = require('react-redux').connect + +Copyable.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect()(Copyable) + + +inherits(Copyable, Component) +function Copyable () { + Component.call(this) + this.state = { + copied: false, + } +} + +Copyable.prototype.render = function () { + const props = this.props + const state = this.state + const { value, children } = props + const { copied } = state + + return h(Tooltip, { + title: copied ? this.context.t('copiedExclamation') : this.context.t('copy'), + position: 'bottom', + }, h('span', { + style: { + cursor: 'pointer', + }, + onClick: (event) => { + event.preventDefault() + event.stopPropagation() + copyToClipboard(value) + this.debounceRestore() + }, + }, children)) +} + +Copyable.prototype.debounceRestore = function () { + this.setState({ copied: true }) + clearTimeout(this.timeout) + this.timeout = setTimeout(() => { + this.setState({ copied: false }) + }, 850) +} 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/app/customize-gas-modal/index.js b/ui/app/components/app/customize-gas-modal/index.js new file mode 100644 index 000000000..dca77bb00 --- /dev/null +++ b/ui/app/components/app/customize-gas-modal/index.js @@ -0,0 +1,396 @@ +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 +const BigNumber = require('bignumber.js') +const actions = require('../../../store/actions') +const GasModalCard = require('./gas-modal-card') +import Button from '../../ui/button' + +const ethUtil = require('ethereumjs-util') + +import { + updateSendErrors, +} from '../../../ducks/send/send.duck' + +const { + MIN_GAS_PRICE_DEC, + MIN_GAS_LIMIT_DEC, + MIN_GAS_PRICE_GWEI, +} = require('../send/send.constants') + +const { + isBalanceSufficient, +} = require('../send/send.utils') + +const { + conversionUtil, + multiplyCurrencies, + conversionGreaterThan, + conversionMax, + subtractCurrencies, +} = require('../../../helpers/utils/conversion-util') + +const { + getGasIsLoading, + getForceGasMin, + conversionRateSelector, + getSendAmount, + getSelectedToken, + getSendFrom, + getCurrentAccountWithSendEtherInfo, + getSelectedTokenToFiatRate, + getSendMaxModeState, +} = require('../../../selectors/selectors') + +const { + getGasPrice, + getGasLimit, +} = require('../send/send.selectors') + +function mapStateToProps (state) { + const selectedToken = getSelectedToken(state) + const currentAccount = getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state) + const conversionRate = conversionRateSelector(state) + + return { + gasPrice: getGasPrice(state), + gasLimit: getGasLimit(state), + gasIsLoading: getGasIsLoading(state), + forceGasMin: getForceGasMin(state), + conversionRate, + amount: getSendAmount(state), + maxModeOn: getSendMaxModeState(state), + balance: currentAccount.balance, + primaryCurrency: selectedToken && selectedToken.symbol, + selectedToken, + amountConversionRate: selectedToken ? getSelectedTokenToFiatRate(state) : conversionRate, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => dispatch(actions.hideModal()), + setGasPrice: newGasPrice => dispatch(actions.setGasPrice(newGasPrice)), + setGasLimit: newGasLimit => dispatch(actions.setGasLimit(newGasLimit)), + setGasTotal: newGasTotal => dispatch(actions.setGasTotal(newGasTotal)), + updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)), + updateSendErrors: error => dispatch(updateSendErrors(error)), + } +} + +function getFreshState (props) { + const gasPrice = props.gasPrice || MIN_GAS_PRICE_DEC + const gasLimit = props.gasLimit || MIN_GAS_LIMIT_DEC + + const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) + + return { + gasPrice, + gasLimit, + gasTotal, + error: null, + priceSigZeros: '', + priceSigDec: '', + } +} + +inherits(CustomizeGasModal, Component) +function CustomizeGasModal (props) { + Component.call(this) + + const originalState = getFreshState(props) + this.state = { + ...originalState, + originalState, + } +} + +CustomizeGasModal.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal) + +CustomizeGasModal.prototype.componentWillReceiveProps = function (nextProps) { + const currentState = getFreshState(this.props) + const { + gasPrice: currentGasPrice, + gasLimit: currentGasLimit, + } = currentState + const newState = getFreshState(nextProps) + const { + gasPrice: newGasPrice, + gasLimit: newGasLimit, + gasTotal: newGasTotal, + } = newState + const gasPriceChanged = currentGasPrice !== newGasPrice + const gasLimitChanged = currentGasLimit !== newGasLimit + + if (gasPriceChanged) { + this.setState({ + gasPrice: newGasPrice, + gasTotal: newGasTotal, + priceSigZeros: '', + priceSigDec: '', + }) + } + if (gasLimitChanged) { + this.setState({ gasLimit: newGasLimit, gasTotal: newGasTotal }) + } + if (gasLimitChanged || gasPriceChanged) { + this.validate({ gasLimit: newGasLimit, gasTotal: newGasTotal }) + } +} + +CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { + const { metricsEvent } = this.context + const { + setGasPrice, + setGasLimit, + hideModal, + setGasTotal, + maxModeOn, + selectedToken, + balance, + updateSendAmount, + updateSendErrors, + } = this.props + const { + originalState, + } = this.state + + if (maxModeOn && !selectedToken) { + const maxAmount = subtractCurrencies( + ethUtil.addHexPrefix(balance), + ethUtil.addHexPrefix(gasTotal), + { toNumericBase: 'hex' } + ) + 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)) + updateSendErrors({ insufficientFunds: false }) + hideModal() +} + +CustomizeGasModal.prototype.revert = function () { + this.setState(this.state.originalState) +} + +CustomizeGasModal.prototype.validate = function ({ gasTotal, gasLimit }) { + const { + amount, + balance, + selectedToken, + amountConversionRate, + conversionRate, + maxModeOn, + } = this.props + + let error = null + + const balanceIsSufficient = isBalanceSufficient({ + amount: selectedToken || maxModeOn ? '0' : amount, + gasTotal, + balance, + selectedToken, + amountConversionRate, + conversionRate, + }) + + if (!balanceIsSufficient) { + error = this.context.t('balanceIsInsufficientGas') + } + + const gasLimitTooLow = gasLimit && conversionGreaterThan( + { + value: MIN_GAS_LIMIT_DEC, + fromNumericBase: 'dec', + conversionRate, + }, + { + value: gasLimit, + fromNumericBase: 'hex', + }, + ) + + if (gasLimitTooLow) { + error = this.context.t('gasLimitTooLow') + } + + this.setState({ error }) + return error +} + +CustomizeGasModal.prototype.convertAndSetGasLimit = function (newGasLimit) { + const { gasPrice } = this.state + + const gasLimit = conversionUtil(newGasLimit, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) + + const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) + + this.validate({ gasTotal, gasLimit }) + + this.setState({ gasTotal, gasLimit }) +} + +CustomizeGasModal.prototype.convertAndSetGasPrice = function (newGasPrice) { + const { gasLimit } = this.state + const sigZeros = String(newGasPrice).match(/^\d+[.]\d*?(0+)$/) + const sigDec = String(newGasPrice).match(/^\d+([.])0*$/) + + this.setState({ + priceSigZeros: sigZeros && sigZeros[1] || '', + priceSigDec: sigDec && sigDec[1] || '', + }) + + const gasPrice = conversionUtil(newGasPrice, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + }) + + const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) + + this.validate({ gasTotal }) + + this.setState({ gasTotal, gasPrice }) +} + +CustomizeGasModal.prototype.render = function () { + const { hideModal, forceGasMin, gasIsLoading } = this.props + const { gasPrice, gasLimit, gasTotal, error, priceSigZeros, priceSigDec } = this.state + + let convertedGasPrice = conversionUtil(gasPrice, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + toDenomination: 'GWEI', + }) + + convertedGasPrice += convertedGasPrice.match(/[.]/) ? priceSigZeros : `${priceSigDec}${priceSigZeros}` + + let newGasPrice = gasPrice + if (forceGasMin) { + const convertedMinPrice = conversionUtil(forceGasMin, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + convertedGasPrice = conversionMax( + { value: convertedMinPrice, fromNumericBase: 'dec' }, + { value: convertedGasPrice, fromNumericBase: 'dec' } + ) + newGasPrice = conversionMax( + { value: gasPrice, fromNumericBase: 'hex' }, + { value: forceGasMin, fromNumericBase: 'hex' } + ) + } + + const convertedGasLimit = conversionUtil(gasLimit, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + + return !gasIsLoading && h('div.send-v2__customize-gas', {}, [ + h('div.send-v2__customize-gas__content', { + }, [ + h('div.send-v2__customize-gas__header', {}, [ + + h('div.send-v2__customize-gas__title', this.context.t('customGas')), + + h('div.send-v2__customize-gas__close', { + onClick: hideModal, + }), + + ]), + + h('div.send-v2__customize-gas__body', {}, [ + + h(GasModalCard, { + value: convertedGasPrice, + min: forceGasMin || MIN_GAS_PRICE_GWEI, + step: 1, + onChange: value => this.convertAndSetGasPrice(value), + title: this.context.t('gasPrice'), + copy: this.context.t('gasPriceCalculation'), + gasIsLoading, + }), + + h(GasModalCard, { + value: convertedGasLimit, + min: 1, + step: 1, + onChange: value => this.convertAndSetGasLimit(value), + title: this.context.t('gasLimit'), + copy: this.context.t('gasLimitCalculation'), + gasIsLoading, + }), + + ]), + + h('div.send-v2__customize-gas__footer', {}, [ + + error && h('div.send-v2__customize-gas__error-message', [ + error, + ]), + + h('div.send-v2__customize-gas__revert', { + onClick: () => this.revert(), + }, [this.context.t('revert')]), + + h('div.send-v2__customize-gas__buttons', [ + h(Button, { + type: 'default', + className: 'send-v2__customize-gas__cancel', + onClick: this.props.hideModal, + }, [this.context.t('cancel')]), + h(Button, { + type: 'primary', + className: 'send-v2__customize-gas__save', + onClick: () => !error && this.save(newGasPrice, gasLimit, gasTotal), + disabled: error, + }, [this.context.t('save')]), + ]), + + ]), + + ]), + ]) +} diff --git a/ui/app/components/app/dropdowns/account-details-dropdown.js b/ui/app/components/app/dropdowns/account-details-dropdown.js new file mode 100644 index 000000000..3d4598946 --- /dev/null +++ b/ui/app/components/app/dropdowns/account-details-dropdown.js @@ -0,0 +1,131 @@ +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 +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) + +function mapStateToProps (state) { + return { + selectedIdentity: getSelectedIdentity(state), + network: state.metamask.network, + keyrings: state.metamask.keyrings, + } +} + +function mapDispatchToProps (dispatch) { + return { + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + viewOnEtherscan: (address, network) => { + global.platform.openWindow({ url: genAccountLink(address, network) }) + }, + showRemoveAccountConfirmationModal: (identity) => { + return dispatch(actions.showModal({ name: 'CONFIRM_REMOVE_ACCOUNT', identity })) + }, + } +} + +inherits(AccountDetailsDropdown, Component) +function AccountDetailsDropdown () { + Component.call(this) + + this.onClose = this.onClose.bind(this) +} + +AccountDetailsDropdown.prototype.onClose = function (e) { + e.stopPropagation() + this.props.onClose() +} + +AccountDetailsDropdown.prototype.render = function () { + const { + selectedIdentity, + network, + keyrings, + showAccountDetailModal, + viewOnEtherscan, + showRemoveAccountConfirmationModal } = this.props + + const address = selectedIdentity.address + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(address) + }) + + const isRemovable = keyring.type !== 'HD Key Tree' + + return h(Menu, { className: 'account-details-dropdown', isShowing: true }, [ + h(CloseArea, { + onClick: this.onClose, + }), + 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() + }, + text: this.context.t('expandView'), + icon: h(`img`, { src: 'images/expand.svg', style: { height: '15px' } }), + }), + h(Item, { + 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'), + icon: h(`img`, { src: 'images/info.svg', style: { height: '15px' } }), + }), + 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() + }, + text: this.context.t('viewOnEtherscan'), + icon: h(`img`, { src: 'images/open-etherscan.svg', style: { height: '15px' } }), + }), + isRemovable ? h(Item, { + onClick: (e) => { + e.stopPropagation() + showRemoveAccountConfirmationModal(selectedIdentity) + this.props.onClose() + }, + text: this.context.t('removeAccount'), + icon: h(`img`, { src: 'images/hide.svg', style: { height: '15px' } }), + }) : null, + ]) +} diff --git a/ui/app/components/app/dropdowns/components/account-dropdowns.js b/ui/app/components/app/dropdowns/components/account-dropdowns.js new file mode 100644 index 000000000..c603a9a9f --- /dev/null +++ b/ui/app/components/app/dropdowns/components/account-dropdowns.js @@ -0,0 +1,473 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +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 '../../../ui/identicon' +const { checksumAddress } = require('../../../../helpers/utils/util') +const copyToClipboard = require('copy-to-clipboard') +const { formatBalance } = require('../../../../helpers/utils/util') + + +class AccountDropdowns extends Component { + constructor (props) { + super(props) + this.state = { + accountSelectorActive: false, + optionsMenuActive: false, + } + // Used for orangeaccount selector icon + // this.accountSelectorToggleClassName = 'accounts-selector' + this.accountSelectorToggleClassName = 'fa-angle-down' + this.optionsMenuToggleClassName = 'fa-ellipsis-h' + } + + renderAccounts () { + const { identities, accounts, selected, menuItemStyles, actions, keyrings, ticker } = this.props + + return Object.keys(identities).map((key, index) => { + const identity = identities[key] + const isSelected = identity.address === selected + + const balanceValue = accounts[key].balance + const formattedBalance = balanceValue ? formatBalance(balanceValue, 6, true, ticker) : '...' + const simpleAddress = identity.address.substring(2).toLowerCase() + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(simpleAddress) || + kr.accounts.includes(identity.address) + }) + + return h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + this.props.actions.showAccountDetail(identity.address) + }, + style: Object.assign( + { + marginTop: index === 0 ? '5px' : '', + fontSize: '24px', + width: '260px', + }, + menuItemStyles, + ), + }, + [ + h('div.flex-row.flex-center', {}, [ + + h('span', { + style: { + flex: '1 1 0', + minWidth: '20px', + minHeight: '30px', + }, + }, [ + h('span', { + style: { + flex: '1 1 auto', + fontSize: '14px', + }, + }, isSelected ? h('i.fa.fa-check') : null), + ]), + + h( + Identicon, + { + address: identity.address, + diameter: 24, + style: { + flex: '1 1 auto', + marginLeft: '10px', + }, + }, + ), + + h('span.flex-column', { + style: { + flex: '10 10 auto', + width: '175px', + alignItems: 'flex-start', + justifyContent: 'center', + marginLeft: '10px', + position: 'relative', + }, + }, [ + this.indicateIfLoose(keyring), + h('span.account-dropdown-name', { + style: { + fontSize: '18px', + maxWidth: '145px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, identity.name || ''), + + h('span.account-dropdown-balance', { + style: { + fontSize: '14px', + fontFamily: 'Avenir', + fontWeight: 500, + }, + }, formattedBalance), + ]), + + h('span', { + style: { + flex: '3 3 auto', + }, + }, [ + h('span.account-dropdown-edit-button.allcaps', { + style: { + fontSize: '16px', + }, + onClick: () => { + actions.showEditAccountModal(identity) + }, + }, [ + this.context.t('edit'), + ]), + ]), + + ]), + ] + ) + }) + } + + indicateIfLoose (keyring) { + try { // Sometimes keyrings aren't loaded yet: + const type = keyring.type + const isLoose = type !== 'HD Key Tree' + return isLoose ? h('.keyring-label.allcaps', this.context.t('loose')) : null + } catch (e) { return } + } + + renderAccountSelector () { + const { actions, useCssTransition, innerStyle, sidebarOpen } = this.props + const { accountSelectorActive, menuItemStyles } = this.state + + return h( + Dropdown, + { + useCssTransition, + style: { + marginLeft: '-185px', + marginTop: '50px', + minWidth: '180px', + overflowY: 'auto', + maxHeight: '300px', + width: '300px', + }, + innerStyle, + isOpen: accountSelectorActive, + onClickOutside: (event) => { + const { classList } = event.target + const isNotToggleElement = !classList.contains(this.accountSelectorToggleClassName) + if (accountSelectorActive && isNotToggleElement) { + this.setState({ accountSelectorActive: false }) + } + }, + }, + [ + ...this.renderAccounts(), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + style: Object.assign( + {}, + menuItemStyles, + ), + onClick: () => actions.showNewAccountPageCreateForm(), + }, + [ + h( + 'i.fa.fa-plus.fa-lg', + { + style: { + marginLeft: '8px', + }, + } + ), + h('span', { + style: { + marginLeft: '14px', + fontFamily: 'DIN OT', + fontSize: '16px', + lineHeight: '23px', + }, + }, this.context.t('createAccount')), + ], + ), + h( + DropdownMenuItem, + { + closeMenu: () => { + if (sidebarOpen) { + actions.hideSidebar() + } + }, + onClick: () => actions.showNewAccountPageImportForm(), + style: Object.assign( + {}, + menuItemStyles, + ), + }, + [ + h( + 'i.fa.fa-download.fa-lg', + { + style: { + marginLeft: '8px', + }, + } + ), + h('span', { + style: { + marginLeft: '20px', + marginBottom: '5px', + fontFamily: 'DIN OT', + fontSize: '16px', + lineHeight: '23px', + }, + }, this.context.t('importAccount')), + ] + ), + ] + ) + } + + renderAccountOptions () { + const { actions, dropdownWrapperStyle, useCssTransition } = this.props + const { optionsMenuActive, menuItemStyles } = this.state + const dropdownMenuItemStyle = { + fontFamily: 'DIN OT', + fontSize: 16, + lineHeight: '24px', + padding: '8px', + } + + return h( + Dropdown, + { + useCssTransition, + style: Object.assign( + { + marginLeft: '-10px', + position: 'absolute', + width: '29vh', // affects both mobile and laptop views + }, + dropdownWrapperStyle, + ), + isOpen: optionsMenuActive, + onClickOutside: (event) => { + const { classList } = event.target + const isNotToggleElement = !classList.contains(this.optionsMenuToggleClassName) + if (optionsMenuActive && isNotToggleElement) { + this.setState({ optionsMenuActive: false }) + } + }, + }, + [ + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + this.props.actions.showAccountDetailModal() + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + this.context.t('accountDetails'), + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + const { selected, network } = this.props + const url = genAccountLink(selected, network) + global.platform.openWindow({ url }) + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + this.context.t('etherscanView'), + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + const { selected } = this.props + copyToClipboard(checksumAddress(selected)) + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + this.context.t('copyAddress'), + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => this.props.actions.showExportPrivateKeyModal(), + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + this.context.t('exportPrivateKey'), + ), + h( + DropdownMenuItem, + { + closeMenu: () => {}, + onClick: () => { + actions.hideSidebar() + actions.showAddTokenPage() + }, + style: Object.assign( + dropdownMenuItemStyle, + menuItemStyles, + ), + }, + this.context.t('addToken'), + ), + + ] + ) + } + + render () { + const { style, enableAccountsSelector, enableAccountOptions } = this.props + const { optionsMenuActive, accountSelectorActive } = this.state + + return h( + 'span', + { + style: style, + }, + [ + enableAccountsSelector && h( + 'i.fa.fa-angle-down', + { + style: { + cursor: 'pointer', + }, + onClick: (event) => { + event.stopPropagation() + this.setState({ + accountSelectorActive: !accountSelectorActive, + optionsMenuActive: false, + }) + }, + }, + this.renderAccountSelector(), + ), + enableAccountOptions && h( + 'i.fa.fa-ellipsis-h', + { + style: { + fontSize: '135%', + cursor: 'pointer', + }, + onClick: (event) => { + event.stopPropagation() + this.setState({ + accountSelectorActive: false, + optionsMenuActive: !optionsMenuActive, + }) + }, + }, + this.renderAccountOptions() + ), + ] + ) + } +} + +AccountDropdowns.defaultProps = { + enableAccountsSelector: false, + enableAccountOptions: false, +} + +AccountDropdowns.propTypes = { + identities: PropTypes.objectOf(PropTypes.object), + selected: PropTypes.string, + keyrings: PropTypes.array, + accounts: PropTypes.object, + menuItemStyles: PropTypes.object, + actions: PropTypes.object, + // actions.showAccountDetail: , + useCssTransition: PropTypes.bool, + innerStyle: PropTypes.object, + sidebarOpen: PropTypes.bool, + dropdownWrapperStyle: PropTypes.string, + // actions.showAccountDetailModal: , + network: PropTypes.number, + // actions.showExportPrivateKeyModal: , + style: PropTypes.object, + ticker: PropTypes.string, + enableAccountsSelector: PropTypes.bool, + enableAccountOption: PropTypes.bool, + enableAccountOptions: PropTypes.bool, + t: PropTypes.func, +} + +const mapDispatchToProps = (dispatch) => { + return { + actions: { + hideSidebar: () => dispatch(actions.hideSidebar()), + showConfigPage: () => dispatch(actions.showConfigPage()), + showAccountDetail: (address) => dispatch(actions.showAccountDetail(address)), + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + showEditAccountModal: (identity) => { + dispatch(actions.showModal({ + name: 'EDIT_ACCOUNT_NAME', + identity, + })) + }, + showNewAccountPageCreateForm: () => dispatch(actions.showNewAccountPage({ form: 'CREATE' })), + showExportPrivateKeyModal: () => { + dispatch(actions.showModal({ name: 'EXPORT_PRIVATE_KEY' })) + }, + showAddTokenPage: () => { + dispatch(actions.showAddTokenPage()) + }, + addNewAccount: () => dispatch(actions.addNewAccount()), + showNewAccountPageImportForm: () => dispatch(actions.showNewAccountPage({ form: 'IMPORT' })), + showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), + }, + } +} + +function mapStateToProps (state) { + return { + ticker: state.metamask.ticker, + keyrings: state.metamask.keyrings, + sidebarOpen: state.appState.sidebar.isOpen, + } +} + +AccountDropdowns.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDropdowns) + 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/dropdowns/components/network-dropdown-icon.js b/ui/app/components/app/dropdowns/components/network-dropdown-icon.js index d4a2c2ff7..d4a2c2ff7 100644 --- a/ui/app/components/dropdowns/components/network-dropdown-icon.js +++ b/ui/app/components/app/dropdowns/components/network-dropdown-icon.js 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/app/dropdowns/network-dropdown.js b/ui/app/components/app/dropdowns/network-dropdown.js new file mode 100644 index 000000000..fcc7a8681 --- /dev/null +++ b/ui/app/components/app/dropdowns/network-dropdown.js @@ -0,0 +1,378 @@ +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 +const { withRouter } = require('react-router-dom') +const { compose } = require('recompose') +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('../../../helpers/constants/routes') + +// classes from nodes of the toggle element. +const notToggleElementClassnames = [ + 'menu-icon', + 'network-name', + 'network-indicator', + 'network-caret', + 'network-component', +] + +function mapStateToProps (state) { + return { + provider: state.metamask.provider, + frequentRpcListDetail: state.metamask.frequentRpcListDetail || [], + networkDropdownOpen: state.appState.networkDropdownOpen, + network: state.metamask.network, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + setProviderType: (type) => { + dispatch(actions.setProviderType(type)) + }, + setDefaultRpcTarget: type => { + dispatch(actions.setDefaultRpcTarget(type)) + }, + setRpcTarget: (target, network, ticker, nickname) => { + dispatch(actions.setRpcTarget(target, network, ticker, nickname)) + }, + delRpcTarget: (target) => { + dispatch(actions.delRpcTarget(target)) + }, + showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), + hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), + } +} + + +inherits(NetworkDropdown, Component) +function NetworkDropdown () { + Component.call(this) +} + +NetworkDropdown.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(NetworkDropdown) + + +// TODO: specify default props and proptypes +NetworkDropdown.prototype.render = function () { + const props = this.props + const { provider: { type: providerType, rpcTarget: activeNetwork } } = props + const rpcListDetail = props.frequentRpcListDetail + const isOpen = this.props.networkDropdownOpen + const dropdownMenuItemStyle = { + fontSize: '16px', + lineHeight: '20px', + padding: '12px 0', + } + + return h(Dropdown, { + isOpen, + onClickOutside: (event) => { + const { classList } = event.target + const isInClassList = className => classList.contains(className) + const notToggleElementIndex = R.findIndex(isInClassList)(notToggleElementClassnames) + + if (notToggleElementIndex === -1) { + this.props.hideNetworkDropdown() + } + }, + containerClassName: 'network-droppo', + zIndex: 55, + style: { + position: 'absolute', + top: '58px', + width: '309px', + zIndex: '55px', + }, + innerStyle: { + padding: '18px 8px', + }, + }, [ + + h('div.network-dropdown-header', {}, [ + h('div.network-dropdown-title', {}, this.context.t('networks')), + + h('div.network-dropdown-divider'), + + h('div.network-dropdown-content', + {}, + this.context.t('defaultNetwork') + ), + ]), + + h( + DropdownMenuItem, + { + key: 'main', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => this.handleClick('mainnet'), + style: { ...dropdownMenuItemStyle, borderColor: '#038789' }, + }, + [ + providerType === 'mainnet' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#29B6AF', // $java + isSelected: providerType === 'mainnet', + }), + h('span.network-name-item', { + style: { + color: providerType === 'mainnet' ? '#ffffff' : '#9b9b9b', + }, + }, this.context.t('mainnet')), + ] + ), + + h( + DropdownMenuItem, + { + key: 'ropsten', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => this.handleClick('ropsten'), + style: dropdownMenuItemStyle, + }, + [ + providerType === 'ropsten' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#ff4a8d', // $wild-strawberry + isSelected: providerType === 'ropsten', + }), + h('span.network-name-item', { + style: { + color: providerType === 'ropsten' ? '#ffffff' : '#9b9b9b', + }, + }, this.context.t('ropsten')), + ] + ), + + h( + DropdownMenuItem, + { + key: 'kovan', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => this.handleClick('kovan'), + style: dropdownMenuItemStyle, + }, + [ + providerType === 'kovan' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#7057ff', // $cornflower-blue + isSelected: providerType === 'kovan', + }), + h('span.network-name-item', { + style: { + color: providerType === 'kovan' ? '#ffffff' : '#9b9b9b', + }, + }, this.context.t('kovan')), + ] + ), + + h( + DropdownMenuItem, + { + key: 'rinkeby', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => this.handleClick('rinkeby'), + style: dropdownMenuItemStyle, + }, + [ + providerType === 'rinkeby' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + backgroundColor: '#f6c343', // $saffron + isSelected: providerType === 'rinkeby', + }), + h('span.network-name-item', { + style: { + color: providerType === 'rinkeby' ? '#ffffff' : '#9b9b9b', + }, + }, this.context.t('rinkeby')), + ] + ), + + h( + DropdownMenuItem, + { + key: 'default', + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => this.handleClick('localhost'), + style: dropdownMenuItemStyle, + }, + [ + providerType === 'localhost' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + isSelected: providerType === 'localhost', + innerBorder: '1px solid #9b9b9b', + }), + h('span.network-name-item', { + style: { + color: providerType === 'localhost' ? '#ffffff' : '#9b9b9b', + }, + }, this.context.t('localhost')), + ] + ), + + this.renderCustomOption(props.provider), + this.renderCommonRpc(rpcListDetail, props.provider), + + h( + DropdownMenuItem, + { + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => this.props.history.push(SETTINGS_ROUTE), + style: dropdownMenuItemStyle, + }, + [ + activeNetwork === 'custom' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h(NetworkDropdownIcon, { + isSelected: activeNetwork === 'custom', + innerBorder: '1px solid #9b9b9b', + }), + h('span.network-name-item', { + style: { + color: activeNetwork === 'custom' ? '#ffffff' : '#9b9b9b', + }, + }, this.context.t('customRPC')), + ] + ), + + ]) +} + +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 + 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 = provider.nickname || this.context.t('unknownNetwork') + } + + return name +} + +NetworkDropdown.prototype.renderCommonRpc = function (rpcListDetail, provider) { + const props = this.props + const reversedRpcListDetail = rpcListDetail.slice().reverse() + + return reversedRpcListDetail.map((entry) => { + const rpc = entry.rpcUrl + const ticker = entry.ticker || 'ETH' + const nickname = entry.nickname || '' + const currentRpcTarget = provider.type === 'rpc' && rpc === provider.rpcTarget + + if ((rpc === 'http://localhost:8545') || currentRpcTarget) { + return null + } else { + const chainId = entry.chainId + return h( + DropdownMenuItem, + { + key: `common${rpc}`, + closeMenu: () => this.props.hideNetworkDropdown(), + onClick: () => props.setRpcTarget(rpc, chainId, ticker, nickname), + style: { + fontSize: '16px', + lineHeight: '20px', + padding: '12px 0', + }, + }, + [ + currentRpcTarget ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + h('i.fa.fa-question-circle.fa-med.menu-icon-circle'), + h('span.network-name-item', { + style: { + color: currentRpcTarget ? '#ffffff' : '#9b9b9b', + }, + }, nickname || rpc), + h('i.fa.fa-times.delete', + { + onClick: (e) => { + e.stopPropagation() + props.delRpcTarget(rpc) + }, + }), + ] + ) + } + }) +} + +NetworkDropdown.prototype.renderCustomOption = function (provider) { + const { rpcTarget, type, ticker, nickname } = provider + const props = this.props + const network = props.network + + if (type !== 'rpc') return null + + switch (rpcTarget) { + + case 'http://localhost:8545': + return null + + default: + return h( + DropdownMenuItem, + { + key: rpcTarget, + onClick: () => props.setRpcTarget(rpcTarget, network, ticker, nickname), + closeMenu: () => this.props.hideNetworkDropdown(), + style: { + fontSize: '16px', + lineHeight: '20px', + padding: '12px 0', + }, + }, + [ + h('i.fa.fa-check'), + h('i.fa.fa-question-circle.fa-med.menu-icon-circle'), + h('span.network-name-item', { + style: { + color: '#ffffff', + }, + }, nickname || rpcTarget), + ] + ) + } +} 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/app/dropdowns/tests/network-dropdown.test.js b/ui/app/components/app/dropdowns/tests/network-dropdown.test.js new file mode 100644 index 000000000..91e7899a7 --- /dev/null +++ b/ui/app/components/app/dropdowns/tests/network-dropdown.test.js @@ -0,0 +1,97 @@ +import React from 'react' +import assert from 'assert' +import { createMockStore } from 'redux-test-utils' +import { mountWithRouter } from '../../../../../../test/lib/render-helpers' +import NetworkDropdown from '../network-dropdown' +import { DropdownMenuItem } from '../components/dropdown' +import NetworkDropdownIcon from '../components/network-dropdown-icon' + +describe('Network Dropdown', () => { + let wrapper + + describe('NetworkDropdown in appState in false', () => { + const mockState = { + metamask: { + provider: { + type: 'test', + }, + }, + appState: { + networkDropdown: false, + }, + } + + const store = createMockStore(mockState) + + beforeEach(() => { + wrapper = mountWithRouter( + <NetworkDropdown store={store} /> + ) + }) + + it('checks for network droppo class', () => { + assert.equal(wrapper.find('.network-droppo').length, 1) + }) + + it('renders only one child when networkDropdown is false in state', () => { + assert.equal(wrapper.children().length, 1) + }) + + }) + + describe('NetworkDropdown in appState is true', () => { + const mockState = { + metamask: { + provider: { + 'type': 'test', + }, + frequentRpcListDetail: [ + { rpcUrl: 'http://localhost:7545' }, + ], + }, + appState: { + 'networkDropdownOpen': true, + }, + } + const store = createMockStore(mockState) + + beforeEach(() => { + wrapper = mountWithRouter( + <NetworkDropdown store={store}/>, + ) + }) + + it('renders 7 DropDownMenuItems ', () => { + assert.equal(wrapper.find(DropdownMenuItem).length, 7) + }) + + it('checks background color for first NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(0).prop('backgroundColor'), '#29B6AF') // Main Ethereum Network Teal + }) + + it('checks background color for second NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(1).prop('backgroundColor'), '#ff4a8d') // Ropsten Red + }) + + it('checks background color for third NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(2).prop('backgroundColor'), '#7057ff') // Kovan Purple + }) + + it('checks background color for fourth NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(3).prop('backgroundColor'), '#f6c343') // Rinkeby Yellow + }) + + it('checks background color for fifth NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(4).prop('innerBorder'), '1px solid #9b9b9b') + }) + + it('checks dropdown for frequestRPCList from state ', () => { + assert.equal(wrapper.find(DropdownMenuItem).at(5).text(), '✓http://localhost:7545') + }) + + it('checks background color for sixth NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(5).prop('innerBorder'), '1px solid #9b9b9b') + }) + + }) +}) diff --git a/ui/app/components/app/dropdowns/token-menu-dropdown.js b/ui/app/components/app/dropdowns/token-menu-dropdown.js new file mode 100644 index 000000000..e2730aea2 --- /dev/null +++ b/ui/app/components/app/dropdowns/token-menu-dropdown.js @@ -0,0 +1,68 @@ +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 +const actions = require('../../../store/actions') +const genAccountLink = require('etherscan-link').createAccountLink +const { Menu, Item, CloseArea } = require('./components/menu') + +TokenMenuDropdown.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(TokenMenuDropdown) + +function mapStateToProps (state) { + return { + network: state.metamask.network, + } +} + +function mapDispatchToProps (dispatch) { + return { + showHideTokenConfirmationModal: (token) => { + dispatch(actions.showModal({ name: 'HIDE_TOKEN_CONFIRMATION', token })) + }, + } +} + + +inherits(TokenMenuDropdown, Component) +function TokenMenuDropdown () { + Component.call(this) + + this.onClose = this.onClose.bind(this) +} + +TokenMenuDropdown.prototype.onClose = function (e) { + e.stopPropagation() + this.props.onClose() +} + +TokenMenuDropdown.prototype.render = function () { + const { showHideTokenConfirmationModal } = this.props + + return h(Menu, { className: 'token-menu-dropdown', isShowing: true }, [ + h(CloseArea, { + onClick: this.onClose, + }), + h(Item, { + onClick: (e) => { + e.stopPropagation() + showHideTokenConfirmationModal(this.props.token) + this.props.onClose() + }, + text: this.context.t('hideToken'), + }), + h(Item, { + onClick: (e) => { + e.stopPropagation() + const url = genAccountLink(this.props.token.address, this.props.network) + global.platform.openWindow({ url }) + this.props.onClose() + }, + text: this.context.t('viewOnEtherscan'), + }), + ]) +} diff --git a/ui/app/components/app/ens-input.js b/ui/app/components/app/ens-input.js new file mode 100644 index 000000000..274058a1b --- /dev/null +++ b/ui/app/components/app/ens-input.js @@ -0,0 +1,181 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const inherits = require('util').inherits +const extend = require('xtend') +const debounce = require('debounce') +const copyToClipboard = require('copy-to-clipboard') +const ENS = require('ethjs-ens') +const networkMap = require('ethjs-ens/lib/network-map.json') +const ensRE = /.+\..+$/ +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const connect = require('react-redux').connect +const ToAutoComplete = require('./send/to-autocomplete').default +const log = require('loglevel') +const { isValidENSAddress } = require('../../helpers/utils/util') + +EnsInput.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect()(EnsInput) + + +inherits(EnsInput, Component) +function EnsInput () { + Component.call(this) +} + +EnsInput.prototype.onChange = function (recipient) { + + const network = this.props.network + const networkHasEnsSupport = getNetworkEnsSupport(network) + + this.props.onChange({ toAddress: recipient }) + + if (!networkHasEnsSupport) return + + if (recipient.match(ensRE) === null) { + return this.setState({ + loadingEns: false, + ensResolution: null, + ensFailure: null, + toError: null, + }) + } + + this.setState({ + loadingEns: true, + }) + this.checkName(recipient) +} + +EnsInput.prototype.render = function () { + const props = this.props + const opts = extend(props, { + list: 'addresses', + onChange: this.onChange.bind(this), + qrScanner: true, + }) + return h('div', { + style: { width: '100%', position: 'relative' }, + }, [ + h(ToAutoComplete, { ...opts }), + this.ensIcon(), + ]) +} + +EnsInput.prototype.componentDidMount = function () { + const network = this.props.network + const networkHasEnsSupport = getNetworkEnsSupport(network) + this.setState({ ensResolution: ZERO_ADDRESS }) + + if (networkHasEnsSupport) { + const provider = global.ethereumProvider + this.ens = new ENS({ provider, network }) + this.checkName = debounce(this.lookupEnsName.bind(this), 200) + } +} + +EnsInput.prototype.lookupEnsName = function (recipient) { + const { ensResolution } = this.state + + log.info(`ENS attempting to resolve name: ${recipient}`) + this.ens.lookup(recipient.trim()) + .then((address) => { + if (address === ZERO_ADDRESS) throw new Error(this.context.t('noAddressForName')) + if (address !== ensResolution) { + this.setState({ + loadingEns: false, + ensResolution: address, + nickname: recipient.trim(), + hoverText: address + '\n' + this.context.t('clickCopy'), + ensFailure: false, + toError: null, + }) + } + }) + .catch((reason) => { + const setStateObj = { + loadingEns: false, + ensResolution: recipient, + ensFailure: true, + toError: null, + } + if (isValidENSAddress(recipient) && reason.message === 'ENS name not defined.') { + setStateObj.hoverText = this.context.t('ensNameNotFound') + setStateObj.toError = 'ensNameNotFound' + setStateObj.ensFailure = false + } else { + log.error(reason) + setStateObj.hoverText = reason.message + } + + return this.setState(setStateObj) + }) +} + +EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) { + const state = this.state || {} + const ensResolution = state.ensResolution + // If an address is sent without a nickname, meaning not from ENS or from + // the user's own accounts, a default of a one-space string is used. + const nickname = state.nickname || ' ' + if (prevProps.network !== this.props.network) { + const provider = global.ethereumProvider + this.ens = new ENS({ provider, network: this.props.network }) + this.onChange(ensResolution) + } + if (prevState && ensResolution && this.props.onChange && + ensResolution !== prevState.ensResolution) { + this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError, toWarning: state.toWarning }) + } +} + +EnsInput.prototype.ensIcon = function (recipient) { + const { hoverText } = this.state || {} + return h('span.#ensIcon', { + title: hoverText, + style: { + position: 'absolute', + top: '16px', + left: '-25px', + }, + }, this.ensIconContents(recipient)) +} + +EnsInput.prototype.ensIconContents = function (recipient) { + const { loadingEns, ensFailure, ensResolution, toError } = this.state || { ensResolution: ZERO_ADDRESS } + + if (toError) return + + if (loadingEns) { + return h('img', { + src: 'images/loading.svg', + style: { + width: '30px', + height: '30px', + transform: 'translateY(-6px)', + }, + }) + } + + if (ensFailure) { + return h('i.fa.fa-warning.fa-lg.warning') + } + + if (ensResolution && (ensResolution !== ZERO_ADDRESS)) { + return h('i.fa.fa-check-circle.fa-lg.cursor-pointer', { + style: { color: 'green' }, + onClick: (event) => { + event.preventDefault() + event.stopPropagation() + copyToClipboard(ensResolution) + }, + }) + } +} + +function getNetworkEnsSupport (network) { + return Boolean(networkMap[network]) +} diff --git a/ui/app/components/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 index 95894140c..95894140c 100644 --- a/ui/app/components/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 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/gas-customization/advanced-gas-inputs/index.js b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.js index bd8abaa3e..bd8abaa3e 100644 --- a/ui/app/components/gas-customization/advanced-gas-inputs/index.js +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.js diff --git a/ui/app/components/gas-customization/advanced-gas-inputs/index.scss b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss index 50953cbe5..50953cbe5 100644 --- a/ui/app/components/gas-customization/advanced-gas-inputs/index.scss +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss 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..7fbf8f0bd --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js @@ -0,0 +1,219 @@ +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, + } + + 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, + } = 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, + }) } + <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> + ) + } +} diff --git a/ui/app/components/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 index 492037f25..492037f25 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index 53cb84791..53cb84791 100644 --- a/ui/app/components/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 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..a6b81d2ce --- /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,364 @@ +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} + />, { 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).hasClass('advanced-tab__fee-chart__title')) + assert(feeChartDiv.childAt(2).is(GasPriceChart)) + assert(feeChartDiv.childAt(3).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).hasClass('advanced-tab__fee-chart__title')) + assert(feeChartDiv.childAt(2).is(Loading)) + assert(feeChartDiv.childAt(3).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/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 index 61b681e1a..61b681e1a 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index e2115af7f..e2115af7f 100644 --- a/ui/app/components/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 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/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 index 826d41f9c..826d41f9c 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index cf43e0acb..cf43e0acb 100644 --- a/ui/app/components/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 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/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 index 078d50fce..078d50fce 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index e34e4e328..e34e4e328 100644 --- a/ui/app/components/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 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..76edbc334 --- /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={'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..cbc1e3e96 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js @@ -0,0 +1,291 @@ +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 { + getCurrentCurrency, + conversionRateSelector as getConversionRate, + getSelectedToken, + getCurrentEthBalance, +} 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' +import {getIsMainnet, preferencesSelector} from '../../../../selectors/selectors' + +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, + } +} + +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/gas-customization/gas-modal-page-container/index.js b/ui/app/components/app/gas-customization/gas-modal-page-container/index.js index ec0ebad22..ec0ebad22 100644 --- a/ui/app/components/gas-customization/gas-modal-page-container/index.js +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/index.js diff --git a/ui/app/components/gas-customization/gas-modal-page-container/index.scss b/ui/app/components/app/gas-customization/gas-modal-page-container/index.scss index b9e0f59c4..b9e0f59c4 100644 --- a/ui/app/components/gas-customization/gas-modal-page-container/index.scss +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/index.scss 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..aaa4f1c41 --- /dev/null +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js @@ -0,0 +1,425 @@ +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, + } + 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: '', + }, + }, + }, + { + mockState: Object.assign({}, baseMockState, { + metamask: { + ...baseMockState.metamask, + preferences: { + ...baseMockState.metamask.preferences, + showFiatInTestnets: true, + }, + provider: { + ...baseMockState.metamask.provider, + type: 'rinkeby', + }, + }, + }), + mockOwnProps: baseMockOwnProps, + expectedResult: baseExpectedResult, + }, + { + 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/gas-customization/gas-price-button-group/index.js b/ui/app/components/app/gas-customization/gas-price-button-group/index.js index 775648330..775648330 100644 --- a/ui/app/components/gas-customization/gas-price-button-group/index.js +++ b/ui/app/components/app/gas-customization/gas-price-button-group/index.js diff --git a/ui/app/components/gas-customization/gas-price-button-group/index.scss b/ui/app/components/app/gas-customization/gas-price-button-group/index.scss index cb2f3ecf1..cb2f3ecf1 100644 --- a/ui/app/components/gas-customization/gas-price-button-group/index.scss +++ b/ui/app/components/app/gas-customization/gas-price-button-group/index.scss 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/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 index c0eaf4852..c0eaf4852 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index f19dafcc1..f19dafcc1 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/gas-customization/gas-price-chart/index.js b/ui/app/components/app/gas-customization/gas-price-chart/index.js index 9895acb62..9895acb62 100644 --- a/ui/app/components/gas-customization/gas-price-chart/index.js +++ b/ui/app/components/app/gas-customization/gas-price-chart/index.js diff --git a/ui/app/components/gas-customization/gas-price-chart/index.scss b/ui/app/components/app/gas-customization/gas-price-chart/index.scss index 097543104..097543104 100644 --- a/ui/app/components/gas-customization/gas-price-chart/index.scss +++ b/ui/app/components/app/gas-customization/gas-price-chart/index.scss 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/gas-customization/gas-slider/gas-slider.component.js b/ui/app/components/app/gas-customization/gas-slider/gas-slider.component.js index 5836e7dfc..5836e7dfc 100644 --- a/ui/app/components/gas-customization/gas-slider/gas-slider.component.js +++ b/ui/app/components/app/gas-customization/gas-slider/gas-slider.component.js diff --git a/ui/app/components/gas-customization/gas-slider/index.js b/ui/app/components/app/gas-customization/gas-slider/index.js index f1752c93f..f1752c93f 100644 --- a/ui/app/components/gas-customization/gas-slider/index.js +++ b/ui/app/components/app/gas-customization/gas-slider/index.js diff --git a/ui/app/components/gas-customization/gas-slider/index.scss b/ui/app/components/app/gas-customization/gas-slider/index.scss index e6c734367..e6c734367 100644 --- a/ui/app/components/gas-customization/gas-slider/index.scss +++ b/ui/app/components/app/gas-customization/gas-slider/index.scss diff --git a/ui/app/components/gas-customization/gas.selectors.js b/ui/app/components/app/gas-customization/gas.selectors.js index 89374b5f1..89374b5f1 100644 --- a/ui/app/components/gas-customization/gas.selectors.js +++ b/ui/app/components/app/gas-customization/gas.selectors.js diff --git a/ui/app/components/gas-customization/index.scss b/ui/app/components/app/gas-customization/index.scss index b06c1d044..b06c1d044 100644 --- a/ui/app/components/gas-customization/index.scss +++ b/ui/app/components/app/gas-customization/index.scss 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/app/input-number.js b/ui/app/components/app/input-number.js new file mode 100644 index 000000000..8a6ec725c --- /dev/null +++ b/ui/app/components/app/input-number.js @@ -0,0 +1,81 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const { + addCurrencies, + conversionGTE, + conversionLTE, + subtractCurrencies, +} = require('../../helpers/utils/conversion-util') + +module.exports = InputNumber + +inherits(InputNumber, Component) +function InputNumber () { + Component.call(this) + + this.setValue = this.setValue.bind(this) +} + +function isValidInput (text) { + const re = /^([1-9]\d*|0)(\.|\.\d*)?$/ + return re.test(text) +} + +function removeLeadingZeroes (str) { + return str.replace(/^0*(?=\d)/, '') +} + +InputNumber.prototype.setValue = function (newValue) { + newValue = removeLeadingZeroes(newValue) + if (newValue && !isValidInput(newValue)) return + const { fixed, min = -1, max = Infinity, onChange } = this.props + + newValue = fixed ? newValue.toFixed(4) : newValue + const newValueGreaterThanMin = conversionGTE( + { value: newValue || '0', fromNumericBase: 'dec' }, + { value: min, fromNumericBase: 'hex' }, + ) + + const newValueLessThanMax = conversionLTE( + { value: newValue || '0', fromNumericBase: 'dec' }, + { value: max, fromNumericBase: 'hex' }, + ) + if (newValueGreaterThanMin && newValueLessThanMax) { + onChange(newValue) + } else if (!newValueGreaterThanMin) { + onChange(min) + } else if (!newValueLessThanMax) { + onChange(max) + } +} + +InputNumber.prototype.render = function () { + const { unitLabel, step = 1, placeholder, value } = this.props + + return h('div.customize-gas-input-wrapper', {}, [ + h('input', { + className: 'customize-gas-input', + value, + placeholder, + type: 'number', + onChange: e => { + this.setValue(e.target.value) + }, + min: 0, + }), + h('span.gas-tooltip-input-detail', {}, [unitLabel]), + h('div.gas-tooltip-input-arrows', {}, [ + h('div.gas-tooltip-input-arrow-wrapper', { + onClick: () => this.setValue(addCurrencies(value, step, { toNumericBase: 'dec' })), + }, [ + h('i.fa.fa-angle-up'), + ]), + h('div.gas-tooltip-input-arrow-wrapper', { + onClick: () => this.setValue(subtractCurrencies(value, step, { toNumericBase: 'dec' })), + }, [ + h('i.fa.fa-angle-down'), + ]), + ]), + ]) +} diff --git a/ui/app/components/loading-network-screen/index.js b/ui/app/components/app/loading-network-screen/index.js index 726b4b530..726b4b530 100644 --- a/ui/app/components/loading-network-screen/index.js +++ b/ui/app/components/app/loading-network-screen/index.js 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/app/menu-bar/menu-bar.container.js b/ui/app/components/app/menu-bar/menu-bar.container.js new file mode 100644 index 000000000..059263ff3 --- /dev/null +++ b/ui/app/components/app/menu-bar/menu-bar.container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux' +import { WALLET_VIEW_SIDEBAR } from '../sidebars/sidebar.constants' +import MenuBar from './menu-bar.component' +import { showSidebar, hideSidebar } from '../../../store/actions' + +const mapStateToProps = state => { + const { appState: { sidebar: { isOpen } } } = state + + return { + sidebarOpen: isOpen, + } +} + +const mapDispatchToProps = dispatch => { + return { + showSidebar: () => { + dispatch(showSidebar({ + transitionName: 'sidebar-right', + type: WALLET_VIEW_SIDEBAR, + })) + }, + hideSidebar: () => dispatch(hideSidebar()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(MenuBar) 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/app/modal/index.scss b/ui/app/components/app/modal/index.scss new file mode 100644 index 000000000..ec67d15fd --- /dev/null +++ b/ui/app/components/app/modal/index.scss @@ -0,0 +1,62 @@ +@import 'modal-content/index'; + +.modal-container { + width: 100%; + height: 100%; + background-color: #fff; + display: flex; + flex-flow: column; + border-radius: 8px; + + @media screen and (max-width: 575px) { + max-height: 450px; + } + + &__content { + overflow-y: auto; + flex: 1; + padding: 16px 32px; + + @media screen and (max-width: 575px) { + justify-content: center; + padding: 28px 20px; + } + } + + &__header { + position: relative; + display: flex; + padding: 12px; + justify-content: center; + border-bottom: 1px solid #d2d8dd; + flex: 0 0 auto; + } + + &__header-close::after { + content: '\00D7'; + font-size: 40px; + color: $dusty-gray; + position: absolute; + top: -5px; + right: 10px; + cursor: pointer; + } + + &__footer { + display: flex; + flex-flow: row; + justify-content: center; + border-top: 1px solid #d2d8dd; + padding: 16px; + flex: 0 0 auto; + + &-button { + min-width: 0; + margin-right: 16px; + + &:last-of-type { + margin-right: 0; + } + } + } +} 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/app/modal/modal.component.js b/ui/app/components/app/modal/modal.component.js new file mode 100644 index 000000000..49e131b3c --- /dev/null +++ b/ui/app/components/app/modal/modal.component.js @@ -0,0 +1,83 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Button from '../../ui/button' + +export default class Modal extends PureComponent { + static propTypes = { + children: PropTypes.node, + // Header text + headerText: PropTypes.string, + onClose: PropTypes.func, + // Submit button (right button) + onSubmit: PropTypes.func, + submitType: PropTypes.string, + submitText: PropTypes.string, + submitDisabled: PropTypes.bool, + // Cancel button (left button) + onCancel: PropTypes.func, + cancelType: PropTypes.string, + cancelText: PropTypes.string, + } + + static defaultProps = { + submitType: 'primary', + cancelType: 'default', + } + + render () { + const { + children, + headerText, + onClose, + onSubmit, + submitType, + submitText, + submitDisabled, + onCancel, + cancelType, + cancelText, + } = this.props + + return ( + <div className="modal-container"> + { + headerText && ( + <div className="modal-container__header"> + <div className="modal-container__header-text"> + { headerText } + </div> + <div + className="modal-container__header-close" + onClick={onClose} + /> + </div> + ) + } + <div className="modal-container__content"> + { children } + </div> + <div className="modal-container__footer"> + { + onCancel && ( + <Button + type={cancelType} + onClick={onCancel} + className="modal-container__footer-button" + > + { cancelText } + </Button> + ) + } + <Button + type={submitType} + onClick={onSubmit} + disabled={submitDisabled} + className="modal-container__footer-button" + > + { submitText } + </Button> + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/modal/tests/modal.component.test.js b/ui/app/components/app/modal/tests/modal.component.test.js new file mode 100644 index 000000000..a13d7c06a --- /dev/null +++ b/ui/app/components/app/modal/tests/modal.component.test.js @@ -0,0 +1,133 @@ +import React from 'react' +import assert from 'assert' +import { mount, shallow } from 'enzyme' +import sinon from 'sinon' +import Modal from '../modal.component' +import Button from '../../../ui/button' + +describe('Modal Component', () => { + it('should render a modal with a submit button', () => { + const wrapper = shallow(<Modal />) + + assert.equal(wrapper.find('.modal-container').length, 1) + const buttons = wrapper.find(Button) + assert.equal(buttons.length, 1) + assert.equal(buttons.at(0).props().type, 'primary') + }) + + it('should render a modal with a cancel and a submit button', () => { + const handleCancel = sinon.spy() + const handleSubmit = sinon.spy() + const wrapper = shallow( + <Modal + onCancel={handleCancel} + cancelText="Cancel" + onSubmit={handleSubmit} + submitText="Submit" + /> + ) + + const buttons = wrapper.find(Button) + assert.equal(buttons.length, 2) + const cancelButton = buttons.at(0) + const submitButton = buttons.at(1) + + assert.equal(cancelButton.props().type, 'default') + assert.equal(cancelButton.props().children, 'Cancel') + assert.equal(handleCancel.callCount, 0) + cancelButton.simulate('click') + assert.equal(handleCancel.callCount, 1) + + assert.equal(submitButton.props().type, 'primary') + assert.equal(submitButton.props().children, 'Submit') + assert.equal(handleSubmit.callCount, 0) + submitButton.simulate('click') + assert.equal(handleSubmit.callCount, 1) + }) + + it('should render a modal with different button types', () => { + const wrapper = shallow( + <Modal + onCancel={() => {}} + cancelText="Cancel" + cancelType="secondary" + onSubmit={() => {}} + submitText="Submit" + submitType="confirm" + /> + ) + + const buttons = wrapper.find(Button) + assert.equal(buttons.length, 2) + assert.equal(buttons.at(0).props().type, 'secondary') + assert.equal(buttons.at(1).props().type, 'confirm') + }) + + it('should render a modal with children', () => { + const wrapper = shallow( + <Modal + onCancel={() => {}} + cancelText="Cancel" + onSubmit={() => {}} + submitText="Submit" + > + <div className="test-child" /> + </Modal> + ) + + assert.ok(wrapper.find('.test-class')) + }) + + it('should render a modal with a header', () => { + const handleCancel = sinon.spy() + const handleSubmit = sinon.spy() + const wrapper = shallow( + <Modal + onCancel={handleCancel} + cancelText="Cancel" + onSubmit={handleSubmit} + submitText="Submit" + headerText="My Header" + onClose={handleCancel} + /> + ) + + assert.ok(wrapper.find('.modal-container__header')) + assert.equal(wrapper.find('.modal-container__header-text').text(), 'My Header') + assert.equal(handleCancel.callCount, 0) + assert.equal(handleSubmit.callCount, 0) + wrapper.find('.modal-container__header-close').simulate('click') + 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/app/modals/account-details-modal.js b/ui/app/components/app/modals/account-details-modal.js new file mode 100644 index 000000000..94ed04df9 --- /dev/null +++ b/ui/app/components/app/modals/account-details-modal.js @@ -0,0 +1,101 @@ +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 +const actions = require('../../../store/actions') +const AccountModalContainer = require('./account-modal-container') +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 '../../ui/button' + +function mapStateToProps (state) { + return { + network: state.metamask.network, + selectedIdentity: getSelectedIdentity(state), + keyrings: state.metamask.keyrings, + } +} + +function mapDispatchToProps (dispatch) { + return { + // Is this supposed to be used somewhere? + showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), + showExportPrivateKeyModal: () => { + dispatch(actions.showModal({ name: 'EXPORT_PRIVATE_KEY' })) + }, + hideModal: () => dispatch(actions.hideModal()), + setAccountLabel: (address, label) => dispatch(actions.setAccountLabel(address, label)), + } +} + +inherits(AccountDetailsModal, Component) +function AccountDetailsModal () { + Component.call(this) +} + +AccountDetailsModal.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDetailsModal) + + +// Not yet pixel perfect todos: + // fonts of qr-header + +AccountDetailsModal.prototype.render = function () { + const { + selectedIdentity, + network, + showExportPrivateKeyModal, + setAccountLabel, + keyrings, + } = this.props + const { name, address } = selectedIdentity + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(address) + }) + + let exportPrivateKeyFeatureEnabled = true + // This feature is disabled for hardware wallets + if (keyring && keyring.type.search('Hardware') !== -1) { + exportPrivateKeyFeatureEnabled = false + } + + return h(AccountModalContainer, {}, [ + h(EditableLabel, { + className: 'account-modal__name', + defaultValue: name, + onSubmit: label => setAccountLabel(address, label), + }), + + h(QrView, { + Qr: { + data: address, + network: network, + }, + }), + + h('div.account-modal-divider'), + + h(Button, { + type: 'primary', + className: 'account-modal__button', + onClick: () => global.platform.openWindow({ url: genAccountLink(address, network) }), + }, this.context.t('etherscanView')), + + // Holding on redesign for Export Private Key functionality + + exportPrivateKeyFeatureEnabled ? h(Button, { + type: 'primary', + className: 'account-modal__button', + onClick: () => showExportPrivateKeyModal(), + }, this.context.t('exportPrivateKey')) : null, + + ]) +} diff --git a/ui/app/components/app/modals/account-modal-container.js b/ui/app/components/app/modals/account-modal-container.js new file mode 100644 index 000000000..b7ae0b5b8 --- /dev/null +++ b/ui/app/components/app/modals/account-modal-container.js @@ -0,0 +1,80 @@ +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 +const actions = require('../../../store/actions') +const { getSelectedIdentity } = require('../../../selectors/selectors') +import Identicon from '../../ui/identicon' + +function mapStateToProps (state, ownProps) { + return { + selectedIdentity: ownProps.selectedIdentity || getSelectedIdentity(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +inherits(AccountModalContainer, Component) +function AccountModalContainer () { + Component.call(this) +} + +AccountModalContainer.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountModalContainer) + + +AccountModalContainer.prototype.render = function () { + const { + selectedIdentity, + showBackButton = false, + backButtonAction, + } = this.props + let { children } = this.props + + if (children.constructor !== Array) { + children = [children] + } + + return h('div', { style: { borderRadius: '4px' }}, [ + h('div.account-modal-container', [ + + h('div', [ + + // Needs a border; requires changes to svg + h(Identicon, { + address: selectedIdentity.address, + diameter: 64, + style: {}, + }), + + ]), + + showBackButton && h('div.account-modal-back', { + onClick: backButtonAction, + }, [ + + h('i.fa.fa-angle-left.fa-lg'), + + h('span.account-modal-back__text', ' ' + this.context.t('back')), + + ]), + + h('div.account-modal-close', { + onClick: this.props.hideModal, + }), + + ...children, + + ]), + ]) +} diff --git a/ui/app/components/app/modals/buy-options-modal.js b/ui/app/components/app/modals/buy-options-modal.js new file mode 100644 index 000000000..2df20e65c --- /dev/null +++ b/ui/app/components/app/modals/buy-options-modal.js @@ -0,0 +1,101 @@ +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 +const actions = require('../../../store/actions') +const { getNetworkDisplayName } = require('../../../../../app/scripts/controllers/network/util') + +function mapStateToProps (state) { + return { + network: state.metamask.network, + address: state.metamask.selectedAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + toCoinbase: (address) => { + dispatch(actions.buyEth({ network: '1', address, amount: 0 })) + }, + hideModal: () => { + dispatch(actions.hideModal()) + }, + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + toFaucet: network => dispatch(actions.buyEth({ network })), + } +} + +inherits(BuyOptions, Component) +function BuyOptions () { + Component.call(this) +} + +BuyOptions.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(BuyOptions) + + +BuyOptions.prototype.renderModalContentOption = function (title, header, onClick) { + return h('div.buy-modal-content-option', { + onClick, + }, [ + h('div.buy-modal-content-option-title', {}, title), + h('div.buy-modal-content-option-subtitle', {}, header), + ]) +} + +BuyOptions.prototype.render = function () { + const { network, toCoinbase, address, toFaucet } = this.props + const isTestNetwork = ['3', '4', '42'].find(n => n === network) + const networkName = getNetworkDisplayName(network) + + return h('div', {}, [ + h('div.buy-modal-content.transfers-subview', { + }, [ + h('div.buy-modal-content-title-wrapper.flex-column.flex-center', { + style: {}, + }, [ + h('div.buy-modal-content-title', { + style: {}, + }, this.context.t('transfers')), + h('div', {}, this.context.t('howToDeposit')), + ]), + + h('div.buy-modal-content-options.flex-column.flex-center', {}, [ + + isTestNetwork + ? this.renderModalContentOption(networkName, this.context.t('testFaucet'), () => toFaucet(network)) + : this.renderModalContentOption('Coinbase', this.context.t('depositFiat'), () => toCoinbase(address)), + + // h('div.buy-modal-content-option', {}, [ + // h('div.buy-modal-content-option-title', {}, 'Shapeshift'), + // h('div.buy-modal-content-option-subtitle', {}, 'Trade any digital asset for any other'), + // ]),, + + this.renderModalContentOption( + this.context.t('directDeposit'), + this.context.t('depositFromAccount'), + () => this.goToAccountDetailsModal() + ), + + ]), + + h('button', { + style: { + background: 'white', + }, + onClick: () => { this.props.hideModal() }, + }, h('div.buy-modal-content-footer#buy-modal-content-footer-text', {}, this.context.t('cancel'))), + ]), + ]) +} + +BuyOptions.prototype.goToAccountDetailsModal = function () { + this.props.hideModal() + this.props.showAccountDetailModal() +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..beebb7ed7 --- /dev/null +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js @@ -0,0 +1,29 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display' +import { PRIMARY, SECONDARY } from '../../../../../helpers/constants/common' + +export default class CancelTransaction extends PureComponent { + static propTypes = { + value: PropTypes.string, + } + + render () { + const { value } = this.props + + return ( + <div className="cancel-transaction-gas-fee"> + <UserPreferencedCurrencyDisplay + className="cancel-transaction-gas-fee__eth" + value={value} + type={PRIMARY} + /> + <UserPreferencedCurrencyDisplay + className="cancel-transaction-gas-fee__fiat" + value={value} + type={SECONDARY} + /> + </div> + ) + } +} 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/app/modals/cancel-transaction/cancel-transaction.component.js b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.component.js new file mode 100644 index 000000000..6bab5ec1f --- /dev/null +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.component.js @@ -0,0 +1,76 @@ +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 '../../../../helpers/constants/transactions' + +export default class CancelTransaction extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + createCancelTransaction: PropTypes.func, + hideModal: PropTypes.func, + showTransactionConfirmedModal: PropTypes.func, + transactionStatus: PropTypes.string, + newGasFee: PropTypes.string, + } + + state = { + busy: false, + } + + componentDidUpdate () { + const { transactionStatus, showTransactionConfirmedModal } = this.props + + if (transactionStatus !== SUBMITTED_STATUS) { + showTransactionConfirmedModal() + return + } + } + + handleSubmit = async () => { + const { createCancelTransaction, hideModal } = this.props + + this.setState({ busy: true }) + + await createCancelTransaction() + this.setState({ busy: false }, () => hideModal()) + } + + handleCancel = () => { + this.props.hideModal() + } + + render () { + const { t } = this.context + const { newGasFee } = this.props + const { busy } = this.state + + return ( + <Modal + headerText={t('attemptToCancel')} + onClose={this.handleCancel} + onSubmit={this.handleSubmit} + onCancel={this.handleCancel} + submitText={t('yesLetsTry')} + cancelText={t('nevermind')} + submitType="secondary" + submitDisabled={busy} + > + <div> + <div className="cancel-transaction__title"> + { t('cancellationGasFee') } + </div> + <div className="cancel-transaction__cancel-transaction-gas-fee-container"> + <CancelTransactionGasFee value={newGasFee} /> + </div> + <div className="cancel-transaction__description"> + { t('attemptToCancelDescription') } + </div> + </div> + </Modal> + ) + } +} diff --git a/ui/app/components/app/modals/cancel-transaction/cancel-transaction.container.js b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.container.js new file mode 100644 index 000000000..6959889d9 --- /dev/null +++ b/ui/app/components/app/modals/cancel-transaction/cancel-transaction.container.js @@ -0,0 +1,60 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import ethUtil from 'ethereumjs-util' +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 '../../../../store/actions' +import { getHexGasTotal } from '../../../../helpers/utils/confirm-tx.util' + +const mapStateToProps = (state, ownProps) => { + const { metamask } = state + const { transactionId, originalGasPrice } = ownProps + const { selectedAddressTxList } = metamask + const transaction = selectedAddressTxList.find(({ id }) => id === transactionId) + const transactionStatus = transaction ? transaction.status : '' + + const defaultNewGasPrice = ethUtil.addHexPrefix( + multiplyCurrencies(originalGasPrice, 1.1, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + }) + ) + + const newGasFee = getHexGasTotal({ gasPrice: defaultNewGasPrice, gasLimit: '0x5208' }) + + return { + transactionId, + transactionStatus, + originalGasPrice, + defaultNewGasPrice, + newGasFee, + } +} + +const mapDispatchToProps = dispatch => { + return { + createCancelTransaction: (txId, customGasPrice) => { + return dispatch(createCancelTransaction(txId, customGasPrice)) + }, + showTransactionConfirmedModal: () => dispatch(showModal({ name: 'TRANSACTION_CONFIRMED' })), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { transactionId, defaultNewGasPrice, ...restStateProps } = stateProps + const { createCancelTransaction, ...restDispatchProps } = dispatchProps + + return { + ...restStateProps, + ...restDispatchProps, + ...ownProps, + createCancelTransaction: () => createCancelTransaction(transactionId, defaultNewGasPrice), + } +} + +export default compose( + withModalProps, + connect(mapStateToProps, mapDispatchToProps, mergeProps), +)(CancelTransaction) 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/app/modals/cancel-transaction/index.scss b/ui/app/components/app/modals/cancel-transaction/index.scss new file mode 100644 index 000000000..4ffb5a0f8 --- /dev/null +++ b/ui/app/components/app/modals/cancel-transaction/index.scss @@ -0,0 +1,18 @@ +@import 'cancel-transaction-gas-fee/index'; + +.cancel-transaction { + &__title { + font-weight: 500; + padding-bottom: 16px; + text-align: center; + } + + &__description { + text-align: center; + font-size: .875rem; + } + + &__cancel-transaction-gas-fee-container { + margin-bottom: 16px; + } +} 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 345951b0f..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 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/app/modals/clear-approved-origins/clear-approved-origins.container.js b/ui/app/components/app/modals/clear-approved-origins/clear-approved-origins.container.js new file mode 100644 index 000000000..2276bc7e7 --- /dev/null +++ b/ui/app/components/app/modals/clear-approved-origins/clear-approved-origins.container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' +import ClearApprovedOriginsComponent from './clear-approved-origins.component' +import { clearApprovedOrigins } from '../../../../store/actions' + +const mapDispatchToProps = dispatch => { + return { + clearApprovedOrigins: () => dispatch(clearApprovedOrigins()), + } +} + +export default compose( + withModalProps, + connect(null, mapDispatchToProps) +)(ClearApprovedOriginsComponent) 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/app/modals/confirm-remove-account/confirm-remove-account.component.js b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.component.js new file mode 100644 index 000000000..f35fb85a0 --- /dev/null +++ b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.component.js @@ -0,0 +1,89 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import Modal from '../../modal' +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 = { + hideModal: PropTypes.func.isRequired, + removeAccount: PropTypes.func.isRequired, + identity: PropTypes.object.isRequired, + network: PropTypes.string.isRequired, + } + + static contextTypes = { + t: PropTypes.func, + } + + handleRemove = () => { + this.props.removeAccount(this.props.identity.address) + .then(() => this.props.hideModal()) + } + + handleCancel = () => { + this.props.hideModal() + } + + renderSelectedAccount () { + const { identity } = this.props + return ( + <div className="confirm-remove-account__account"> + <div className="confirm-remove-account__account__identicon"> + <Identicon + address={identity.address} + diameter={32} + /> + </div> + <div className="confirm-remove-account__account__name"> + <span className="confirm-remove-account__account__label">Name</span> + <span className="account_value">{identity.name}</span> + </div> + <div className="confirm-remove-account__account__address"> + <span className="confirm-remove-account__account__label">Public Address</span> + <span className="account_value">{ addressSummary(identity.address, 4, 4) }</span> + </div> + <div className="confirm-remove-account__account__link"> + <a + className="" + href={genAccountLink(identity.address, this.props.network)} + target={'_blank'} + title={this.context.t('etherscanView')} + > + <img src="images/popout.svg" /> + </a> + </div> + </div> + ) + } + + render () { + const { t } = this.context + + return ( + <Modal + headerText={`${t('removeAccount')}?`} + onClose={this.handleCancel} + onSubmit={this.handleRemove} + onCancel={this.handleCancel} + submitText={t('remove')} + cancelText={t('nevermind')} + submitType="secondary" + > + <div> + { this.renderSelectedAccount() } + <div className="confirm-remove-account__description"> + { t('removeAccountDescription') } + <a + className="confirm-remove-account__link" + rel="noopener noreferrer" + target="_blank" href="https://metamask.zendesk.com/hc/en-us/articles/360015289932"> + { t('learnMore') } + </a> + </div> + </div> + </Modal> + ) + } +} diff --git a/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.container.js b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.container.js new file mode 100644 index 000000000..0a3cda5b6 --- /dev/null +++ b/ui/app/components/app/modals/confirm-remove-account/confirm-remove-account.container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import ConfirmRemoveAccount from './confirm-remove-account.component' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' +import { removeAccount } from '../../../../store/actions' + +const mapStateToProps = state => { + return { + network: state.metamask.network, + } +} + +const mapDispatchToProps = dispatch => { + return { + removeAccount: (address) => dispatch(removeAccount(address)), + } +} + +export default compose( + withModalProps, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmRemoveAccount) 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/app/modals/confirm-reset-account/confirm-reset-account.container.js b/ui/app/components/app/modals/confirm-reset-account/confirm-reset-account.container.js new file mode 100644 index 000000000..ffbd40d9d --- /dev/null +++ b/ui/app/components/app/modals/confirm-reset-account/confirm-reset-account.container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' +import ConfirmResetAccount from './confirm-reset-account.component' +import { resetAccount } from '../../../../store/actions' + +const mapDispatchToProps = dispatch => { + return { + resetAccount: () => dispatch(resetAccount()), + } +} + +export default compose( + withModalProps, + connect(null, mapDispatchToProps) +)(ConfirmResetAccount) 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/app/modals/customize-gas/customize-gas.component.js b/ui/app/components/app/modals/customize-gas/customize-gas.component.js new file mode 100644 index 000000000..5db5c79e7 --- /dev/null +++ b/ui/app/components/app/modals/customize-gas/customize-gas.component.js @@ -0,0 +1,162 @@ +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 '../../../ui/button' + +import { + getDecimalGasLimit, + getDecimalGasPrice, + getPrefixedHexGasLimit, + getPrefixedHexGasPrice, +} from './customize-gas.util' + +export default class CustomizeGas extends Component { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + txData: PropTypes.object.isRequired, + hideModal: PropTypes.func, + validate: PropTypes.func, + onSubmit: PropTypes.func, + } + + state = { + gasPrice: 0, + gasLimit: 0, + originalGasPrice: 0, + originalGasLimit: 0, + } + + componentDidMount () { + const { txData = {} } = this.props + const { txParams: { gas: hexGasLimit, gasPrice: hexGasPrice } = {} } = txData + + const gasLimit = getDecimalGasLimit(hexGasLimit) + const gasPrice = getDecimalGasPrice(hexGasPrice) + + this.setState({ + gasPrice, + gasLimit, + originalGasPrice: gasPrice, + originalGasLimit: gasLimit, + }) + } + + handleRevert () { + const { originalGasPrice, originalGasLimit } = this.state + + this.setState({ + gasPrice: originalGasPrice, + gasLimit: originalGasLimit, + }) + } + + handleSave () { + const { onSubmit, hideModal } = this.props + const { gasLimit, gasPrice } = this.state + const prefixedHexGasPrice = getPrefixedHexGasPrice(gasPrice) + const prefixedHexGasLimit = getPrefixedHexGasLimit(gasLimit) + + Promise.resolve(onSubmit({ gasPrice: prefixedHexGasPrice, gasLimit: prefixedHexGasLimit })) + .then(() => hideModal()) + } + + validate () { + const { gasLimit, gasPrice } = this.state + return this.props.validate({ + gasPrice: getPrefixedHexGasPrice(gasPrice), + gasLimit: getPrefixedHexGasLimit(gasLimit), + }) + } + + render () { + const { t, metricsEvent } = this.context + const { hideModal } = this.props + const { gasPrice, gasLimit, originalGasPrice, originalGasLimit } = this.state + const { valid, errorKey } = this.validate() + + return ( + <div className="customize-gas"> + <div className="customize-gas__content"> + <div className="customize-gas__header"> + <div className="customize-gas__title"> + { this.context.t('customGas') } + </div> + <div + className="customize-gas__close" + onClick={() => hideModal()} + /> + </div> + <div className="customize-gas__body"> + <GasModalCard + value={gasPrice} + min={MIN_GAS_PRICE_GWEI} + step={1} + onChange={value => this.setState({ gasPrice: value })} + title={t('gasPrice')} + copy={t('gasPriceCalculation')} + /> + <GasModalCard + value={gasLimit} + min={1} + step={1} + onChange={value => this.setState({ gasLimit: value })} + title={t('gasLimit')} + copy={t('gasLimitCalculation')} + /> + </div> + <div className="customize-gas__footer"> + { !valid && <div className="customize-gas__error-message">{ t(errorKey) }</div> } + <div + className="customize-gas__revert" + onClick={() => this.handleRevert()} + > + { t('revert') } + </div> + <div className="customize-gas__buttons"> + <Button + type="default" + className="customize-gas__cancel" + onClick={() => hideModal()} + style={{ marginRight: '10px' }} + > + { t('cancel') } + </Button> + <Button + type="primary" + className="customize-gas__save" + 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} + > + { t('save') } + </Button> + </div> + </div> + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/modals/customize-gas/customize-gas.container.js b/ui/app/components/app/modals/customize-gas/customize-gas.container.js new file mode 100644 index 000000000..221881a8a --- /dev/null +++ b/ui/app/components/app/modals/customize-gas/customize-gas.container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux' +import CustomizeGas from './customize-gas.component' +import { hideModal } from '../../../../store/actions' + +const mapStateToProps = state => { + const { appState: { modal: { modalState: { props } } } } = state + const { txData, onSubmit, validate } = props + + return { + txData, + onSubmit, + validate, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(CustomizeGas) diff --git a/ui/app/components/app/modals/customize-gas/customize-gas.util.js b/ui/app/components/app/modals/customize-gas/customize-gas.util.js new file mode 100644 index 000000000..e686183bd --- /dev/null +++ b/ui/app/components/app/modals/customize-gas/customize-gas.util.js @@ -0,0 +1,34 @@ +import ethUtil from 'ethereumjs-util' +import { conversionUtil } from '../../../../helpers/utils/conversion-util' + +export function getDecimalGasLimit (hexGasLimit) { + return conversionUtil(hexGasLimit, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) +} + +export function getDecimalGasPrice (hexGasPrice) { + return conversionUtil(hexGasPrice, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + toDenomination: 'GWEI', + }) +} + +export function getPrefixedHexGasLimit (gasLimit) { + return ethUtil.addHexPrefix(conversionUtil(gasLimit, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + })) +} + +export function getPrefixedHexGasPrice (gasPrice) { + return ethUtil.addHexPrefix(conversionUtil(gasPrice, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + })) +} 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/app/modals/deposit-ether-modal.js b/ui/app/components/app/modals/deposit-ether-modal.js new file mode 100644 index 000000000..72bb1df89 --- /dev/null +++ b/ui/app/components/app/modals/deposit-ether-modal.js @@ -0,0 +1,220 @@ +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 +const actions = require('../../../store/actions') +const { getNetworkDisplayName } = require('../../../../../app/scripts/controllers/network/util') +const ShapeshiftForm = require('../shapeshift-form') + +import Button from '../../ui/button' + +let DIRECT_DEPOSIT_ROW_TITLE +let DIRECT_DEPOSIT_ROW_TEXT +let COINBASE_ROW_TITLE +let COINBASE_ROW_TEXT +let SHAPESHIFT_ROW_TITLE +let SHAPESHIFT_ROW_TEXT +let FAUCET_ROW_TITLE + +function mapStateToProps (state) { + return { + network: state.metamask.network, + address: state.metamask.selectedAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + toCoinbase: (address) => { + dispatch(actions.buyEth({ network: '1', address, amount: 0 })) + }, + hideModal: () => { + dispatch(actions.hideModal()) + }, + hideWarning: () => { + dispatch(actions.hideWarning()) + }, + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + toFaucet: network => dispatch(actions.buyEth({ network })), + } +} + +inherits(DepositEtherModal, Component) +function DepositEtherModal (props, context) { + Component.call(this) + + // 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') + SHAPESHIFT_ROW_TITLE = context.t('depositShapeShift') + SHAPESHIFT_ROW_TEXT = context.t('depositShapeShiftExplainer') + FAUCET_ROW_TITLE = context.t('testFaucet') + + this.state = { + buyingWithShapeshift: false, + } +} + +DepositEtherModal.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(DepositEtherModal) + + +DepositEtherModal.prototype.facuetRowText = function (networkName) { + return this.context.t('getEtherFromFaucet', [networkName]) +} + +DepositEtherModal.prototype.renderRow = function ({ + logo, + title, + text, + buttonLabel, + onButtonClick, + hide, + className, + hideButton, + hideTitle, + onBackClick, + showBackButton, +}) { + if (hide) { + return null + } + + return h('div', { + className: className || 'deposit-ether-modal__buy-row', + }, [ + + onBackClick && showBackButton && h('div.deposit-ether-modal__buy-row__back', { + onClick: onBackClick, + }, [ + + h('i.fa.fa-arrow-left.cursor-pointer'), + + ]), + + h('div.deposit-ether-modal__buy-row__logo-container', [logo]), + + h('div.deposit-ether-modal__buy-row__description', [ + + !hideTitle && h('div.deposit-ether-modal__buy-row__description__title', [title]), + + h('div.deposit-ether-modal__buy-row__description__text', [text]), + + ]), + + !hideButton && h('div.deposit-ether-modal__buy-row__button', [ + h(Button, { + type: 'primary', + className: 'deposit-ether-modal__deposit-button', + large: true, + onClick: onButtonClick, + }, [buttonLabel]), + ]), + + ]) +} + +DepositEtherModal.prototype.render = function () { + const { network, toCoinbase, address, toFaucet } = this.props + const { buyingWithShapeshift } = this.state + + const isTestNetwork = ['3', '4', '42'].find(n => n === network) + const networkName = getNetworkDisplayName(network) + + return h('div.page-container.page-container--full-width.page-container--full-height', {}, [ + + h('div.page-container__header', [ + + h('div.page-container__title', [this.context.t('depositEther')]), + + h('div.page-container__subtitle', [ + this.context.t('needEtherInWallet'), + ]), + + h('div.page-container__header-close', { + onClick: () => { + this.setState({ buyingWithShapeshift: false }) + this.props.hideWarning() + this.props.hideModal() + }, + }), + + ]), + + h('.page-container__content', {}, [ + + h('div.deposit-ether-modal__buy-rows', [ + + this.renderRow({ + logo: h('img.deposit-ether-modal__logo', { + src: './images/deposit-eth.svg', + }), + title: DIRECT_DEPOSIT_ROW_TITLE, + text: DIRECT_DEPOSIT_ROW_TEXT, + buttonLabel: this.context.t('viewAccount'), + onButtonClick: () => this.goToAccountDetailsModal(), + hide: buyingWithShapeshift, + }), + + this.renderRow({ + logo: h('i.fa.fa-tint.fa-2x'), + title: FAUCET_ROW_TITLE, + text: this.facuetRowText(networkName), + buttonLabel: this.context.t('getEther'), + onButtonClick: () => toFaucet(network), + hide: !isTestNetwork || buyingWithShapeshift, + }), + + this.renderRow({ + logo: h('div.deposit-ether-modal__logo', { + style: { + backgroundImage: 'url(\'./images/coinbase logo.png\')', + height: '40px', + }, + }), + title: COINBASE_ROW_TITLE, + text: COINBASE_ROW_TEXT, + buttonLabel: this.context.t('continueToCoinbase'), + onButtonClick: () => toCoinbase(address), + hide: isTestNetwork || buyingWithShapeshift, + }), + + this.renderRow({ + logo: h('div.deposit-ether-modal__logo', { + style: { + backgroundImage: 'url(\'./images/shapeshift logo.png\')', + }, + }), + title: SHAPESHIFT_ROW_TITLE, + text: SHAPESHIFT_ROW_TEXT, + buttonLabel: this.context.t('shapeshiftBuy'), + onButtonClick: () => this.setState({ buyingWithShapeshift: true }), + hide: isTestNetwork, + hideButton: buyingWithShapeshift, + hideTitle: buyingWithShapeshift, + onBackClick: () => this.setState({ buyingWithShapeshift: false }), + showBackButton: this.state.buyingWithShapeshift, + className: buyingWithShapeshift && 'deposit-ether-modal__buy-row__shapeshift-buy', + }), + + buyingWithShapeshift && h(ShapeshiftForm), + + ]), + + ]), + ]) +} + +DepositEtherModal.prototype.goToAccountDetailsModal = function () { + this.props.hideWarning() + this.props.hideModal() + this.props.showAccountDetailModal() +} diff --git a/ui/app/components/app/modals/edit-account-name-modal.js b/ui/app/components/app/modals/edit-account-name-modal.js new file mode 100644 index 000000000..41a9862e9 --- /dev/null +++ b/ui/app/components/app/modals/edit-account-name-modal.js @@ -0,0 +1,83 @@ +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 +const actions = require('../../../store/actions') +const { getSelectedAccount } = require('../../../selectors/selectors') + +function mapStateToProps (state) { + return { + selectedAccount: getSelectedAccount(state), + identity: state.appState.modal.modalState.props.identity, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + setAccountLabel: (account, label) => { + dispatch(actions.setAccountLabel(account, label)) + }, + } +} + +inherits(EditAccountNameModal, Component) +function EditAccountNameModal (props) { + Component.call(this) + + this.state = { + inputText: props.identity.name, + } +} + +EditAccountNameModal.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(EditAccountNameModal) + + +EditAccountNameModal.prototype.render = function () { + const { hideModal, setAccountLabel, identity } = this.props + + return h('div', {}, [ + h('div.flex-column.edit-account-name-modal-content', { + }, [ + + h('div.edit-account-name-modal-cancel', { + onClick: () => { + hideModal() + }, + }, [ + h('i.fa.fa-times'), + ]), + + h('div.edit-account-name-modal-title', { + }, [this.context.t('editAccountName')]), + + h('input.edit-account-name-modal-input', { + placeholder: identity.name, + onChange: (event) => { + this.setState({ inputText: event.target.value }) + }, + value: this.state.inputText, + }, []), + + h('button.btn-clear.edit-account-name-modal-save-button.allcaps', { + onClick: () => { + if (this.state.inputText.length !== 0) { + setAccountLabel(identity.address, this.state.inputText) + hideModal() + } + }, + disabled: this.state.inputText.length === 0, + }, [ + this.context.t('save'), + ]), + + ]), + ]) +} diff --git a/ui/app/components/app/modals/export-private-key-modal.js b/ui/app/components/app/modals/export-private-key-modal.js new file mode 100644 index 000000000..639887d4c --- /dev/null +++ b/ui/app/components/app/modals/export-private-key-modal.js @@ -0,0 +1,177 @@ +const log = require('loglevel') +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 +const { stripHexPrefix } = require('ethereumjs-util') +const actions = require('../../../store/actions') +const AccountModalContainer = require('./account-modal-container') +const { getSelectedIdentity } = require('../../../selectors/selectors') +const ReadOnlyInput = require('../../ui/readonly-input') +const copyToClipboard = require('copy-to-clipboard') +const { checksumAddress } = require('../../../helpers/utils/util') +import Button from '../../ui/button' + +function mapStateToPropsFactory () { + let selectedIdentity = null + return function mapStateToProps (state) { + // We should **not** change the identity displayed here even if it changes from underneath us. + // If we do, we will be showing the user one private key and a **different** address and name. + // Note that the selected identity **will** change from underneath us when we unlock the keyring + // which is the expected behavior that we are side-stepping. + selectedIdentity = selectedIdentity || getSelectedIdentity(state) + return { + warning: state.appState.warning, + privateKey: state.appState.accountDetail.privateKey, + network: state.metamask.network, + selectedIdentity, + previousModalState: state.appState.modal.previousModalState.name, + } + } +} + +function mapDispatchToProps (dispatch) { + return { + exportAccount: (password, address) => { + return dispatch(actions.exportAccount(password, address)) + .then((res) => { + dispatch(actions.hideWarning()) + return res + }) + }, + showAccountDetailModal: () => dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })), + hideModal: () => dispatch(actions.hideModal()), + } +} + +inherits(ExportPrivateKeyModal, Component) +function ExportPrivateKeyModal () { + Component.call(this) + + this.state = { + password: '', + privateKey: null, + showWarning: true, + } +} + +ExportPrivateKeyModal.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToPropsFactory, mapDispatchToProps)(ExportPrivateKeyModal) + + +ExportPrivateKeyModal.prototype.exportAccountAndGetPrivateKey = function (password, address) { + const { exportAccount } = this.props + + exportAccount(password, address) + .then(privateKey => this.setState({ + privateKey, + showWarning: false, + })) + .catch((e) => log.error(e)) +} + +ExportPrivateKeyModal.prototype.renderPasswordLabel = function (privateKey) { + return h('span.private-key-password-label', privateKey + ? this.context.t('copyPrivateKey') + : this.context.t('typePassword') + ) +} + +ExportPrivateKeyModal.prototype.renderPasswordInput = function (privateKey) { + const plainKey = privateKey && stripHexPrefix(privateKey) + + return privateKey + ? h(ReadOnlyInput, { + wrapperClass: 'private-key-password-display-wrapper', + inputClass: 'private-key-password-display-textarea', + textarea: true, + value: plainKey, + onClick: () => copyToClipboard(plainKey), + }) + : h('input.private-key-password-input', { + type: 'password', + onChange: event => this.setState({ password: event.target.value }), + }) +} + +ExportPrivateKeyModal.prototype.renderButtons = function (privateKey, password, address, hideModal) { + return h('div.export-private-key-buttons', {}, [ + !privateKey && h(Button, { + type: 'default', + large: true, + className: 'export-private-key__button export-private-key__button--cancel', + onClick: () => hideModal(), + }, this.context.t('cancel')), + + (privateKey + ? ( + h(Button, { + type: 'primary', + large: true, + className: 'export-private-key__button', + onClick: () => hideModal(), + }, this.context.t('done')) + ) : ( + h(Button, { + type: 'primary', + large: true, + className: 'export-private-key__button', + onClick: () => this.exportAccountAndGetPrivateKey(this.state.password, address), + }, this.context.t('confirm')) + ) + ), + + ]) +} + +ExportPrivateKeyModal.prototype.render = function () { + const { + selectedIdentity, + warning, + showAccountDetailModal, + hideModal, + previousModalState, + } = this.props + const { name, address } = selectedIdentity + + const { + privateKey, + showWarning, + } = this.state + + return h(AccountModalContainer, { + selectedIdentity, + showBackButton: previousModalState === 'ACCOUNT_DETAILS', + backButtonAction: () => showAccountDetailModal(), + }, [ + + h('span.account-name', name), + + h(ReadOnlyInput, { + wrapperClass: 'ellip-address-wrapper', + inputClass: 'qr-ellip-address ellip-address', + value: checksumAddress(address), + }), + + h('div.account-modal-divider'), + + h('span.modal-body-title', this.context.t('showPrivateKeys')), + + h('div.private-key-password', {}, [ + this.renderPasswordLabel(privateKey), + + this.renderPasswordInput(privateKey), + + showWarning && warning ? h('span.private-key-password-error', warning) : null, + ]), + + h('div.private-key-password-warning', this.context.t('privateKeyWarning')), + + this.renderButtons(privateKey, this.state.password, address, hideModal), + + ]) +} diff --git a/ui/app/components/app/modals/hide-token-confirmation-modal.js b/ui/app/components/app/modals/hide-token-confirmation-modal.js new file mode 100644 index 000000000..8a9a48fd2 --- /dev/null +++ b/ui/app/components/app/modals/hide-token-confirmation-modal.js @@ -0,0 +1,83 @@ +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 +const actions = require('../../../store/actions') +import Identicon from '../../ui/identicon' + +function mapStateToProps (state) { + return { + network: state.metamask.network, + token: state.appState.modal.modalState.props.token, + assetImages: state.metamask.assetImages, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => dispatch(actions.hideModal()), + hideToken: address => { + dispatch(actions.removeToken(address)) + .then(() => { + dispatch(actions.hideModal()) + }) + }, + } +} + +inherits(HideTokenConfirmationModal, Component) +function HideTokenConfirmationModal () { + Component.call(this) + + this.state = {} +} + +HideTokenConfirmationModal.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(HideTokenConfirmationModal) + + +HideTokenConfirmationModal.prototype.render = function () { + const { token, network, hideToken, hideModal, assetImages } = this.props + const { symbol, address } = token + const image = assetImages[address] + + return h('div.hide-token-confirmation', {}, [ + h('div.hide-token-confirmation__container', { + }, [ + h('div.hide-token-confirmation__title', {}, [ + this.context.t('hideTokenPrompt'), + ]), + + h(Identicon, { + className: 'hide-token-confirmation__identicon', + diameter: 45, + address, + network, + image, + }), + + h('div.hide-token-confirmation__symbol', {}, symbol), + + h('div.hide-token-confirmation__copy', {}, [ + this.context.t('readdToken'), + ]), + + h('div.hide-token-confirmation__buttons', {}, [ + h('button.btn-cancel.hide-token-confirmation__button.allcaps', { + onClick: () => hideModal(), + }, [ + this.context.t('cancel'), + ]), + h('button.btn-clear.hide-token-confirmation__button.allcaps', { + onClick: () => hideToken(address), + }, [ + this.context.t('hide'), + ]), + ]), + ]), + ]) +} 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/modals/loading-network-error/index.js b/ui/app/components/app/modals/loading-network-error/index.js index b3737458a..b3737458a 100644 --- a/ui/app/components/modals/loading-network-error/index.js +++ b/ui/app/components/app/modals/loading-network-error/index.js diff --git a/ui/app/components/modals/loading-network-error/loading-network-error.component.js b/ui/app/components/app/modals/loading-network-error/loading-network-error.component.js index 44f71e4b2..44f71e4b2 100644 --- a/ui/app/components/modals/loading-network-error/loading-network-error.component.js +++ b/ui/app/components/app/modals/loading-network-error/loading-network-error.component.js 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/modals/metametrics-opt-in-modal/index.js b/ui/app/components/app/modals/metametrics-opt-in-modal/index.js index 47f946757..47f946757 100644 --- a/ui/app/components/modals/metametrics-opt-in-modal/index.js +++ b/ui/app/components/app/modals/metametrics-opt-in-modal/index.js diff --git a/ui/app/components/modals/metametrics-opt-in-modal/index.scss b/ui/app/components/app/modals/metametrics-opt-in-modal/index.scss index 88b6d7a4d..88b6d7a4d 100644 --- a/ui/app/components/modals/metametrics-opt-in-modal/index.scss +++ b/ui/app/components/app/modals/metametrics-opt-in-modal/index.scss 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/app/modals/modal.js b/ui/app/components/app/modals/modal.js new file mode 100644 index 000000000..717f623af --- /dev/null +++ b/ui/app/components/app/modals/modal.js @@ -0,0 +1,511 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const FadeModal = require('boron').FadeModal +const actions = require('../../../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') +const DepositEtherModal = require('./deposit-ether-modal') +const AccountDetailsModal = require('./account-details-modal') +const EditAccountNameModal = require('./edit-account-name-modal') +const ExportPrivateKeyModal = require('./export-private-key-modal') +const NewAccountModal = require('./new-account-modal') +const ShapeshiftDepositTxModal = require('./shapeshift-deposit-tx-modal.js') +const HideTokenConfirmationModal = require('./hide-token-confirmation-modal') +const 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 CancelTransaction from './cancel-transaction' + +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)', + border: '1px solid #CCCFD1', + borderRadius: '8px', + backgroundColor: '#FFFFFF', + boxShadow: '0 2px 22px 0 rgba(0,0,0,0.2)', +} + +const modalContainerLaptopStyle = { + ...modalContainerBaseStyle, + width: '344px', + top: '15%', +} + +const modalContainerMobileStyle = { + ...modalContainerBaseStyle, + width: '309px', + top: '12.5%', +} + +const accountModalStyle = { + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + borderRadius: '4px', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '360px', + // top: 'calc(33% + 45px)', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + borderRadius: '4px', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + contentStyle: { + borderRadius: '4px', + }, +} + +const MODALS = { + BUY: { + contents: [ + h(BuyOptions, {}, []), + ], + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '48vh' : '36.5vh', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', + top: '10%', + }, + laptopModalStyle: { + width: '66%', + maxWidth: '550px', + top: 'calc(10% + 10px)', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', + transform: 'none', + }, + }, + + DEPOSIT_ETHER: { + contents: [ + h(DepositEtherModal, {}, []), + ], + onHide: (props) => props.hideWarning(), + mobileModalStyle: { + width: '100%', + height: '100%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', + top: '0', + display: 'flex', + }, + laptopModalStyle: { + width: 'initial', + maxWidth: '850px', + top: 'calc(10% + 10px)', + left: '0', + right: '0', + margin: '0 auto', + boxShadow: '0 0 6px 0 rgba(0,0,0,0.3)', + borderRadius: '7px', + transform: 'none', + height: 'calc(80% - 20px)', + overflowY: 'hidden', + }, + contentStyle: { + borderRadius: '7px', + height: '100%', + }, + }, + + EDIT_ACCOUNT_NAME: { + contents: [ + h(EditAccountNameModal, {}, []), + ], + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '48vh' : '36.5vh', + top: '10%', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '375px', + // top: 'calc(30% + 10px)', + top: '10%', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + + ACCOUNT_DETAILS: { + contents: [ + h(AccountDetailsModal, {}, []), + ], + ...accountModalStyle, + }, + + EXPORT_PRIVATE_KEY: { + contents: [ + h(ExportPrivateKeyModal, {}, []), + ], + ...accountModalStyle, + }, + + SHAPESHIFT_DEPOSIT_TX: { + contents: [ + h(ShapeshiftDepositTxModal), + ], + ...accountModalStyle, + }, + + HIDE_TOKEN_CONFIRMATION: { + contents: [ + h(HideTokenConfirmationModal, {}, []), + ], + mobileModalStyle: { + width: '95%', + top: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + + CLEAR_APPROVED_ORIGINS: { + contents: h(ClearApprovedOrigins), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + METAMETRICS_OPT_IN_MODAL: { + contents: h(MetaMetricsOptInModal), + mobileModalStyle: { + ...modalContainerMobileStyle, + width: '100%', + height: '100%', + top: '0px', + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + top: '10%', + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + OLD_UI_NOTIFICATION_MODAL: { + contents: [ + h(NotifcationModal, { + header: 'oldUI', + message: 'oldUIMessage', + }), + ], + mobileModalStyle: { + width: '95%', + top: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + + 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: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + CONFIRM_REMOVE_ACCOUNT: { + contents: h(ConfirmRemoveAccount), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + NEW_ACCOUNT: { + contents: [ + h(NewAccountModal, {}, []), + ], + mobileModalStyle: { + width: '95%', + // top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '449px', + // top: 'calc(33% + 45px)', + top: '10%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + + CUSTOMIZE_GAS: { + contents: [ + h(ConfirmCustomizeGasModal), + ], + mobileModalStyle: { + width: '100vw', + height: '100vh', + top: '0', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: 'auto', + height: '0px', + top: '80px', + left: '0px', + transform: 'none', + margin: '0 auto', + position: 'relative', + }, + contentStyle: { + borderRadius: '8px', + }, + customOnHideOpts: { + action: resetCustomGasData, + args: [], + }, + }, + + TRANSACTION_CONFIRMED: { + disableBackdropClick: true, + contents: h(TransactionConfirmed), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + QR_SCANNER: { + contents: h(QRScanner), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + CANCEL_TRANSACTION: { + contents: h(CancelTransaction), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + REJECT_TRANSACTIONS: { + contents: h(RejectTransactions), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + DEFAULT: { + contents: [], + mobileModalStyle: {}, + laptopModalStyle: {}, + }, +} + +const BACKDROPSTYLE = { + backgroundColor: 'rgba(0, 0, 0, 0.5)', +} + +function mapStateToProps (state) { + return { + active: state.appState.modal.open, + modalState: state.appState.modal.modalState, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: (customOnHideOpts) => { + dispatch(actions.hideModal()) + if (customOnHideOpts && customOnHideOpts.action) { + dispatch(customOnHideOpts.action(...customOnHideOpts.args)) + } + }, + hideWarning: () => { + dispatch(actions.hideWarning()) + }, + + } +} + +// Global Modal Component +inherits(Modal, Component) +function Modal () { + Component.call(this) +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(Modal) + +Modal.prototype.render = function () { + const modal = MODALS[this.props.modalState.name || 'DEFAULT'] + + const { contents: children, disableBackdropClick = false } = modal + const modalStyle = modal[isMobileView() ? 'mobileModalStyle' : 'laptopModalStyle'] + const contentStyle = modal.contentStyle || {} + + return h(FadeModal, + { + className: 'modal', + keyboard: false, + onHide: () => { + if (modal.onHide) { + modal.onHide(this.props) + } + this.onHide(modal.customOnHideOpts) + }, + ref: (ref) => { + this.modalRef = ref + }, + modalStyle, + contentStyle, + backdropStyle: BACKDROPSTYLE, + closeOnClick: !disableBackdropClick, + }, + children, + ) +} + +Modal.prototype.componentWillReceiveProps = function (nextProps) { + if (nextProps.active) { + this.show() + } else if (this.props.active) { + this.hide() + } +} + +Modal.prototype.onHide = function (customOnHideOpts) { + if (this.props.onHideCallback) { + this.props.onHideCallback() + } + this.props.hideModal(customOnHideOpts) +} + +Modal.prototype.hide = function () { + this.modalRef.hide() +} + +Modal.prototype.show = function () { + this.modalRef.show() +} diff --git a/ui/app/components/app/modals/new-account-modal.js b/ui/app/components/app/modals/new-account-modal.js new file mode 100644 index 000000000..27c81a701 --- /dev/null +++ b/ui/app/components/app/modals/new-account-modal.js @@ -0,0 +1,112 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const actions = require('../../../store/actions') + +class NewAccountModal extends Component { + constructor (props) { + super(props) + const { numberOfExistingAccounts = 0 } = props + const newAccountNumber = numberOfExistingAccounts + 1 + + this.state = { + newAccountName: `${this.context.t('account')} ${newAccountNumber}`, + } + } + + render () { + const { newAccountName } = this.state + + return h('div', [ + h('div.new-account-modal-wrapper', { + }, [ + h('div.new-account-modal-header', {}, [ + this.context.t('newAccount'), + ]), + + h('div.modal-close-x', { + onClick: this.props.hideModal, + }), + + h('div.new-account-modal-content', {}, [ + this.context.t('accountName'), + ]), + + h('div.new-account-input-wrapper', {}, [ + h('input.new-account-input', { + value: this.state.newAccountName, + placeholder: this.context.t('sampleAccountName'), + onChange: event => this.setState({ newAccountName: event.target.value }), + }, []), + ]), + + h('div.new-account-modal-content.after-input', {}, [ + this.context.t('or'), + ]), + + h('div.new-account-modal-content.after-input.pointer', { + onClick: () => { + this.props.hideModal() + this.props.showImportPage() + }, + }, this.context.t('importAnAccount')), + + h('div.new-account-modal-content.button.allcaps', {}, [ + h('button.btn-clear', { + onClick: () => this.props.createAccount(newAccountName), + }, [ + this.context.t('save'), + ]), + ]), + ]), + ]) + } +} + +NewAccountModal.propTypes = { + hideModal: PropTypes.func, + showImportPage: PropTypes.func, + createAccount: PropTypes.func, + numberOfExistingAccounts: PropTypes.number, + t: PropTypes.func, +} + +const mapStateToProps = state => { + const { metamask: { network, selectedAddress, identities = {} } } = state + const numberOfExistingAccounts = Object.keys(identities).length + + return { + network, + address: selectedAddress, + numberOfExistingAccounts, + } +} + +const mapDispatchToProps = dispatch => { + return { + toCoinbase: (address) => { + dispatch(actions.buyEth({ network: '1', address, amount: 0 })) + }, + hideModal: () => { + dispatch(actions.hideModal()) + }, + createAccount: (newAccountName) => { + dispatch(actions.addNewAccount()) + .then((newAccountAddress) => { + if (newAccountName) { + dispatch(actions.setAccountLabel(newAccountAddress, newAccountName)) + } + dispatch(actions.hideModal()) + }) + }, + showImportPage: () => dispatch(actions.showImportPage()), + } +} + +NewAccountModal.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(NewAccountModal) + diff --git a/ui/app/components/app/modals/notification-modal.js b/ui/app/components/app/modals/notification-modal.js new file mode 100644 index 000000000..2d73b2cfa --- /dev/null +++ b/ui/app/components/app/modals/notification-modal.js @@ -0,0 +1,81 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const actions = require('../../../store/actions') + +class NotificationModal extends Component { + render () { + const { + header, + message, + showCancelButton = false, + showConfirmButton = false, + hideModal, + onConfirm, + } = this.props + + const showButtons = showCancelButton || showConfirmButton + + return h('div', [ + h('div.notification-modal__wrapper', { + }, [ + + h('div.notification-modal__header', {}, [ + this.context.t(header), + ]), + + h('div.notification-modal__message-wrapper', {}, [ + h('div.notification-modal__message', {}, [ + this.context.t(message), + ]), + ]), + + h('div.modal-close-x', { + onClick: hideModal, + }), + + showButtons && h('div.notification-modal__buttons', [ + + showCancelButton && h('div.btn-cancel.notification-modal__buttons__btn', { + onClick: hideModal, + }, 'Cancel'), + + showConfirmButton && h('div.btn-clear.notification-modal__buttons__btn', { + onClick: () => { + onConfirm() + hideModal() + }, + }, 'Confirm'), + + ]), + + ]), + ]) + } +} + +NotificationModal.propTypes = { + hideModal: PropTypes.func, + header: PropTypes.string, + message: PropTypes.node, + showCancelButton: PropTypes.bool, + showConfirmButton: PropTypes.bool, + onConfirm: PropTypes.func, + t: PropTypes.func, +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +NotificationModal.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(null, mapDispatchToProps)(NotificationModal) + 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/app/modals/qr-scanner/qr-scanner.component.js b/ui/app/components/app/modals/qr-scanner/qr-scanner.component.js new file mode 100644 index 000000000..20915b5f9 --- /dev/null +++ b/ui/app/components/app/modals/qr-scanner/qr-scanner.component.js @@ -0,0 +1,216 @@ +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 '../../../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 = { + hideModal: PropTypes.func.isRequired, + qrCodeDetected: PropTypes.func, + scanQrCode: PropTypes.func, + error: PropTypes.bool, + errorType: PropTypes.string, + } + + static contextTypes = { + t: PropTypes.func, + } + + constructor (props, context) { + super(props) + + this.state = { + ready: false, + msg: context.t('accessingYourCamera'), + } + this.codeReader = null + this.permissionChecker = null + this.needsToReinit = false + + // Clear pre-existing qr code data before scanning + this.props.qrCodeDetected(null) + } + + componentDidMount () { + this.initCamera() + } + + async checkPermisisions () { + const { permissions } = await WebcamUtils.checkStatus() + if (permissions) { + clearTimeout(this.permissionChecker) + // Let the video stream load first... + setTimeout(_ => { + this.setState({ + ready: true, + msg: this.context.t('scanInstructions'), + }) + if (this.needsToReinit) { + this.initCamera() + this.needsToReinit = false + } + }, 2000) + } else { + // Keep checking for permissions + this.permissionChecker = setTimeout(_ => { + this.checkPermisisions() + }, 1000) + } + } + + componentWillUnmount () { + clearTimeout(this.permissionChecker) + if (this.codeReader) { + this.codeReader.reset() + } + } + + initCamera () { + this.codeReader = new BrowserQRCodeReader() + this.codeReader.getVideoInputDevices() + .then(videoInputDevices => { + clearTimeout(this.permissionChecker) + this.checkPermisisions() + this.codeReader.decodeFromInputVideoDevice(undefined, 'video') + .then(content => { + const result = this.parseContent(content.text) + if (result.type !== 'unknown') { + this.props.qrCodeDetected(result) + this.stopAndClose() + } else { + this.setState({msg: this.context.t('unknownQrCode')}) + } + }) + .catch(err => { + if (err && err.name === 'NotAllowedError') { + this.setState({msg: this.context.t('youNeedToAllowCameraAccess')}) + clearTimeout(this.permissionChecker) + this.needsToReinit = true + this.checkPermisisions() + } + }) + }).catch(err => { + console.error('[QR-SCANNER]: getVideoInputDevices threw an exception: ', err) + }) + } + + parseContent (content) { + let type = 'unknown' + let values = {} + + // Here we could add more cases + // To parse other type of links + // For ex. EIP-681 (https://eips.ethereum.org/EIPS/eip-681) + + + // Ethereum address links - fox ex. ethereum:0x.....1111 + if (content.split('ethereum:').length > 1) { + + type = 'address' + values = {'address': content.split('ethereum:')[1] } + + // Regular ethereum addresses - fox ex. 0x.....1111 + } else if (content.substring(0, 2).toLowerCase() === '0x') { + + type = 'address' + values = {'address': content } + + } + return {type, values} + } + + + stopAndClose = () => { + if (this.codeReader) { + this.codeReader.reset() + } + this.setState({ ready: false }) + this.props.hideModal() + } + + tryAgain = () => { + // close the modal + this.stopAndClose() + // wait for the animation and try again + setTimeout(_ => { + this.props.scanQrCode() + }, 1000) + } + + renderVideo () { + return ( + <div className={'qr-scanner__content__video-wrapper'}> + <video + id="video" + style={{ + display: this.state.ready ? 'block' : 'none', + }} + /> + { !this.state.ready ? <Spinner color={'#F7C06C'} /> : null} + </div> + ) + } + + renderErrorModal () { + let title, msg + + if (this.props.error) { + if (this.props.errorType === 'NO_WEBCAM_FOUND') { + title = this.context.t('noWebcamFoundTitle') + msg = this.context.t('noWebcamFound') + } else { + title = this.context.t('unknownCameraErrorTitle') + msg = this.context.t('unknownCameraError') + } + } + + return ( + <div className="qr-scanner"> + <div className="qr-scanner__close" onClick={this.stopAndClose}></div> + + <div className="qr-scanner__image"> + <img src={'images/webcam.svg'} width={70} height={70} /> + </div> + <div className="qr-scanner__title"> + { title } + </div> + <div className={'qr-scanner__error'}> + {msg} + </div> + <PageContainerFooter + onCancel={this.stopAndClose} + onSubmit={this.tryAgain} + cancelText={this.context.t('cancel')} + submitText={this.context.t('tryAgain')} + submitButtonType="confirm" + /> + </div> + ) + } + + render () { + const { t } = this.context + + if (this.props.error) { + return this.renderErrorModal() + } + + return ( + <div className="qr-scanner"> + <div className="qr-scanner__close" onClick={this.stopAndClose}></div> + <div className="qr-scanner__title"> + { `${t('scanQrCode')}` } + </div> + <div className="qr-scanner__content"> + { this.renderVideo() } + </div> + <div className={'qr-scanner__status'}> + {this.state.msg} + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/modals/qr-scanner/qr-scanner.container.js b/ui/app/components/app/modals/qr-scanner/qr-scanner.container.js new file mode 100644 index 000000000..2210fbed2 --- /dev/null +++ b/ui/app/components/app/modals/qr-scanner/qr-scanner.container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux' +import QrScanner from './qr-scanner.component' + +const { hideModal, qrCodeDetected, showQrScanner } = require('../../../../store/actions') +import { + SEND_ROUTE, +} from '../../../../helpers/constants/routes' + +const mapStateToProps = state => { + return { + error: state.appState.modal.modalState.props.error, + errorType: state.appState.modal.modalState.props.errorType, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), + scanQrCode: () => dispatch(showQrScanner(SEND_ROUTE)), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(QrScanner) 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/app/modals/reject-transactions/reject-transactions.container.js b/ui/app/components/app/modals/reject-transactions/reject-transactions.container.js new file mode 100644 index 000000000..d2af05573 --- /dev/null +++ b/ui/app/components/app/modals/reject-transactions/reject-transactions.container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import RejectTransactionsModal from './reject-transactions.component' +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props' + +const mapStateToProps = (state, ownProps) => { + const { unapprovedTxCount } = ownProps + + return { + unapprovedTxCount, + } +} + +export default compose( + withModalProps, + connect(mapStateToProps), +)(RejectTransactionsModal) diff --git a/ui/app/components/app/modals/shapeshift-deposit-tx-modal.js b/ui/app/components/app/modals/shapeshift-deposit-tx-modal.js new file mode 100644 index 000000000..ada9430f7 --- /dev/null +++ b/ui/app/components/app/modals/shapeshift-deposit-tx-modal.js @@ -0,0 +1,40 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../../store/actions') +const QrView = require('../../ui/qr-code') +const AccountModalContainer = require('./account-modal-container') + +function mapStateToProps (state) { + return { + Qr: state.appState.modal.modalState.props.Qr, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +inherits(ShapeshiftDepositTxModal, Component) +function ShapeshiftDepositTxModal () { + Component.call(this) + +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ShapeshiftDepositTxModal) + +ShapeshiftDepositTxModal.prototype.render = function () { + const { Qr } = this.props + + return h(AccountModalContainer, { + }, [ + h('div', {}, [ + h(QrView, {key: 'qr', Qr}), + ]), + ]) +} diff --git a/ui/app/components/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/app/modals/transaction-confirmed/transaction-confirmed.container.js b/ui/app/components/app/modals/transaction-confirmed/transaction-confirmed.container.js new file mode 100644 index 000000000..9089ec158 --- /dev/null +++ b/ui/app/components/app/modals/transaction-confirmed/transaction-confirmed.container.js @@ -0,0 +1,4 @@ +import TransactionConfirmed from './transaction-confirmed.component' +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/app/network-display/network-display.component.js b/ui/app/components/app/network-display/network-display.component.js new file mode 100644 index 000000000..1142e8606 --- /dev/null +++ b/ui/app/components/app/network-display/network-display.component.js @@ -0,0 +1,76 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { + MAINNET_CODE, + ROPSTEN_CODE, + RINKEYBY_CODE, + KOVAN_CODE, +} from '../../../../../app/scripts/controllers/network/enums' + +const networkToClassHash = { + [MAINNET_CODE]: 'mainnet', + [ROPSTEN_CODE]: 'ropsten', + [RINKEYBY_CODE]: 'rinkeby', + [KOVAN_CODE]: 'kovan', +} + +export default class NetworkDisplay extends Component { + static defaultProps = { + colored: true, + } + + static propTypes = { + colored: PropTypes.bool, + network: PropTypes.string, + provider: PropTypes.object, + } + + static contextTypes = { + t: PropTypes.func, + } + + renderNetworkIcon () { + const { network } = this.props + const networkClass = networkToClassHash[network] + + return networkClass + ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> + : <div + className="i fa fa-question-circle fa-med" + style={{ + margin: '0 4px', + color: 'rgb(125, 128, 130)', + }} + /> + } + + render () { + const { colored, network, provider: { type, nickname } } = this.props + const networkClass = networkToClassHash[network] + + return ( + <div + className={classnames('network-display__container', { + 'network-display__container--colored': colored, + ['network-display__container--' + networkClass]: colored && networkClass, + })} + > + { + networkClass + ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> + : <div + className="i fa fa-question-circle fa-med" + style={{ + margin: '0 4px', + color: 'rgb(125, 128, 130)', + }} + /> + } + <div className="network-display__name"> + { type === 'rpc' && nickname ? nickname : this.context.t(type) } + </div> + </div> + ) + } +} 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 e18404f42..e18404f42 100644 --- a/ui/app/components/network.js +++ b/ui/app/components/app/network.js 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/app/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 new file mode 100644 index 000000000..0eb1d616a --- /dev/null +++ b/ui/app/components/app/provider-page-container/provider-page-container-content/provider-page-container-content.component.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types' +import React, {PureComponent} from 'react' +import Identicon from '../../../ui/identicon' + +export default class ProviderPageContainerContent extends PureComponent { + static propTypes = { + origin: PropTypes.string.isRequired, + selectedIdentity: PropTypes.string.isRequired, + siteImage: PropTypes.string, + siteTitle: PropTypes.string.isRequired, + } + + static contextTypes = { + t: PropTypes.func, + }; + + renderConnectVisual = () => { + const { origin, selectedIdentity, siteImage, siteTitle } = this.props + + return ( + <div className="provider-approval-visual"> + <section> + {siteImage ? ( + <img + className="provider-approval-visual__identicon" + src={siteImage} + /> + ) : ( + <i className="provider-approval-visual__identicon--default"> + {siteTitle.charAt(0).toUpperCase()} + </i> + )} + <h1>{siteTitle}</h1> + <h2>{origin}</h2> + </section> + <span className="provider-approval-visual__check" /> + <section> + <Identicon + className="provider-approval-visual__identicon" + address={selectedIdentity.address} + diameter={64} + /> + <h1>{selectedIdentity.name}</h1> + </section> + </div> + ) + } + + render () { + const { siteTitle } = this.props + const { t } = this.context + + return ( + <div className="provider-approval-container__content"> + <section> + <h2>{t('connectRequest')}</h2> + {this.renderConnectVisual()} + <h1>{t('providerRequest', [siteTitle])}</h1> + <p> + {t('providerRequestInfo')} + <br/> + <a + href="https://medium.com/metamask/introducing-privacy-mode-42549d4870fa" + target="_blank" + rel="noopener noreferrer" + > + {t('learnMore')}. + </a> + </p> + </section> + <section className="secure-badge"> + <img src="/images/mm-secure.svg" /> + </section> + </div> + ) + } +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..4dbdddd16 --- /dev/null +++ b/ui/app/components/app/provider-page-container/provider-page-container-content/provider-page-container-content.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import ProviderPageContainerContent from './provider-page-container-content.component' +import { getSelectedIdentity } from '../../../../selectors/selectors' + +const mapStateToProps = (state) => { + return { + selectedIdentity: getSelectedIdentity(state), + } +} + +export default connect(mapStateToProps)(ProviderPageContainerContent) 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/app/provider-page-container/provider-page-container.component.js b/ui/app/components/app/provider-page-container/provider-page-container.component.js new file mode 100644 index 000000000..910def2a3 --- /dev/null +++ b/ui/app/components/app/provider-page-container/provider-page-container.component.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types' +import React, {PureComponent} from 'react' +import { ProviderPageContainerContent, ProviderPageContainerHeader } from '.' +import { PageContainerFooter } from '../../ui/page-container' + +export default class ProviderPageContainer extends PureComponent { + static propTypes = { + approveProviderRequest: PropTypes.func.isRequired, + origin: PropTypes.string.isRequired, + 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 { tabID, rejectProviderRequest } = this.props + this.context.metricsEvent({ + eventOpts: { + category: 'Auth', + action: 'Connect', + name: 'Canceled', + }, + }) + rejectProviderRequest(tabID) + } + + onSubmit = () => { + const { approveProviderRequest, tabID } = this.props + this.context.metricsEvent({ + eventOpts: { + category: 'Auth', + action: 'Connect', + name: 'Confirmed', + }, + }) + approveProviderRequest(tabID) + } + + render () { + const {origin, siteImage, siteTitle} = this.props + + return ( + <div className="page-container provider-approval-container"> + <ProviderPageContainerHeader /> + <ProviderPageContainerContent + origin={origin} + siteImage={siteImage} + siteTitle={siteTitle} + /> + <PageContainerFooter + onCancel={() => this.onCancel()} + cancelText={this.context.t('cancel')} + onSubmit={() => this.onSubmit()} + submitText={this.context.t('connect')} + submitButtonType="confirm" + /> + </div> + ) + } +} 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/app/selected-account/selected-account.component.js b/ui/app/components/app/selected-account/selected-account.component.js new file mode 100644 index 000000000..5a3fa815f --- /dev/null +++ b/ui/app/components/app/selected-account/selected-account.component.js @@ -0,0 +1,55 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import copyToClipboard from 'copy-to-clipboard' +import { addressSlicer, checksumAddress } from '../../../helpers/utils/util' + +const Tooltip = require('../../ui/tooltip-v2.js').default + +class SelectedAccount extends Component { + state = { + copied: false, + } + + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + selectedAddress: PropTypes.string, + selectedIdentity: PropTypes.object, + network: PropTypes.string, + } + + render () { + const { t } = this.context + const { selectedAddress, selectedIdentity, network } = this.props + const checksummedAddress = checksumAddress(selectedAddress, network) + + return ( + <div className="selected-account"> + <Tooltip + position="bottom" + title={this.state.copied ? t('copiedExclamation') : t('copyToClipboard')} + > + <div + className="selected-account__clickable" + onClick={() => { + this.setState({ copied: true }) + setTimeout(() => this.setState({ copied: false }), 3000) + copyToClipboard(checksummedAddress) + }} + > + <div className="selected-account__name"> + { selectedIdentity.name } + </div> + <div className="selected-account__address"> + { addressSlicer(checksummedAddress) } + </div> + </div> + </Tooltip> + </div> + ) + } +} + +export default SelectedAccount diff --git a/ui/app/components/app/selected-account/selected-account.container.js b/ui/app/components/app/selected-account/selected-account.container.js new file mode 100644 index 000000000..b5dbe74f3 --- /dev/null +++ b/ui/app/components/app/selected-account/selected-account.container.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux' +import SelectedAccount from './selected-account.component' + +const selectors = require('../../../selectors/selectors') + +const mapStateToProps = state => { + return { + selectedAddress: selectors.getSelectedAddress(state), + selectedIdentity: selectors.getSelectedIdentity(state), + network: state.metamask.network, + } +} + +export default connect(mapStateToProps)(SelectedAccount) 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/app/send/account-list-item/account-list-item.container.js b/ui/app/components/app/send/account-list-item/account-list-item.container.js new file mode 100644 index 000000000..bc9a60f49 --- /dev/null +++ b/ui/app/components/app/send/account-list-item/account-list-item.container.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux' +import { + getConversionRate, + 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/app/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 new file mode 100644 index 000000000..5df9f77d6 --- /dev/null +++ b/ui/app/components/app/send/account-list-item/tests/account-list-item-component.test.js @@ -0,0 +1,148 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import proxyquire from 'proxyquire' +import Identicon from '../../../../ui/identicon' +import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display' + +const utilsMethodStubs = { + checksumAddress: sinon.stub().returns('mockCheckSumAddress'), +} + +const AccountListItem = proxyquire('../account-list-item.component.js', { + '../../../../helpers/utils/util': utilsMethodStubs, +}).default + + +const propsMethodSpies = { + handleClick: sinon.spy(), +} + +describe('AccountListItem Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<AccountListItem + account={ { address: 'mockAddress', name: 'mockName', balance: 'mockBalance' } } + className={'mockClassName'} + conversionRate={4} + currentCurrency={'mockCurrentyCurrency'} + nativeCurrency={'ETH'} + displayAddress={false} + displayBalance={false} + handleClick={propsMethodSpies.handleClick} + icon={<i className="mockIcon" />} + />, { context: { t: str => str + '_t' } }) + }) + + afterEach(() => { + propsMethodSpies.handleClick.resetHistory() + }) + + describe('render', () => { + it('should render a div with the passed className', () => { + assert.equal(wrapper.find('.mockClassName').length, 1) + assert(wrapper.find('.mockClassName').is('div')) + assert(wrapper.find('.mockClassName').hasClass('account-list-item')) + }) + + it('should call handleClick with the expected props when the root div is clicked', () => { + const { onClick } = wrapper.find('.mockClassName').props() + assert.equal(propsMethodSpies.handleClick.callCount, 0) + onClick() + assert.equal(propsMethodSpies.handleClick.callCount, 1) + assert.deepEqual( + propsMethodSpies.handleClick.getCall(0).args, + [{ address: 'mockAddress', name: 'mockName', balance: 'mockBalance' }] + ) + }) + + it('should have a top row div', () => { + assert.equal(wrapper.find('.mockClassName > .account-list-item__top-row').length, 1) + assert(wrapper.find('.mockClassName > .account-list-item__top-row').is('div')) + }) + + it('should have an identicon, name and icon in the top row', () => { + const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') + assert.equal(topRow.find(Identicon).length, 1) + assert.equal(topRow.find('.account-list-item__account-name').length, 1) + assert.equal(topRow.find('.account-list-item__icon').length, 1) + }) + + it('should show the account name if it exists', () => { + const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') + assert.equal(topRow.find('.account-list-item__account-name').text(), 'mockName') + }) + + it('should show the account address if there is no name', () => { + wrapper.setProps({ account: { address: 'addressButNoName' } }) + const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') + assert.equal(topRow.find('.account-list-item__account-name').text(), 'addressButNoName') + }) + + it('should render the passed icon', () => { + const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') + assert(topRow.find('.account-list-item__icon').childAt(0).is('i')) + assert(topRow.find('.account-list-item__icon').childAt(0).hasClass('mockIcon')) + }) + + it('should not render an icon if none is passed', () => { + wrapper.setProps({ icon: null }) + const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') + assert.equal(topRow.find('.account-list-item__icon').length, 0) + }) + + it('should render the account address as a checksumAddress if displayAddress is true and name is provided', () => { + wrapper.setProps({ displayAddress: true }) + assert.equal(wrapper.find('.account-list-item__account-address').length, 1) + assert.equal(wrapper.find('.account-list-item__account-address').text(), 'mockCheckSumAddress') + assert.deepEqual( + utilsMethodStubs.checksumAddress.getCall(0).args, + ['mockAddress'] + ) + }) + + it('should not render the account address as a checksumAddress if displayAddress is false', () => { + wrapper.setProps({ displayAddress: false }) + assert.equal(wrapper.find('.account-list-item__account-address').length, 0) + }) + + it('should not render the account address as a checksumAddress if name is not provided', () => { + wrapper.setProps({ account: { address: 'someAddressButNoName' } }) + assert.equal(wrapper.find('.account-list-item__account-address').length, 0) + }) + + it('should render a CurrencyDisplay with the correct props if displayBalance is true', () => { + wrapper.setProps({ displayBalance: true }) + assert.equal(wrapper.find(UserPreferencedCurrencyDisplay).length, 2) + assert.deepEqual( + wrapper.find(UserPreferencedCurrencyDisplay).at(0).props(), + { + 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, + } + ) + }) + + it('should not render a CurrencyDisplay if displayBalance is false', () => { + 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 f17137c1e..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 diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..16c5a0db5 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js @@ -0,0 +1,40 @@ +import { connect } from 'react-redux' +import { + getGasTotal, + getSelectedToken, + getSendFromBalance, + getTokenBalance, +} from '../../../send.selectors.js' +import { getMaxModeOn } from './amount-max-button.selectors.js' +import { calcMaxAmount } from './amount-max-button.utils.js' +import { + updateSendAmount, + setMaxModeTo, +} from '../../../../../../store/actions' +import AmountMaxButton from './amount-max-button.component' +import { + updateSendErrors, +} from '../../../../../../ducks/send/send.duck' + +export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton) + +function mapStateToProps (state) { + + return { + balance: getSendFromBalance(state), + gasTotal: getGasTotal(state), + maxModeOn: getMaxModeOn(state), + selectedToken: getSelectedToken(state), + tokenBalance: getTokenBalance(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + setAmountToMax: maxAmountDataObject => { + dispatch(updateSendErrors({ amount: null })) + dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))) + }, + setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), + } +} 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/app/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 new file mode 100644 index 000000000..f446e330c --- /dev/null +++ b/ui/app/components/app/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js @@ -0,0 +1,91 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps + +const actionSpies = { + setMaxModeTo: sinon.spy(), + updateSendAmount: sinon.spy(), +} +const duckActionSpies = { + updateSendErrors: sinon.spy(), +} + +proxyquire('../amount-max-button.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + mapDispatchToProps = md + return () => ({}) + }, + }, + '../../../send.selectors.js': { + getGasTotal: (s) => `mockGasTotal:${s}`, + getSelectedToken: (s) => `mockSelectedToken:${s}`, + getSendFromBalance: (s) => `mockBalance:${s}`, + getTokenBalance: (s) => `mockTokenBalance:${s}`, + }, + './amount-max-button.selectors.js': { getMaxModeOn: (s) => `mockMaxModeOn:${s}` }, + './amount-max-button.utils.js': { calcMaxAmount: (mockObj) => mockObj.val + 1 }, + '../../../../../../store/actions': actionSpies, + '../../../../../../ducks/send/send.duck': duckActionSpies, +}) + +describe('amount-max-button container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + balance: 'mockBalance:mockState', + gasTotal: 'mockGasTotal:mockState', + maxModeOn: 'mockMaxModeOn:mockState', + selectedToken: 'mockSelectedToken:mockState', + tokenBalance: 'mockTokenBalance:mockState', + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + }) + + describe('setAmountToMax()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' }) + assert(dispatchSpy.calledTwice) + assert(duckActionSpies.updateSendErrors.calledOnce) + assert.deepEqual( + duckActionSpies.updateSendErrors.getCall(0).args[0], + { amount: null } + ) + assert(actionSpies.updateSendAmount.calledOnce) + assert.equal( + actionSpies.updateSendAmount.getCall(0).args[0], + 12 + ) + }) + }) + + describe('setMaxModeTo()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.setMaxModeTo('mockVal') + assert(dispatchSpy.calledOnce) + assert.equal( + actionSpies.setMaxModeTo.getCall(0).args[0], + 'mockVal' + ) + }) + }) + + }) + +}) 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 1ee858f67..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 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/app/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 new file mode 100644 index 000000000..e725e7eda --- /dev/null +++ b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.component.js @@ -0,0 +1,119 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +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' + +export default class SendAmountRow extends Component { + + static propTypes = { + amount: PropTypes.string, + amountConversionRate: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + balance: PropTypes.string, + conversionRate: PropTypes.number, + convertedCurrency: PropTypes.string, + gasTotal: PropTypes.string, + inError: PropTypes.bool, + primaryCurrency: PropTypes.string, + selectedToken: PropTypes.object, + setMaxModeTo: PropTypes.func, + tokenBalance: PropTypes.string, + updateGasFeeError: PropTypes.func, + updateSendAmount: PropTypes.func, + updateSendAmountError: PropTypes.func, + updateGas: PropTypes.func, + } + + static contextTypes = { + t: PropTypes.func, + } + + validateAmount (amount) { + const { + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + updateGasFeeError, + updateSendAmountError, + } = this.props + + updateSendAmountError({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + + if (selectedToken) { + updateGasFeeError({ + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + } + } + + updateAmount (amount) { + const { updateSendAmount, setMaxModeTo } = this.props + + setMaxModeTo(false) + updateSendAmount(amount) + } + + updateGas (amount) { + const { selectedToken, updateGas } = this.props + + if (selectedToken) { + updateGas({ amount }) + } + } + + renderInput () { + const { amount, inError, selectedToken } = this.props + const Component = selectedToken ? UserPreferencedTokenInput : UserPreferencedCurrencyInput + + return ( + <Component + onChange={newAmount => this.validateAmount(newAmount)} + onBlur={newAmount => { + this.updateGas(newAmount) + this.updateAmount(newAmount) + }} + error={inError} + value={amount} + /> + ) + } + + render () { + const { gasTotal, inError } = this.props + + return ( + <SendRowWrapper + label={`${this.context.t('amount')}:`} + showError={inError} + errorType={'amount'} + > + {!inError && gasTotal && <AmountMaxButton />} + { this.renderInput() } + </SendRowWrapper> + ) + } + +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..0646355ab --- /dev/null +++ b/ui/app/components/app/send/send-content/send-amount-row/send-amount-row.container.js @@ -0,0 +1,54 @@ +import { connect } from 'react-redux' +import { + getAmountConversionRate, + getConversionRate, + getCurrentCurrency, + getGasTotal, + getPrimaryCurrency, + getSelectedToken, + getSendAmount, + getSendFromBalance, + getTokenBalance, +} from '../../send.selectors' +import { + sendAmountIsInError, +} from './send-amount-row.selectors' +import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils' +import { + setMaxModeTo, + updateSendAmount, +} from '../../../../../store/actions' +import { + updateSendErrors, +} from '../../../../../ducks/send/send.duck' +import SendAmountRow from './send-amount-row.component' + +export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow) + +function mapStateToProps (state) { + return { + amount: getSendAmount(state), + amountConversionRate: getAmountConversionRate(state), + balance: getSendFromBalance(state), + conversionRate: getConversionRate(state), + convertedCurrency: getCurrentCurrency(state), + gasTotal: getGasTotal(state), + inError: sendAmountIsInError(state), + primaryCurrency: getPrimaryCurrency(state), + selectedToken: getSelectedToken(state), + tokenBalance: getTokenBalance(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), + updateSendAmount: newAmount => dispatch(updateSendAmount(newAmount)), + updateGasFeeError: (amountDataObject) => { + dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))) + }, + updateSendAmountError: (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 14a71129f..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 diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..6d20202b0 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-amount-row/tests/send-amount-row-container.test.js @@ -0,0 +1,125 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps + +const actionSpies = { + setMaxModeTo: sinon.spy(), + updateSendAmount: sinon.spy(), +} +const duckActionSpies = { + updateSendErrors: sinon.spy(), +} + +proxyquire('../send-amount-row.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + mapDispatchToProps = md + return () => ({}) + }, + }, + '../../send.selectors': { + getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`, + getConversionRate: (s) => `mockConversionRate:${s}`, + getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`, + getGasTotal: (s) => `mockGasTotal:${s}`, + getPrimaryCurrency: (s) => `mockPrimaryCurrency:${s}`, + getSelectedToken: (s) => `mockSelectedToken:${s}`, + getSendAmount: (s) => `mockAmount:${s}`, + getSendFromBalance: (s) => `mockBalance:${s}`, + getTokenBalance: (s) => `mockTokenBalance:${s}`, + }, + './send-amount-row.selectors': { sendAmountIsInError: (s) => `mockInError:${s}` }, + '../../send.utils': { + getAmountErrorObject: (mockDataObject) => ({ ...mockDataObject, mockChange: true }), + getGasFeeErrorObject: (mockDataObject) => ({ ...mockDataObject, mockGasFeeErrorChange: true }), + }, + '../../../../../store/actions': actionSpies, + '../../../../../ducks/send/send.duck': duckActionSpies, +}) + +describe('send-amount-row container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + amount: 'mockAmount:mockState', + amountConversionRate: 'mockAmountConversionRate:mockState', + balance: 'mockBalance:mockState', + conversionRate: 'mockConversionRate:mockState', + convertedCurrency: 'mockConvertedCurrency:mockState', + gasTotal: 'mockGasTotal:mockState', + inError: 'mockInError:mockState', + primaryCurrency: 'mockPrimaryCurrency:mockState', + selectedToken: 'mockSelectedToken:mockState', + tokenBalance: 'mockTokenBalance:mockState', + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + duckActionSpies.updateSendErrors.resetHistory() + }) + + describe('setMaxModeTo()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.setMaxModeTo('mockBool') + assert(dispatchSpy.calledOnce) + assert(actionSpies.setMaxModeTo.calledOnce) + assert.equal( + actionSpies.setMaxModeTo.getCall(0).args[0], + 'mockBool' + ) + }) + }) + + describe('updateSendAmount()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendAmount('mockAmount') + assert(dispatchSpy.calledOnce) + assert(actionSpies.updateSendAmount.calledOnce) + assert.equal( + actionSpies.updateSendAmount.getCall(0).args[0], + 'mockAmount' + ) + }) + }) + + describe('updateGasFeeError()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateGasFeeError({ some: 'data' }) + assert(dispatchSpy.calledOnce) + assert(duckActionSpies.updateSendErrors.calledOnce) + assert.deepEqual( + duckActionSpies.updateSendErrors.getCall(0).args[0], + { some: 'data', mockGasFeeErrorChange: true } + ) + }) + }) + + describe('updateSendAmountError()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendAmountError({ some: 'data' }) + assert(dispatchSpy.calledOnce) + assert(duckActionSpies.updateSendErrors.calledOnce) + assert.deepEqual( + duckActionSpies.updateSendErrors.getCall(0).args[0], + { some: 'data', mockChange: true } + ) + }) + }) + + }) + +}) 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/app/send/send-content/send-content.component.js b/ui/app/components/app/send/send-content/send-content.component.js new file mode 100644 index 000000000..2c09ceb19 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-content.component.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +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' + +export default class SendContent extends Component { + + static propTypes = { + updateGas: PropTypes.func, + scanQrCode: PropTypes.func, + showHexData: PropTypes.bool, + } + + updateGas = (updateData) => this.props.updateGas(updateData) + + render () { + return ( + <PageContainerContent> + <div className="send-v2__form"> + <SendFromRow /> + <SendToRow + updateGas={this.updateGas} + scanQrCode={ _ => this.props.scanQrCode()} + /> + <SendAmountRow updateGas={this.updateGas} /> + <SendGasRow /> + {(this.props.showHexData && ( + <SendHexDataRow + updateGas={this.updateGas} + /> + ))} + </div> + </PageContainerContent> + ) + } + +} 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/app/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 new file mode 100644 index 000000000..0d026bc69 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-dropdown-list/send-dropdown-list.component.js @@ -0,0 +1,52 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import AccountListItem from '../../account-list-item' + +export default class SendDropdownList extends Component { + + static propTypes = { + accounts: PropTypes.array, + closeDropdown: PropTypes.func, + onSelect: PropTypes.func, + activeAddress: PropTypes.string, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + getListItemIcon (accountAddress, activeAddress) { + return accountAddress === activeAddress + ? <i className={`fa fa-check fa-lg`} style={ { color: '#02c9b1' } }/> + : null + } + + render () { + const { + accounts, + closeDropdown, + onSelect, + activeAddress, + } = this.props + + return (<div> + <div + className="send-v2__from-dropdown__close-area" + onClick={() => closeDropdown()} + /> + <div className="send-v2__from-dropdown__list"> + {accounts.map((account, index) => <AccountListItem + account={account} + className="account-list-item__dropdown" + handleClick={() => { + onSelect(account) + closeDropdown() + }} + icon={this.getListItemIcon(account.address, activeAddress)} + key={`send-dropdown-account-#${index}`} + />)} + </div> + </div>) + } + +} 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/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 index fe3ac9aa1..fe3ac9aa1 100644 --- a/ui/app/components/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 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/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 index 18811c57e..18811c57e 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index fd771ea77..fd771ea77 100644 --- a/ui/app/components/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 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/app/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 new file mode 100644 index 000000000..48088607a --- /dev/null +++ b/ui/app/components/app/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js @@ -0,0 +1,57 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import UserPreferencedCurrencyDisplay from '../../../../user-preferenced-currency-display' +import { PRIMARY, SECONDARY } from '../../../../../../helpers/constants/common' + +export default class GasFeeDisplay extends Component { + + static propTypes = { + conversionRate: PropTypes.number, + primaryCurrency: PropTypes.string, + convertedCurrency: PropTypes.string, + gasLoadingError: PropTypes.bool, + gasTotal: PropTypes.string, + onReset: PropTypes.func, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + render () { + const { gasTotal, gasLoadingError, onReset } = this.props + + return ( + <div className="send-v2__gas-fee-display"> + {gasTotal + ? ( + <div className="currency-display"> + <UserPreferencedCurrencyDisplay + value={gasTotal} + type={PRIMARY} + /> + <UserPreferencedCurrencyDisplay + className="currency-display__converted-value" + value={gasTotal} + type={SECONDARY} + /> + </div> + ) + : gasLoadingError + ? <div className="currency-display.currency-display--message"> + {this.context.t('setGasPrice')} + </div> + : <div className="currency-display"> + {this.context.t('loading')} + </div> + } + <button + className="gas-fee-reset" + onClick={onReset} + > + { 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 cb4180508..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 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 79c838543..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 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 08f26854e..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 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 bd3c9a257..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 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/app/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 new file mode 100644 index 000000000..76c929d08 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-hex-data-row/send-hex-data-row.container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux' +import { + updateSendHexData, +} from '../../../../../store/actions' +import SendHexDataRow from './send-hex-data-row.component' + +export default connect(mapStateToProps, mapDispatchToProps)(SendHexDataRow) + +function mapStateToProps (state) { + return { + data: state.metamask.send.data, + } +} + +function mapDispatchToProps (dispatch) { + return { + updateSendHexData (data) { + return dispatch(updateSendHexData(data)) + }, + } +} 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/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 index fd4d19ef7..fd4d19ef7 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index f1caa8f99..f1caa8f99 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index 7df14fd96..7df14fd96 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.scss 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/send/send-content/send-row-wrapper/send-row-warning-message/send-row-warning-message.scss +++ 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/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 index bd803d833..bd803d833 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index 225bf056c..225bf056c 100644 --- a/ui/app/components/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 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/app/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 new file mode 100644 index 000000000..94309bd96 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-row-wrapper/send-row-wrapper.component.js @@ -0,0 +1,48 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SendRowErrorMessage from './send-row-error-message' +import SendRowWarningMessage from './send-row-warning-message' + +export default class SendRowWrapper extends Component { + + static propTypes = { + children: PropTypes.node, + errorType: PropTypes.string, + label: PropTypes.string, + showError: PropTypes.bool, + showWarning: PropTypes.bool, + warningType: PropTypes.string, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + render () { + const { + children, + 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}/>} + {!showError && showWarning && <SendRowWarningMessage warningType={warningType} />} + {customLabelContent} + </div> + <div className="send-v2__form-field"> + {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/app/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 new file mode 100644 index 000000000..e8a55cb2a --- /dev/null +++ b/ui/app/components/app/send/send-content/send-to-row/send-to-row.component.js @@ -0,0 +1,91 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SendRowWrapper from '../send-row-wrapper' +import EnsInput from '../../../ens-input' +import { getToErrorObject, getToWarningObject } from './send-to-row.utils.js' + +export default class SendToRow extends Component { + + static propTypes = { + 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, 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 }) + } + } + + render () { + const { + closeToDropdown, + inError, + inWarning, + network, + openToDropdown, + to, + toAccounts, + toDropdownOpen, + } = this.props + + return ( + <SendRowWrapper + errorType={'to'} + label={`${this.context.t('to')}: `} + showError={inError} + showWarning={inWarning} + warningType={'to'} + > + <EnsInput + 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, toWarning }) => this.handleToChange(toAddress, nickname, toError, toWarning, this.props.network)} + openDropdown={() => openToDropdown()} + placeholder={this.context.t('recipientAddress')} + to={to} + /> + </SendRowWrapper> + ) + } + +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..30865d295 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-to-row/send-to-row.container.js @@ -0,0 +1,54 @@ +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 '../../../../../store/actions' +import { + updateSendErrors, + updateSendWarnings, + openToDropdown, + closeToDropdown, +} from '../../../../../ducks/send/send.duck' +import SendToRow from './send-to-row.component' + +export default connect(mapStateToProps, mapDispatchToProps)(SendToRow) + +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), + } +} + +function mapDispatchToProps (dispatch) { + return { + closeToDropdown: () => dispatch(closeToDropdown()), + openToDropdown: () => dispatch(openToDropdown()), + updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), + 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 a6160d335..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 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 d4d054057..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 diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..94b4f1024 --- /dev/null +++ b/ui/app/components/app/send/send-content/send-to-row/tests/send-to-row-container.test.js @@ -0,0 +1,134 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps + +const actionSpies = { + updateSendTo: sinon.spy(), +} +const duckActionSpies = { + closeToDropdown: sinon.spy(), + openToDropdown: sinon.spy(), + updateSendErrors: sinon.spy(), + updateSendWarnings: sinon.spy(), +} + +proxyquire('../send-to-row.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + mapDispatchToProps = md + return () => ({}) + }, + }, + '../../send.selectors.js': { + getCurrentNetwork: (s) => `mockNetwork:${s}`, + getSelectedToken: (s) => `mockSelectedToken:${s}`, + getSendHexData: (s) => s, + getSendTo: (s) => `mockTo:${s}`, + getSendToAccounts: (s) => `mockToAccounts:${s}`, + }, + './send-to-row.selectors.js': { + getToDropdownOpen: (s) => `mockToDropdownOpen:${s}`, + sendToIsInError: (s) => `mockInError:${s}`, + sendToIsInWarning: (s) => `mockInWarning:${s}`, + getTokens: (s) => `mockTokens:${s}`, + }, + '../../../../../store/actions': actionSpies, + '../../../../../ducks/send/send.duck': duckActionSpies, +}) + +describe('send-to-row container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + 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', + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + }) + + describe('closeToDropdown()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.closeToDropdown() + assert(dispatchSpy.calledOnce) + assert(duckActionSpies.closeToDropdown.calledOnce) + assert.equal( + duckActionSpies.closeToDropdown.getCall(0).args[0], + undefined + ) + }) + }) + + describe('openToDropdown()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.openToDropdown() + assert(dispatchSpy.calledOnce) + assert(duckActionSpies.openToDropdown.calledOnce) + assert.equal( + duckActionSpies.openToDropdown.getCall(0).args[0], + undefined + ) + }) + }) + + describe('updateSendTo()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname') + assert(dispatchSpy.calledOnce) + assert(actionSpies.updateSendTo.calledOnce) + assert.deepEqual( + actionSpies.updateSendTo.getCall(0).args, + ['mockTo', 'mockNickname'] + ) + }) + }) + + describe('updateSendToError()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendToError('mockToErrorObject') + assert(dispatchSpy.calledOnce) + assert(duckActionSpies.updateSendErrors.calledOnce) + assert.equal( + duckActionSpies.updateSendErrors.getCall(0).args[0], + 'mockToErrorObject' + ) + }) + }) + + 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 0fa342d1e..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 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/app/send/send-content/tests/send-content-component.test.js b/ui/app/components/app/send/send-content/tests/send-content-component.test.js new file mode 100644 index 000000000..7d102c930 --- /dev/null +++ b/ui/app/components/app/send/send-content/tests/send-content-component.test.js @@ -0,0 +1,50 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import SendContent from '../send-content.component.js' + +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' +import SendToRow from '../send-to-row/send-to-row.container' +import SendHexDataRow from '../send-hex-data-row/send-hex-data-row.container' + +describe('SendContent Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<SendContent showHexData={true} />) + }) + + describe('render', () => { + it('should render a PageContainerContent component', () => { + assert.equal(wrapper.find(PageContainerContent).length, 1) + }) + + it('should render a div with a .send-v2__form class as a child of PageContainerContent', () => { + const PageContainerContentChild = wrapper.find(PageContainerContent).children() + PageContainerContentChild.is('div') + PageContainerContentChild.is('.send-v2__form') + }) + + it('should render the correct row components as grandchildren of the PageContainerContent component', () => { + const PageContainerContentChild = wrapper.find(PageContainerContent).children() + assert(PageContainerContentChild.childAt(0).is(SendFromRow)) + assert(PageContainerContentChild.childAt(1).is(SendToRow)) + assert(PageContainerContentChild.childAt(2).is(SendAmountRow)) + assert(PageContainerContentChild.childAt(3).is(SendGasRow)) + assert(PageContainerContentChild.childAt(4).is(SendHexDataRow)) + }) + + it('should not render the SendHexDataRow if props.showHexData is false', () => { + wrapper.setProps({ showHexData: false }) + const PageContainerContentChild = wrapper.find(PageContainerContent).children() + assert(PageContainerContentChild.childAt(0).is(SendFromRow)) + assert(PageContainerContentChild.childAt(1).is(SendToRow)) + assert(PageContainerContentChild.childAt(2).is(SendAmountRow)) + assert(PageContainerContentChild.childAt(3).is(SendGasRow)) + assert.equal(PageContainerContentChild.childAt(4).exists(), false) + }) + }) +}) 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/app/send/send-footer/send-footer.component.js b/ui/app/components/app/send/send-footer/send-footer.component.js new file mode 100644 index 000000000..cc891a9b3 --- /dev/null +++ b/ui/app/components/app/send/send-footer/send-footer.component.js @@ -0,0 +1,137 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +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 { + + static propTypes = { + addToAddressBookIfNew: PropTypes.func, + amount: PropTypes.string, + data: PropTypes.string, + clearSend: PropTypes.func, + disabled: PropTypes.bool, + editingTransactionId: PropTypes.string, + errors: PropTypes.object, + from: PropTypes.object, + gasLimit: PropTypes.string, + gasPrice: PropTypes.string, + gasTotal: PropTypes.string, + history: PropTypes.object, + inError: PropTypes.bool, + selectedToken: PropTypes.object, + sign: PropTypes.func, + to: PropTypes.string, + toAccounts: PropTypes.array, + tokenBalance: PropTypes.string, + unapprovedTxs: PropTypes.object, + update: PropTypes.func, + sendErrors: PropTypes.object, + } + + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + }; + + onCancel () { + this.props.clearSend() + this.props.history.push(DEFAULT_ROUTE) + } + + onSubmit (event) { + event.preventDefault() + const { + addToAddressBookIfNew, + amount, + data, + editingTransactionId, + from: {address: from}, + gasLimit: gas, + gasPrice, + selectedToken, + sign, + to, + unapprovedTxs, + // updateTx, + update, + 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 + + // if (!noErrors) { + // return + // } + + // TODO: add nickname functionality + addToAddressBookIfNew(to, toAccounts) + const promise = editingTransactionId + ? update({ + amount, + data, + editingTransactionId, + from, + gas, + gasPrice, + selectedToken, + to, + unapprovedTxs, + }) + : sign({ data, selectedToken, to, amount, from, gas, gasPrice }) + + Promise.resolve(promise) + .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 + 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 () { + return ( + <PageContainerFooter + onCancel={() => this.onCancel()} + onSubmit={e => this.onSubmit(e)} + disabled={this.formShouldBeDisabled()} + /> + ) + } + +} diff --git a/ui/app/components/app/send/send-footer/send-footer.container.js b/ui/app/components/app/send/send-footer/send-footer.container.js new file mode 100644 index 000000000..502159a81 --- /dev/null +++ b/ui/app/components/app/send/send-footer/send-footer.container.js @@ -0,0 +1,107 @@ +import { connect } from 'react-redux' +import ethUtil from 'ethereumjs-util' +import { + addToAddressBook, + clearSend, + signTokenTx, + signTx, + updateTransaction, +} from '../../../../store/actions' +import SendFooter from './send-footer.component' +import { + getGasLimit, + getGasPrice, + getGasTotal, + getSelectedToken, + getSendAmount, + getSendEditingTransactionId, + getSendFromObject, + getSendTo, + getSendToAccounts, + getSendHexData, + getTokenBalance, + getUnapprovedTxs, + getSendErrors, +} from '../send.selectors' +import { + isSendFormInError, +} from './send-footer.selectors' +import { + addressIsNew, + constructTxParams, + constructUpdatedTx, +} from './send-footer.utils' + +export default connect(mapStateToProps, mapDispatchToProps)(SendFooter) + +function mapStateToProps (state) { + return { + amount: getSendAmount(state), + data: getSendHexData(state), + editingTransactionId: getSendEditingTransactionId(state), + from: getSendFromObject(state), + gasLimit: getGasLimit(state), + gasPrice: getGasPrice(state), + gasTotal: getGasTotal(state), + inError: isSendFormInError(state), + selectedToken: getSelectedToken(state), + to: getSendTo(state), + toAccounts: getSendToAccounts(state), + tokenBalance: getTokenBalance(state), + unapprovedTxs: getUnapprovedTxs(state), + sendErrors: getSendErrors(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + clearSend: () => dispatch(clearSend()), + sign: ({ selectedToken, to, amount, from, gas, gasPrice, data }) => { + const txParams = constructTxParams({ + amount, + data, + from, + gas, + gasPrice, + selectedToken, + to, + }) + + selectedToken + ? dispatch(signTokenTx(selectedToken.address, to, amount, txParams)) + : dispatch(signTx(txParams)) + }, + update: ({ + amount, + data, + editingTransactionId, + from, + gas, + gasPrice, + selectedToken, + to, + unapprovedTxs, + }) => { + const editingTx = constructUpdatedTx({ + amount, + data, + editingTransactionId, + from, + gas, + gasPrice, + selectedToken, + to, + unapprovedTxs, + }) + + return dispatch(updateTransaction(editingTx)) + }, + addToAddressBookIfNew: (newAddress, toAccounts, nickname = '') => { + const hexPrefixedAddress = ethUtil.addHexPrefix(newAddress) + if (addressIsNew(toAccounts)) { + // TODO: nickname, i.e. addToAddressBook(recipient, nickname) + dispatch(addToAddressBook(hexPrefixedAddress, nickname)) + } + }, + } +} 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/app/send/send-footer/tests/send-footer-component.test.js b/ui/app/components/app/send/send-footer/tests/send-footer-component.test.js new file mode 100644 index 000000000..6683ca8c0 --- /dev/null +++ b/ui/app/components/app/send/send-footer/tests/send-footer-component.test.js @@ -0,0 +1,233 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../../../helpers/constants/routes' +import SendFooter from '../send-footer.component.js' + +import PageContainerFooter from '../../../../ui/page-container/page-container-footer' + +const propsMethodSpies = { + addToAddressBookIfNew: sinon.spy(), + clearSend: sinon.spy(), + sign: sinon.spy(), + update: sinon.spy(), +} +const historySpies = { + push: sinon.spy(), +} +const MOCK_EVENT = { preventDefault: () => {} } + +sinon.spy(SendFooter.prototype, 'onCancel') +sinon.spy(SendFooter.prototype, 'onSubmit') + +describe('SendFooter Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<SendFooter + addToAddressBookIfNew={propsMethodSpies.addToAddressBookIfNew} + amount={'mockAmount'} + clearSend={propsMethodSpies.clearSend} + disabled={true} + editingTransactionId={'mockEditingTransactionId'} + errors={{}} + from={ { address: 'mockAddress', balance: 'mockBalance' } } + gasLimit={'mockGasLimit'} + gasPrice={'mockGasPrice'} + gasTotal={'mockGasTotal'} + history={historySpies} + inError={false} + selectedToken={{ mockProp: 'mockSelectedTokenProp' }} + sign={propsMethodSpies.sign} + to={'mockTo'} + toAccounts={['mockAccount']} + tokenBalance={'mockTokenBalance'} + unapprovedTxs={['mockTx']} + update={propsMethodSpies.update} + sendErrors={{}} + />, { context: { t: str => str, metricsEvent: () => ({}) } }) + }) + + afterEach(() => { + propsMethodSpies.clearSend.resetHistory() + propsMethodSpies.addToAddressBookIfNew.resetHistory() + propsMethodSpies.clearSend.resetHistory() + propsMethodSpies.sign.resetHistory() + propsMethodSpies.update.resetHistory() + historySpies.push.resetHistory() + SendFooter.prototype.onCancel.resetHistory() + SendFooter.prototype.onSubmit.resetHistory() + }) + + describe('onCancel', () => { + it('should call clearSend', () => { + assert.equal(propsMethodSpies.clearSend.callCount, 0) + wrapper.instance().onCancel() + assert.equal(propsMethodSpies.clearSend.callCount, 1) + }) + + it('should call history.push', () => { + assert.equal(historySpies.push.callCount, 0) + wrapper.instance().onCancel() + assert.equal(historySpies.push.callCount, 1) + assert.equal(historySpies.push.getCall(0).args[0], DEFAULT_ROUTE) + }) + }) + + + describe('formShouldBeDisabled()', () => { + const config = { + 'should return true if inError is truthy': { + inError: true, + expectedResult: true, + }, + 'should return true if gasTotal is falsy': { + inError: false, + gasTotal: false, + expectedResult: true, + }, + 'should return true if to is truthy': { + to: '0xsomevalidAddress', + inError: false, + gasTotal: false, + expectedResult: true, + }, + 'should return true if selectedToken is truthy and tokenBalance is falsy': { + selectedToken: true, + tokenBalance: null, + expectedResult: true, + }, + 'should return false if inError is false and all other params are truthy': { + inError: false, + gasTotal: '0x123', + selectedToken: true, + tokenBalance: 123, + expectedResult: false, + }, + } + Object.entries(config).map(([description, obj]) => { + it(description, () => { + wrapper.setProps(obj) + assert.equal(wrapper.instance().formShouldBeDisabled(), obj.expectedResult) + }) + }) + }) + + describe('onSubmit', () => { + it('should call addToAddressBookIfNew with the correct params', () => { + wrapper.instance().onSubmit(MOCK_EVENT) + assert(propsMethodSpies.addToAddressBookIfNew.calledOnce) + assert.deepEqual( + propsMethodSpies.addToAddressBookIfNew.getCall(0).args, + ['mockTo', ['mockAccount']] + ) + }) + + it('should call props.update if editingTransactionId is truthy', () => { + wrapper.instance().onSubmit(MOCK_EVENT) + assert(propsMethodSpies.update.calledOnce) + assert.deepEqual( + propsMethodSpies.update.getCall(0).args[0], + { + data: undefined, + amount: 'mockAmount', + editingTransactionId: 'mockEditingTransactionId', + from: 'mockAddress', + gas: 'mockGasLimit', + gasPrice: 'mockGasPrice', + selectedToken: { mockProp: 'mockSelectedTokenProp' }, + to: 'mockTo', + unapprovedTxs: ['mockTx'], + } + ) + }) + + it('should not call props.sign if editingTransactionId is truthy', () => { + assert.equal(propsMethodSpies.sign.callCount, 0) + }) + + it('should call props.sign if editingTransactionId is falsy', () => { + wrapper.setProps({ editingTransactionId: null }) + wrapper.instance().onSubmit(MOCK_EVENT) + assert(propsMethodSpies.sign.calledOnce) + assert.deepEqual( + propsMethodSpies.sign.getCall(0).args[0], + { + data: undefined, + amount: 'mockAmount', + from: 'mockAddress', + gas: 'mockGasLimit', + gasPrice: 'mockGasPrice', + selectedToken: { mockProp: 'mockSelectedTokenProp' }, + to: 'mockTo', + } + ) + }) + + it('should not call props.update if editingTransactionId is falsy', () => { + assert.equal(propsMethodSpies.update.callCount, 0) + }) + + it('should call history.push', done => { + Promise.resolve(wrapper.instance().onSubmit(MOCK_EVENT)) + .then(() => { + assert.equal(historySpies.push.callCount, 1) + assert.equal(historySpies.push.getCall(0).args[0], CONFIRM_TRANSACTION_ROUTE) + done() + }) + }) + }) + + describe('render', () => { + beforeEach(() => { + sinon.stub(SendFooter.prototype, 'formShouldBeDisabled').returns('formShouldBeDisabledReturn') + wrapper = shallow(<SendFooter + addToAddressBookIfNew={propsMethodSpies.addToAddressBookIfNew} + amount={'mockAmount'} + clearSend={propsMethodSpies.clearSend} + disabled={true} + editingTransactionId={'mockEditingTransactionId'} + errors={{}} + from={ { address: 'mockAddress', balance: 'mockBalance' } } + gasLimit={'mockGasLimit'} + gasPrice={'mockGasPrice'} + gasTotal={'mockGasTotal'} + history={historySpies} + inError={false} + selectedToken={{ mockProp: 'mockSelectedTokenProp' }} + sign={propsMethodSpies.sign} + to={'mockTo'} + toAccounts={['mockAccount']} + tokenBalance={'mockTokenBalance'} + unapprovedTxs={['mockTx']} + update={propsMethodSpies.update} + />, { context: { t: str => str, metricsEvent: () => ({}) } }) + }) + + afterEach(() => { + SendFooter.prototype.formShouldBeDisabled.restore() + }) + + it('should render a PageContainerFooter component', () => { + assert.equal(wrapper.find(PageContainerFooter).length, 1) + }) + + it('should pass the correct props to PageContainerFooter', () => { + const { + onCancel, + onSubmit, + disabled, + } = wrapper.find(PageContainerFooter).props() + assert.equal(disabled, 'formShouldBeDisabledReturn') + + assert.equal(SendFooter.prototype.onSubmit.callCount, 0) + onSubmit(MOCK_EVENT) + assert.equal(SendFooter.prototype.onSubmit.callCount, 1) + + assert.equal(SendFooter.prototype.onCancel.callCount, 0) + onCancel() + assert.equal(SendFooter.prototype.onCancel.callCount, 1) + }) + }) +}) diff --git a/ui/app/components/app/send/send-footer/tests/send-footer-container.test.js b/ui/app/components/app/send/send-footer/tests/send-footer-container.test.js new file mode 100644 index 000000000..878b0aa19 --- /dev/null +++ b/ui/app/components/app/send/send-footer/tests/send-footer-container.test.js @@ -0,0 +1,200 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps + +const actionSpies = { + addToAddressBook: sinon.spy(), + clearSend: sinon.spy(), + signTokenTx: sinon.spy(), + signTx: sinon.spy(), + updateTransaction: sinon.spy(), +} +const utilsStubs = { + addressIsNew: sinon.stub().returns(true), + constructTxParams: sinon.stub().returns({ + value: 'mockAmount', + }), + constructUpdatedTx: sinon.stub().returns('mockConstructedUpdatedTxParams'), +} + +proxyquire('../send-footer.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + mapDispatchToProps = md + return () => ({}) + }, + }, + '../../../../store/actions': actionSpies, + '../send.selectors': { + getGasLimit: (s) => `mockGasLimit:${s}`, + getGasPrice: (s) => `mockGasPrice:${s}`, + getGasTotal: (s) => `mockGasTotal:${s}`, + getSelectedToken: (s) => `mockSelectedToken:${s}`, + getSendAmount: (s) => `mockAmount:${s}`, + getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, + getSendFromObject: (s) => `mockFromObject:${s}`, + getSendTo: (s) => `mockTo:${s}`, + getSendToAccounts: (s) => `mockToAccounts:${s}`, + 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, +}) + +describe('send-footer container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + amount: 'mockAmount:mockState', + data: 'mockHexData:mockState', + selectedToken: 'mockSelectedToken:mockState', + editingTransactionId: 'mockEditingTransactionId:mockState', + from: 'mockFromObject:mockState', + gasLimit: 'mockGasLimit:mockState', + gasPrice: 'mockGasPrice:mockState', + gasTotal: 'mockGasTotal:mockState', + inError: 'mockInError:mockState', + to: 'mockTo:mockState', + toAccounts: 'mockToAccounts:mockState', + tokenBalance: 'mockTokenBalance:mockState', + unapprovedTxs: 'mockUnapprovedTxs:mockState', + sendErrors: 'mockSendErrors:mockState', + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + }) + + describe('clearSend()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.clearSend() + assert(dispatchSpy.calledOnce) + assert(actionSpies.clearSend.calledOnce) + }) + }) + + describe('sign()', () => { + it('should dispatch a signTokenTx action if selectedToken is defined', () => { + mapDispatchToPropsObject.sign({ + selectedToken: { + address: '0xabc', + }, + to: 'mockTo', + amount: 'mockAmount', + from: 'mockFrom', + gas: 'mockGas', + gasPrice: 'mockGasPrice', + }) + assert(dispatchSpy.calledOnce) + assert.deepEqual( + utilsStubs.constructTxParams.getCall(0).args[0], + { + data: undefined, + selectedToken: { + address: '0xabc', + }, + to: 'mockTo', + amount: 'mockAmount', + from: 'mockFrom', + gas: 'mockGas', + gasPrice: 'mockGasPrice', + } + ) + assert.deepEqual( + actionSpies.signTokenTx.getCall(0).args, + [ '0xabc', 'mockTo', 'mockAmount', { value: 'mockAmount' } ] + ) + }) + + it('should dispatch a sign action if selectedToken is not defined', () => { + utilsStubs.constructTxParams.resetHistory() + mapDispatchToPropsObject.sign({ + to: 'mockTo', + amount: 'mockAmount', + from: 'mockFrom', + gas: 'mockGas', + gasPrice: 'mockGasPrice', + }) + assert(dispatchSpy.calledOnce) + assert.deepEqual( + utilsStubs.constructTxParams.getCall(0).args[0], + { + data: undefined, + selectedToken: undefined, + to: 'mockTo', + amount: 'mockAmount', + from: 'mockFrom', + gas: 'mockGas', + gasPrice: 'mockGasPrice', + } + ) + assert.deepEqual( + actionSpies.signTx.getCall(0).args, + [ { value: 'mockAmount' } ] + ) + }) + }) + + describe('update()', () => { + it('should dispatch an updateTransaction action', () => { + mapDispatchToPropsObject.update({ + to: 'mockTo', + amount: 'mockAmount', + from: 'mockFrom', + gas: 'mockGas', + gasPrice: 'mockGasPrice', + editingTransactionId: 'mockEditingTransactionId', + selectedToken: 'mockSelectedToken', + unapprovedTxs: 'mockUnapprovedTxs', + }) + assert(dispatchSpy.calledOnce) + assert.deepEqual( + utilsStubs.constructUpdatedTx.getCall(0).args[0], + { + data: undefined, + to: 'mockTo', + amount: 'mockAmount', + from: 'mockFrom', + gas: 'mockGas', + gasPrice: 'mockGasPrice', + editingTransactionId: 'mockEditingTransactionId', + selectedToken: 'mockSelectedToken', + unapprovedTxs: 'mockUnapprovedTxs', + } + ) + assert.equal(actionSpies.updateTransaction.getCall(0).args[0], 'mockConstructedUpdatedTxParams') + }) + }) + + describe('addToAddressBookIfNew()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.addToAddressBookIfNew('mockNewAddress', 'mockToAccounts', 'mockNickname') + assert(dispatchSpy.calledOnce) + assert.equal(utilsStubs.addressIsNew.getCall(0).args[0], 'mockToAccounts') + assert.deepEqual( + actionSpies.addToAddressBook.getCall(0).args, + [ '0xmockNewAddress', 'mockNickname' ] + ) + }) + }) + + }) + +}) 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/app/send/send-header/send-header.component.js b/ui/app/components/app/send/send-header/send-header.component.js new file mode 100644 index 000000000..f216954ef --- /dev/null +++ b/ui/app/components/app/send/send-header/send-header.component.js @@ -0,0 +1,34 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import PageContainerHeader from '../../../ui/page-container/page-container-header' +import { DEFAULT_ROUTE } from '../../../../helpers/constants/routes' + +export default class SendHeader extends Component { + + static propTypes = { + clearSend: PropTypes.func, + history: PropTypes.object, + titleKey: PropTypes.string, + subtitleParams: PropTypes.array, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + onClose () { + this.props.clearSend() + this.props.history.push(DEFAULT_ROUTE) + } + + render () { + return ( + <PageContainerHeader + onClose={() => this.onClose()} + subtitle={this.context.t(...this.props.subtitleParams)} + title={this.context.t(this.props.titleKey)} + /> + ) + } + +} diff --git a/ui/app/components/app/send/send-header/send-header.container.js b/ui/app/components/app/send/send-header/send-header.container.js new file mode 100644 index 000000000..ce53fba9a --- /dev/null +++ b/ui/app/components/app/send/send-header/send-header.container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux' +import { clearSend } from '../../../../store/actions' +import SendHeader from './send-header.component' +import { getSubtitleParams, getTitleKey } from './send-header.selectors' + +export default connect(mapStateToProps, mapDispatchToProps)(SendHeader) + +function mapStateToProps (state) { + return { + titleKey: getTitleKey(state), + subtitleParams: getSubtitleParams(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + clearSend: () => dispatch(clearSend()), + } +} 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/app/send/send-header/tests/send-header-component.test.js b/ui/app/components/app/send/send-header/tests/send-header-component.test.js new file mode 100644 index 000000000..db2ee8967 --- /dev/null +++ b/ui/app/components/app/send/send-header/tests/send-header-component.test.js @@ -0,0 +1,70 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import { DEFAULT_ROUTE } from '../../../../../helpers/constants/routes' +import SendHeader from '../send-header.component.js' + +import PageContainerHeader from '../../../../ui/page-container/page-container-header' + +const propsMethodSpies = { + clearSend: sinon.spy(), +} +const historySpies = { + push: sinon.spy(), +} + +sinon.spy(SendHeader.prototype, 'onClose') + +describe('SendHeader Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<SendHeader + clearSend={propsMethodSpies.clearSend} + history={historySpies} + titleKey={'mockTitleKey'} + subtitleParams={[ 'mockSubtitleKey', 'mockVal']} + />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }) + }) + + afterEach(() => { + propsMethodSpies.clearSend.resetHistory() + historySpies.push.resetHistory() + SendHeader.prototype.onClose.resetHistory() + }) + + describe('onClose', () => { + it('should call clearSend', () => { + assert.equal(propsMethodSpies.clearSend.callCount, 0) + wrapper.instance().onClose() + assert.equal(propsMethodSpies.clearSend.callCount, 1) + }) + + it('should call history.push', () => { + assert.equal(historySpies.push.callCount, 0) + wrapper.instance().onClose() + assert.equal(historySpies.push.callCount, 1) + assert.equal(historySpies.push.getCall(0).args[0], DEFAULT_ROUTE) + }) + }) + + describe('render', () => { + it('should render a PageContainerHeader compenent', () => { + assert.equal(wrapper.find(PageContainerHeader).length, 1) + }) + + it('should pass the correct props to PageContainerHeader', () => { + const { + onClose, + subtitle, + title, + } = wrapper.find(PageContainerHeader).props() + assert.equal(subtitle, 'mockSubtitleKeymockVal') + assert.equal(title, 'mockTitleKey') + assert.equal(SendHeader.prototype.onClose.callCount, 0) + onClose() + assert.equal(SendHeader.prototype.onClose.callCount, 1) + }) + }) +}) diff --git a/ui/app/components/app/send/send-header/tests/send-header-container.test.js b/ui/app/components/app/send/send-header/tests/send-header-container.test.js new file mode 100644 index 000000000..634c3424b --- /dev/null +++ b/ui/app/components/app/send/send-header/tests/send-header-container.test.js @@ -0,0 +1,59 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps + +const actionSpies = { + clearSend: sinon.spy(), +} + +proxyquire('../send-header.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + mapDispatchToProps = md + return () => ({}) + }, + }, + '../../../../store/actions': actionSpies, + './send-header.selectors': { + getTitleKey: (s) => `mockTitleKey:${s}`, + getSubtitleParams: (s) => `mockSubtitleParams:${s}`, + }, +}) + +describe('send-header container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + titleKey: 'mockTitleKey:mockState', + subtitleParams: 'mockSubtitleParams:mockState', + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + }) + + describe('clearSend()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.clearSend() + assert(dispatchSpy.calledOnce) + assert(actionSpies.clearSend.calledOnce) + }) + }) + + }) + +}) 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/app/send/send.component.js b/ui/app/components/app/send/send.component.js new file mode 100644 index 000000000..a38b681b0 --- /dev/null +++ b/ui/app/components/app/send/send.component.js @@ -0,0 +1,218 @@ +import React from 'react' +import PropTypes from 'prop-types' +import PersistentForm from '../../../../lib/persistent-form' +import { + getAmountErrorObject, + getGasFeeErrorObject, + getToAddressForGasUpdate, + doesAmountErrorRequireUpdate, +} from './send.utils' + +import SendHeader from './send-header' +import SendContent from './send-content' +import SendFooter from './send-footer' + +export default class SendTransactionScreen extends PersistentForm { + + static propTypes = { + amount: PropTypes.string, + amountConversionRate: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + blockGasLimit: PropTypes.string, + conversionRate: PropTypes.number, + editingTransactionId: PropTypes.string, + from: PropTypes.object, + gasLimit: PropTypes.string, + gasPrice: PropTypes.string, + gasTotal: PropTypes.string, + history: PropTypes.object, + network: PropTypes.string, + primaryCurrency: PropTypes.string, + recentBlocks: PropTypes.array, + selectedAddress: PropTypes.string, + 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) { + if (nextProps.qrCodeData.type === 'address') { + const scannedAddress = nextProps.qrCodeData.values.address.toLowerCase() + const currentAddress = this.props.to && this.props.to.toLowerCase() + if (currentAddress !== scannedAddress) { + this.props.updateSendTo(scannedAddress) + this.updateGas({ to: scannedAddress }) + // Clean up QR code data after handling + this.props.qrCodeDetected(null) + } + } + } + } + + updateGas ({ to: updatedToAddress, amount: value, data } = {}) { + const { + amount, + blockGasLimit, + editingTransactionId, + gasLimit, + gasPrice, + recentBlocks, + selectedAddress, + selectedToken = {}, + to: currentToAddress, + updateAndSetGasLimit, + } = this.props + + updateAndSetGasLimit({ + blockGasLimit, + editingTransactionId, + gasLimit, + gasPrice, + recentBlocks, + selectedAddress, + selectedToken, + to: getToAddressForGasUpdate(updatedToAddress, currentToAddress), + value: value || amount, + data, + }) + } + + componentDidUpdate (prevProps) { + const { + amount, + amountConversionRate, + conversionRate, + from: { address, balance }, + gasTotal, + network, + primaryCurrency, + selectedToken, + tokenBalance, + updateSendErrors, + updateSendTokenBalance, + tokenContract, + } = this.props + + const { + from: { balance: prevBalance }, + gasTotal: prevGasTotal, + tokenBalance: prevTokenBalance, + network: prevNetwork, + } = prevProps + + const uninitialized = [prevBalance, prevGasTotal].every(n => n === null) + + const amountErrorRequiresUpdate = doesAmountErrorRequireUpdate({ + balance, + gasTotal, + prevBalance, + prevGasTotal, + prevTokenBalance, + selectedToken, + tokenBalance, + }) + + if (amountErrorRequiresUpdate) { + const amountErrorObject = getAmountErrorObject({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + const gasFeeErrorObject = selectedToken + ? getGasFeeErrorObject({ + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + }) + : { gasFee: null } + updateSendErrors(Object.assign(amountErrorObject, gasFeeErrorObject)) + } + + if (!uninitialized) { + + if (network !== prevNetwork && network !== 'loading') { + updateSendTokenBalance({ + selectedToken, + tokenContract, + address, + }) + this.updateGas() + } + } + } + + componentDidMount () { + this.props.fetchBasicGasEstimates() + .then(() => { + this.updateGas() + }) + } + + componentWillMount () { + const { + from: { address }, + selectedToken, + tokenContract, + updateSendTokenBalance, + } = this.props + + updateSendTokenBalance({ + selectedToken, + tokenContract, + address, + }) + + // Show QR Scanner modal if ?scan=true + if (window.location.search === '?scan=true') { + this.props.scanQrCode() + + // Clear the queryString param after showing the modal + const cleanUrl = location.href.split('?')[0] + history.pushState({}, null, `${cleanUrl}`) + window.location.hash = '#send' + } + } + + componentWillUnmount () { + this.props.resetSendState() + } + + render () { + const { history, showHexData } = this.props + + return ( + <div className="page-container"> + <SendHeader history={history}/> + <SendContent + updateGas={(updateData) => this.updateGas(updateData)} + scanQrCode={_ => this.props.scanQrCode()} + showHexData={showHexData} + /> + <SendFooter history={history}/> + </div> + ) + } + +} diff --git a/ui/app/components/app/send/send.constants.js b/ui/app/components/app/send/send.constants.js new file mode 100644 index 000000000..36549038e --- /dev/null +++ b/ui/app/components/app/send/send.constants.js @@ -0,0 +1,61 @@ +const ethUtil = require('ethereumjs-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) +const MIN_GAS_LIMIT_DEC = '21000' +const MIN_GAS_LIMIT_HEX = (parseInt(MIN_GAS_LIMIT_DEC)).toString(16) + +const MIN_GAS_PRICE_GWEI = ethUtil.addHexPrefix(conversionUtil(MIN_GAS_PRICE_HEX, { + fromDenomination: 'WEI', + toDenomination: 'GWEI', + fromNumericBase: 'hex', + toNumericBase: 'hex', + numberOfDecimals: 1, +})) + +const MIN_GAS_TOTAL = multiplyCurrencies(MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_HEX, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, +}) + +const TOKEN_TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb' + +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', + toDenomination: 'WEI', + fromNumericBase: 'hex', + toNumericBase: 'hex', +})) + +const SIMPLE_GAS_COST = '0x5208' // Hex for 21000, cost of a simple send. +const BASE_TOKEN_GAS_COST = '0x186a0' // Hex for 100000, a base estimate for token transfers. + +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, + MIN_GAS_PRICE_GWEI, + MIN_GAS_PRICE_HEX, + MIN_GAS_TOTAL, + NEGATIVE_ETH_ERROR, + ONE_GWEI_IN_WEI_HEX, + REQUIRED_ERROR, + SIMPLE_GAS_COST, + TOKEN_TRANSFER_FUNCTION_SIGNATURE, + BASE_TOKEN_GAS_COST, +} diff --git a/ui/app/components/app/send/send.container.js b/ui/app/components/app/send/send.container.js new file mode 100644 index 000000000..e65463b93 --- /dev/null +++ b/ui/app/components/app/send/send.container.js @@ -0,0 +1,112 @@ +import { connect } from 'react-redux' +import SendEther from './send.component' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import { + getAmountConversionRate, + getBlockGasLimit, + getConversionRate, + getCurrentNetwork, + getGasLimit, + getGasPrice, + getGasTotal, + getPrimaryCurrency, + getRecentBlocks, + getSelectedAddress, + getSelectedToken, + getSelectedTokenContract, + getSelectedTokenToFiatRate, + getSendAmount, + getSendEditingTransactionId, + getSendHexDataFeatureFlagState, + getSendFromObject, + getSendTo, + getTokenBalance, + getQrCodeData, +} from './send.selectors' +import { + updateSendTo, + updateSendTokenBalance, + updateGasData, + setGasTotal, + showQrScanner, + qrCodeDetected, +} from '../../../store/actions' +import { + resetSendState, + updateSendErrors, +} from '../../../ducks/send/send.duck' +import { + fetchBasicGasEstimates, +} from '../../../ducks/gas/gas.duck' +import { + calcGasTotal, +} from './send.utils.js' + +import { + SEND_ROUTE, +} from '../../../helpers/constants/routes' + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(SendEther) + +function mapStateToProps (state) { + return { + amount: getSendAmount(state), + amountConversionRate: getAmountConversionRate(state), + blockGasLimit: getBlockGasLimit(state), + conversionRate: getConversionRate(state), + editingTransactionId: getSendEditingTransactionId(state), + from: getSendFromObject(state), + gasLimit: getGasLimit(state), + gasPrice: getGasPrice(state), + gasTotal: getGasTotal(state), + network: getCurrentNetwork(state), + primaryCurrency: getPrimaryCurrency(state), + recentBlocks: getRecentBlocks(state), + selectedAddress: getSelectedAddress(state), + selectedToken: getSelectedToken(state), + showHexData: getSendHexDataFeatureFlagState(state), + to: getSendTo(state), + tokenBalance: getTokenBalance(state), + tokenContract: getSelectedTokenContract(state), + tokenToFiatRate: getSelectedTokenToFiatRate(state), + qrCodeData: getQrCodeData(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + updateAndSetGasLimit: ({ + blockGasLimit, + editingTransactionId, + gasLimit, + gasPrice, + recentBlocks, + selectedAddress, + selectedToken, + to, + value, + data, + }) => { + !editingTransactionId + ? dispatch(updateGasData({ gasPrice, recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value, data })) + : dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) + }, + updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => { + dispatch(updateSendTokenBalance({ + selectedToken, + tokenContract, + address, + })) + }, + updateSendErrors: newError => dispatch(updateSendErrors(newError)), + resetSendState: () => dispatch(resetSendState()), + 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/app/send/send.selectors.js b/ui/app/components/app/send/send.selectors.js new file mode 100644 index 000000000..2ec677ad1 --- /dev/null +++ b/ui/app/components/app/send/send.selectors.js @@ -0,0 +1,291 @@ +const { valuesFor } = require('../../../helpers/utils/util') +const abi = require('human-standard-token-abi') +const { + multiplyCurrencies, +} = 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, + getAddressBook, + getAmountConversionRate, + getBlockGasLimit, + getConversionRate, + getCurrentAccountWithSendEtherInfo, + getCurrentCurrency, + getCurrentNetwork, + getCurrentViewContext, + getForceGasMin, + getNativeCurrency, + getGasLimit, + getGasPrice, + getGasPriceFromRecentBlocks, + getGasTotal, + getPrimaryCurrency, + getRecentBlocks, + getSelectedAccount, + getSelectedAddress, + getSelectedIdentity, + getSelectedToken, + getSelectedTokenContract, + getSelectedTokenExchangeRate, + getSelectedTokenToFiatRate, + getSendAmount, + getSendHexData, + getSendHexDataFeatureFlagState, + getSendEditingTransactionId, + getSendErrors, + getSendFrom, + getSendFromBalance, + getSendFromObject, + getSendMaxModeState, + getSendTo, + getSendToAccounts, + getSendWarnings, + getTokenBalance, + getTokenExchangeRate, + getUnapprovedTxs, + transactionsSelector, + getQrCodeData, +} + +module.exports = selectors + +function accountsWithSendEtherInfoSelector (state) { + const accounts = getMetaMaskAccounts(state) + const { identities } = state.metamask + + const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { + return Object.assign({}, account, identities[key]) + }) + + return accountsWithSendEtherInfo +} + +function getAddressBook (state) { + return state.metamask.addressBook +} + +function getAmountConversionRate (state) { + return getSelectedToken(state) + ? getSelectedTokenToFiatRate(state) + : getConversionRate(state) +} + +function getBlockGasLimit (state) { + return state.metamask.currentBlockGasLimit +} + +function getConversionRate (state) { + return state.metamask.conversionRate +} + +function getCurrentAccountWithSendEtherInfo (state) { + const currentAddress = getSelectedAddress(state) + const accounts = accountsWithSendEtherInfoSelector(state) + + return accounts.find(({ address }) => address === currentAddress) +} + +function getCurrentCurrency (state) { + return state.metamask.currentCurrency +} + +function getNativeCurrency (state) { + return state.metamask.nativeCurrency +} + +function getCurrentNetwork (state) { + return state.metamask.network +} + +function getCurrentViewContext (state) { + const { currentView = {} } = state.appState + return currentView.context +} + +function getForceGasMin (state) { + return state.metamask.send.forceGasMin +} + +function getGasLimit (state) { + return state.metamask.send.gasLimit || '0' +} + +function getGasPrice (state) { + return state.metamask.send.gasPrice || getFastPriceEstimateInHexWEI(state) +} + +function getGasPriceFromRecentBlocks (state) { + return estimateGasPriceFromRecentBlocks(state.metamask.recentBlocks) +} + +function getGasTotal (state) { + return calcGasTotal(getGasLimit(state), getGasPrice(state)) +} + +function getPrimaryCurrency (state) { + const selectedToken = getSelectedToken(state) + return selectedToken && selectedToken.symbol +} + +function getRecentBlocks (state) { + return state.metamask.recentBlocks +} + +function getSelectedAccount (state) { + const accounts = getMetaMaskAccounts(state) + const selectedAddress = getSelectedAddress(state) + + return accounts[selectedAddress] +} + +function getSelectedAddress (state) { + const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0] + + return selectedAddress +} + +function getSelectedIdentity (state) { + const selectedAddress = getSelectedAddress(state) + const identities = state.metamask.identities + + return identities[selectedAddress] +} + +function getSelectedToken (state) { + const tokens = state.metamask.tokens || [] + const selectedTokenAddress = state.metamask.selectedTokenAddress + const selectedToken = tokens.filter(({ address }) => address === selectedTokenAddress)[0] + const sendToken = state.metamask.send.token + + return selectedToken || sendToken || null +} + +function getSelectedTokenContract (state) { + const selectedToken = getSelectedToken(state) + + return selectedToken + ? global.eth.contract(abi).at(selectedToken.address) + : null +} + +function getSelectedTokenExchangeRate (state) { + const tokenExchangeRates = state.metamask.tokenExchangeRates + const selectedToken = getSelectedToken(state) || {} + const { symbol = '' } = selectedToken + const pair = `${symbol.toLowerCase()}_eth` + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates && tokenExchangeRates[pair] || {} + + return tokenExchangeRate +} + +function getSelectedTokenToFiatRate (state) { + const selectedTokenExchangeRate = getSelectedTokenExchangeRate(state) + const conversionRate = getConversionRate(state) + + const tokenToFiatRate = multiplyCurrencies( + conversionRate, + selectedTokenExchangeRate, + { toNumericBase: 'dec' } + ) + + return tokenToFiatRate +} + +function getSendAmount (state) { + return state.metamask.send.amount +} + +function getSendHexData (state) { + return state.metamask.send.data +} + +function getSendHexDataFeatureFlagState (state) { + return state.metamask.featureFlags.sendHexData +} + +function getSendEditingTransactionId (state) { + return state.metamask.send.editingTransactionId +} + +function getSendErrors (state) { + return state.send.errors +} + +function getSendFrom (state) { + return state.metamask.send.from +} + +function getSendFromBalance (state) { + const from = getSendFrom(state) || getSelectedAccount(state) + return from.balance +} + +function getSendFromObject (state) { + return getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state) +} + +function getSendMaxModeState (state) { + return state.metamask.send.maxModeOn +} + +function getSendTo (state) { + return state.metamask.send.to +} + +function getSendToAccounts (state) { + const fromAccounts = accountsWithSendEtherInfoSelector(state) + const addressBookAccounts = getAddressBook(state) + const allAccounts = [...fromAccounts, ...addressBookAccounts] + // TODO: figure out exactly what the below returns and put a descriptive variable name on it + return Object.entries(allAccounts).map(([key, account]) => account) +} + +function getSendWarnings (state) { + return state.send.warnings +} + +function getTokenBalance (state) { + return state.metamask.send.tokenBalance +} + +function getTokenExchangeRate (state, tokenSymbol) { + const pair = `${tokenSymbol.toLowerCase()}_eth` + const tokenExchangeRates = state.metamask.tokenExchangeRates + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} + + return tokenExchangeRate +} + +function getUnapprovedTxs (state) { + return state.metamask.unapprovedTxs +} + +function transactionsSelector (state) { + const { network, selectedTokenAddress } = state.metamask + const unapprovedMsgs = valuesFor(state.metamask.unapprovedMsgs) + const shapeShiftTxList = (network === '1') ? state.metamask.shapeShiftTxList : undefined + const transactions = state.metamask.selectedAddressTxList || [] + const txsToRender = !shapeShiftTxList ? transactions.concat(unapprovedMsgs) : transactions.concat(unapprovedMsgs, shapeShiftTxList) + + return selectedTokenAddress + ? txsToRender + .filter(({ txParams }) => txParams && txParams.to === selectedTokenAddress) + .sort((a, b) => b.time - a.time) + : txsToRender + .sort((a, b) => b.time - a.time) +} + +function getQrCodeData (state) { + return state.appState.qrCodeData +} diff --git a/ui/app/components/app/send/send.utils.js b/ui/app/components/app/send/send.utils.js new file mode 100644 index 000000000..7609d46ea --- /dev/null +++ b/ui/app/components/app/send/send.utils.js @@ -0,0 +1,332 @@ +const { + addCurrencies, + conversionUtil, + conversionGTE, + multiplyCurrencies, + conversionGreaterThan, + conversionLessThan, +} = require('../../../helpers/utils/conversion-util') +const { + calcTokenAmount, +} = require('../../../helpers/utils/token-util') +const { + BASE_TOKEN_GAS_COST, + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + NEGATIVE_ETH_ERROR, + ONE_GWEI_IN_WEI_HEX, + SIMPLE_GAS_COST, + TOKEN_TRANSFER_FUNCTION_SIGNATURE, +} = require('./send.constants') +const abi = require('ethereumjs-abi') +const ethUtil = require('ethereumjs-util') + +module.exports = { + addGasBuffer, + calcGasTotal, + calcTokenBalance, + doesAmountErrorRequireUpdate, + estimateGas, + estimateGasPriceFromRecentBlocks, + generateTokenTransferData, + getAmountErrorObject, + getGasFeeErrorObject, + getToAddressForGasUpdate, + isBalanceSufficient, + isTokenBalanceSufficient, + removeLeadingZeroes, +} + +function calcGasTotal (gasLimit = '0', gasPrice = '0') { + return multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) +} + +function isBalanceSufficient ({ + amount = '0x0', + amountConversionRate = 1, + balance = '0x0', + conversionRate = 1, + gasTotal = '0x0', + primaryCurrency, +}) { + const totalAmount = addCurrencies(amount, gasTotal, { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + + const balanceIsSufficient = conversionGTE( + { + value: balance, + fromNumericBase: 'hex', + fromCurrency: primaryCurrency, + conversionRate, + }, + { + value: totalAmount, + fromNumericBase: 'hex', + conversionRate: Number(amountConversionRate) || conversionRate, + fromCurrency: primaryCurrency, + }, + ) + + return balanceIsSufficient +} + +function isTokenBalanceSufficient ({ + amount = '0x0', + tokenBalance, + decimals, +}) { + const amountInDec = conversionUtil(amount, { + fromNumericBase: 'hex', + }) + + const tokenBalanceIsSufficient = conversionGTE( + { + value: tokenBalance, + fromNumericBase: 'hex', + }, + { + value: calcTokenAmount(amountInDec, decimals), + }, + ) + + return tokenBalanceIsSufficient +} + +function getAmountErrorObject ({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, +}) { + let insufficientFunds = false + if (gasTotal && conversionRate && !selectedToken) { + insufficientFunds = !isBalanceSufficient({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + }) + } + + let inSufficientTokens = false + if (selectedToken && tokenBalance !== null) { + const { decimals } = selectedToken + inSufficientTokens = !isTokenBalanceSufficient({ + tokenBalance, + amount, + decimals, + }) + } + + const amountLessThanZero = conversionGreaterThan( + { value: 0, fromNumericBase: 'dec' }, + { value: amount, fromNumericBase: 'hex' }, + ) + + let amountError = null + + if (insufficientFunds) { + amountError = INSUFFICIENT_FUNDS_ERROR + } else if (inSufficientTokens) { + amountError = INSUFFICIENT_TOKENS_ERROR + } else if (amountLessThanZero) { + amountError = NEGATIVE_ETH_ERROR + } + + return { amount: amountError } +} + +function getGasFeeErrorObject ({ + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, +}) { + let gasFeeError = null + + if (gasTotal && conversionRate) { + const insufficientFunds = !isBalanceSufficient({ + amount: '0x0', + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + }) + + if (insufficientFunds) { + gasFeeError = INSUFFICIENT_FUNDS_ERROR + } + } + + return { gasFee: gasFeeError } +} + +function calcTokenBalance ({ selectedToken, usersToken }) { + const { decimals } = selectedToken || {} + return calcTokenAmount(usersToken.balance.toString(), decimals).toString(16) +} + +function doesAmountErrorRequireUpdate ({ + balance, + gasTotal, + prevBalance, + prevGasTotal, + prevTokenBalance, + selectedToken, + tokenBalance, +}) { + const balanceHasChanged = balance !== prevBalance + const gasTotalHasChange = gasTotal !== prevGasTotal + const tokenBalanceHasChanged = selectedToken && tokenBalance !== prevTokenBalance + const amountErrorRequiresUpdate = balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged + + return amountErrorRequiresUpdate +} + +async function estimateGas ({ + selectedAddress, + selectedToken, + blockGasLimit, + to, + value, + data, + gasPrice, + estimateGasMethod, +}) { + const paramsForGasEstimate = { from: selectedAddress, value, gasPrice } + + // if recipient has no code, gas is 21k max: + if (!selectedToken && !data) { + const code = Boolean(to) && await global.eth.getCode(to) + // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' + const codeIsEmpty = !code || code === '0x' || code === '0x0' + if (codeIsEmpty) { + return SIMPLE_GAS_COST + } + } else if (selectedToken && !to) { + return BASE_TOKEN_GAS_COST + } + + if (selectedToken) { + paramsForGasEstimate.value = '0x0' + paramsForGasEstimate.data = generateTokenTransferData({ toAddress: to, amount: value, selectedToken }) + paramsForGasEstimate.to = selectedToken.address + } else { + if (data) { + paramsForGasEstimate.data = data + } + + 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 || '0x5208', 0.95, { + multiplicandBase: 16, + multiplierBase: 10, + roundDown: '0', + toNumericBase: 'hex', + })) + // run tx + return new Promise((resolve, reject) => { + return estimateGasMethod(paramsForGasEstimate, (err, estimatedGas) => { + if (err) { + const simulationFailed = ( + err.message.includes('Transaction execution error.') || + err.message.includes('gas required exceeds allowance or always failing transaction') + ) + if (simulationFailed) { + const estimateWithBuffer = addGasBuffer(paramsForGasEstimate.gas, blockGasLimit, 1.5) + return resolve(ethUtil.addHexPrefix(estimateWithBuffer)) + } else { + return reject(err) + } + } + const estimateWithBuffer = addGasBuffer(estimatedGas.toString(16), blockGasLimit, 1.5) + return resolve(ethUtil.addHexPrefix(estimateWithBuffer)) + }) + }) +} + +function addGasBuffer (initialGasLimitHex, blockGasLimitHex, bufferMultiplier = 1.5) { + const upperGasLimit = multiplyCurrencies(blockGasLimitHex, 0.9, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + numberOfDecimals: '0', + }) + const bufferedGasLimit = multiplyCurrencies(initialGasLimitHex, bufferMultiplier, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + numberOfDecimals: '0', + }) + + // if initialGasLimit is above blockGasLimit, dont modify it + if (conversionGreaterThan( + { value: initialGasLimitHex, fromNumericBase: 'hex' }, + { value: upperGasLimit, fromNumericBase: 'hex' }, + )) return initialGasLimitHex + // if bufferedGasLimit is below blockGasLimit, use bufferedGasLimit + if (conversionLessThan( + { value: bufferedGasLimit, fromNumericBase: 'hex' }, + { value: upperGasLimit, fromNumericBase: 'hex' }, + )) return bufferedGasLimit + // otherwise use blockGasLimit + return upperGasLimit +} + +function generateTokenTransferData ({ toAddress = '0x0', amount = '0x0', selectedToken }) { + if (!selectedToken) return + return TOKEN_TRANSFER_FUNCTION_SIGNATURE + Array.prototype.map.call( + abi.rawEncode(['address', 'uint256'], [toAddress, ethUtil.addHexPrefix(amount)]), + x => ('00' + x.toString(16)).slice(-2) + ).join('') +} + +function estimateGasPriceFromRecentBlocks (recentBlocks) { + // Return 1 gwei if no blocks have been observed: + if (!recentBlocks || recentBlocks.length === 0) { + return ONE_GWEI_IN_WEI_HEX + } + + const lowestPrices = recentBlocks.map((block) => { + if (!block.gasPrices || block.gasPrices.length < 1) { + return ONE_GWEI_IN_WEI_HEX + } + return block.gasPrices.reduce((currentLowest, next) => { + return parseInt(next, 16) < parseInt(currentLowest, 16) ? next : currentLowest + }) + }) + .sort((a, b) => parseInt(a, 16) > parseInt(b, 16) ? 1 : -1) + + return lowestPrices[Math.floor(lowestPrices.length / 2)] +} + +function getToAddressForGasUpdate (...addresses) { + return [...addresses, ''].find(str => str !== undefined && str !== null).toLowerCase() +} + +function removeLeadingZeroes (str) { + return str.replace(/^0*(?=\d)/, '') +} diff --git a/ui/app/components/app/send/tests/send-component.test.js b/ui/app/components/app/send/tests/send-component.test.js new file mode 100644 index 000000000..738c14839 --- /dev/null +++ b/ui/app/components/app/send/tests/send-component.test.js @@ -0,0 +1,354 @@ +import React from 'react' +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 = { + 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' }), + getGasFeeErrorObject: sinon.stub().returns({ gasFee: 'mockGasFeeError' }), + doesAmountErrorRequireUpdate: sinon.stub().callsFake(obj => obj.balance !== obj.prevBalance), +} + +const SendTransactionScreen = proxyquire('../send.component.js', { + './send.utils': utilsMethodStubs, +}).default + +sinon.spy(SendTransactionScreen.prototype, 'componentDidMount') +sinon.spy(SendTransactionScreen.prototype, 'updateGas') + +describe('Send Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<SendTransactionScreen + amount={'mockAmount'} + amountConversionRate={'mockAmountConversionRate'} + blockGasLimit={'mockBlockGasLimit'} + conversionRate={10} + editingTransactionId={'mockEditingTransactionId'} + fetchBasicGasEstimates={propsMethodSpies.fetchBasicGasEstimates} + fetchGasEstimates={propsMethodSpies.fetchGasEstimates} + from={ { address: 'mockAddress', balance: 'mockBalance' } } + gasLimit={'mockGasLimit'} + gasPrice={'mockGasPrice'} + gasTotal={'mockGasTotal'} + history={{ mockProp: 'history-abc'}} + network={'3'} + primaryCurrency={'mockPrimaryCurrency'} + recentBlocks={['mockBlock']} + selectedAddress={'mockSelectedAddress'} + selectedToken={'mockSelectedToken'} + showHexData={true} + tokenBalance={'mockTokenBalance'} + tokenContract={'mockTokenContract'} + updateAndSetGasLimit={propsMethodSpies.updateAndSetGasLimit} + updateSendErrors={propsMethodSpies.updateSendErrors} + updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance} + resetSendState={propsMethodSpies.resetSendState} + />) + }) + + afterEach(() => { + SendTransactionScreen.prototype.componentDidMount.resetHistory() + SendTransactionScreen.prototype.updateGas.resetHistory() + utilsMethodStubs.doesAmountErrorRequireUpdate.resetHistory() + utilsMethodStubs.getAmountErrorObject.resetHistory() + utilsMethodStubs.getGasFeeErrorObject.resetHistory() + propsMethodSpies.fetchBasicGasEstimates.resetHistory() + propsMethodSpies.updateAndSetGasLimit.resetHistory() + propsMethodSpies.updateSendErrors.resetHistory() + propsMethodSpies.updateSendTokenBalance.resetHistory() + }) + + it('should call componentDidMount', () => { + assert(SendTransactionScreen.prototype.componentDidMount.calledOnce) + }) + + 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().componentDidMount() + await timeout(250) + assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1) + }) + }) + + describe('componentWillUnmount', () => { + it('should call this.props.resetSendState', () => { + propsMethodSpies.resetSendState.resetHistory() + assert.equal(propsMethodSpies.resetSendState.callCount, 0) + wrapper.instance().componentWillUnmount() + assert.equal(propsMethodSpies.resetSendState.callCount, 1) + }) + }) + + describe('componentDidUpdate', () => { + it('should call doesAmountErrorRequireUpdate with the expected params', () => { + utilsMethodStubs.getAmountErrorObject.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: '', + }, + }) + assert(utilsMethodStubs.doesAmountErrorRequireUpdate.calledOnce) + assert.deepEqual( + utilsMethodStubs.doesAmountErrorRequireUpdate.getCall(0).args[0], + { + balance: 'mockBalance', + gasTotal: 'mockGasTotal', + prevBalance: '', + prevGasTotal: undefined, + prevTokenBalance: undefined, + selectedToken: 'mockSelectedToken', + tokenBalance: 'mockTokenBalance', + } + ) + }) + + it('should not call getAmountErrorObject if doesAmountErrorRequireUpdate returns false', () => { + utilsMethodStubs.getAmountErrorObject.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: 'mockBalance', + }, + }) + assert.equal(utilsMethodStubs.getAmountErrorObject.callCount, 0) + }) + + it('should call getAmountErrorObject if doesAmountErrorRequireUpdate returns true', () => { + utilsMethodStubs.getAmountErrorObject.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(utilsMethodStubs.getAmountErrorObject.callCount, 1) + assert.deepEqual( + utilsMethodStubs.getAmountErrorObject.getCall(0).args[0], + { + amount: 'mockAmount', + amountConversionRate: 'mockAmountConversionRate', + balance: 'mockBalance', + conversionRate: 10, + gasTotal: 'mockGasTotal', + primaryCurrency: 'mockPrimaryCurrency', + selectedToken: 'mockSelectedToken', + tokenBalance: 'mockTokenBalance', + } + ) + }) + + it('should call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true and selectedToken is truthy', () => { + utilsMethodStubs.getGasFeeErrorObject.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 1) + assert.deepEqual( + utilsMethodStubs.getGasFeeErrorObject.getCall(0).args[0], + { + amountConversionRate: 'mockAmountConversionRate', + balance: 'mockBalance', + conversionRate: 10, + gasTotal: 'mockGasTotal', + primaryCurrency: 'mockPrimaryCurrency', + selectedToken: 'mockSelectedToken', + } + ) + }) + + it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns false', () => { + utilsMethodStubs.getGasFeeErrorObject.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { address: 'mockAddress', balance: 'mockBalance' }, + }) + assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0) + }) + + it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true but selectedToken is falsy', () => { + utilsMethodStubs.getGasFeeErrorObject.resetHistory() + wrapper.setProps({ selectedToken: null }) + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0) + }) + + it('should call updateSendErrors with the expected params if selectedToken is falsy', () => { + propsMethodSpies.updateSendErrors.resetHistory() + wrapper.setProps({ selectedToken: null }) + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(propsMethodSpies.updateSendErrors.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateSendErrors.getCall(0).args[0], + { amount: 'mockAmountError', gasFee: null } + ) + }) + + it('should call updateSendErrors with the expected params if selectedToken is truthy', () => { + propsMethodSpies.updateSendErrors.resetHistory() + wrapper.setProps({ selectedToken: 'someToken' }) + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(propsMethodSpies.updateSendErrors.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateSendErrors.getCall(0).args[0], + { amount: 'mockAmountError', gasFee: 'mockGasFeeError' } + ) + }) + + it('should not call updateSendTokenBalance or this.updateGas if network === prevNetwork', () => { + SendTransactionScreen.prototype.updateGas.resetHistory() + propsMethodSpies.updateSendTokenBalance.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + network: '3', + }) + assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 0) + assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0) + }) + + it('should not call updateSendTokenBalance or this.updateGas if network === loading', () => { + wrapper.setProps({ network: 'loading' }) + SendTransactionScreen.prototype.updateGas.resetHistory() + propsMethodSpies.updateSendTokenBalance.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + network: '3', + }) + assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 0) + assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0) + }) + + it('should call updateSendTokenBalance and this.updateGas with the correct params', () => { + SendTransactionScreen.prototype.updateGas.resetHistory() + propsMethodSpies.updateSendTokenBalance.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + network: '2', + }) + assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateSendTokenBalance.getCall(0).args[0], + { + selectedToken: 'mockSelectedToken', + tokenContract: 'mockTokenContract', + address: 'mockAddress', + } + ) + assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1) + assert.deepEqual( + SendTransactionScreen.prototype.updateGas.getCall(0).args, + [] + ) + }) + }) + + describe('updateGas', () => { + it('should call updateAndSetGasLimit with the correct params if no to prop is passed', () => { + propsMethodSpies.updateAndSetGasLimit.resetHistory() + wrapper.instance().updateGas() + assert.equal(propsMethodSpies.updateAndSetGasLimit.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0], + { + blockGasLimit: 'mockBlockGasLimit', + editingTransactionId: 'mockEditingTransactionId', + gasLimit: 'mockGasLimit', + gasPrice: 'mockGasPrice', + recentBlocks: ['mockBlock'], + selectedAddress: 'mockSelectedAddress', + selectedToken: 'mockSelectedToken', + to: '', + value: 'mockAmount', + data: undefined, + } + ) + }) + + 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.updateAndSetGasLimit.getCall(0).args[0].to, + 'someaddress', + ) + }) + + it('should call updateAndSetGasLimit with to set to lowercase if passed', () => { + propsMethodSpies.updateAndSetGasLimit.resetHistory() + wrapper.instance().updateGas({ to: '0xABC' }) + assert.equal(propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0].to, '0xabc') + }) + }) + + describe('render', () => { + it('should render a page-container class', () => { + assert.equal(wrapper.find('.page-container').length, 1) + }) + + it('should render SendHeader, SendContent and SendFooter', () => { + assert.equal(wrapper.find(SendHeader).length, 1) + assert.equal(wrapper.find(SendContent).length, 1) + assert.equal(wrapper.find(SendFooter).length, 1) + }) + + it('should pass the history prop to SendHeader and SendFooter', () => { + assert.deepEqual( + wrapper.find(SendFooter).props(), + { + history: { mockProp: 'history-abc' }, + } + ) + }) + + it('should pass showHexData to SendContent', () => { + assert.equal(wrapper.find(SendContent).props().showHexData, true) + }) + }) +}) diff --git a/ui/app/components/app/send/tests/send-container.test.js b/ui/app/components/app/send/tests/send-container.test.js new file mode 100644 index 000000000..9538b67b3 --- /dev/null +++ b/ui/app/components/app/send/tests/send-container.test.js @@ -0,0 +1,174 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' +import sinon from 'sinon' + +let mapStateToProps +let mapDispatchToProps + +const actionSpies = { + updateSendTokenBalance: sinon.spy(), + updateGasData: sinon.spy(), + setGasTotal: sinon.spy(), +} +const duckActionSpies = { + updateSendErrors: sinon.spy(), + resetSendState: sinon.spy(), +} + +proxyquire('../send.container.js', { + 'react-redux': { + connect: (ms, md) => { + mapStateToProps = ms + mapDispatchToProps = md + return () => ({}) + }, + }, + 'react-router-dom': { withRouter: () => {} }, + 'recompose': { compose: (arg1, arg2) => () => arg2() }, + './send.selectors': { + getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`, + getBlockGasLimit: (s) => `mockBlockGasLimit:${s}`, + getConversionRate: (s) => `mockConversionRate:${s}`, + getCurrentNetwork: (s) => `mockNetwork:${s}`, + getGasLimit: (s) => `mockGasLimit:${s}`, + getGasPrice: (s) => `mockGasPrice:${s}`, + getGasTotal: (s) => `mockGasTotal:${s}`, + getPrimaryCurrency: (s) => `mockPrimaryCurrency:${s}`, + getRecentBlocks: (s) => `mockRecentBlocks:${s}`, + getSelectedAddress: (s) => `mockSelectedAddress:${s}`, + getSelectedToken: (s) => `mockSelectedToken:${s}`, + getSelectedTokenContract: (s) => `mockTokenContract:${s}`, + getSelectedTokenToFiatRate: (s) => `mockTokenToFiatRate:${s}`, + getSendHexDataFeatureFlagState: (s) => `mockSendHexDataFeatureFlagState:${s}`, + getSendAmount: (s) => `mockAmount:${s}`, + getSendTo: (s) => `mockTo:${s}`, + getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, + getSendFromObject: (s) => `mockFrom:${s}`, + getTokenBalance: (s) => `mockTokenBalance:${s}`, + getQrCodeData: (s) => `mockQrCodeData:${s}`, + }, + '../../../store/actions': actionSpies, + '../../../ducks/send/send.duck': duckActionSpies, + './send.utils.js': { + calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice, + }, +}) + +describe('send container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + amount: 'mockAmount:mockState', + amountConversionRate: 'mockAmountConversionRate:mockState', + blockGasLimit: 'mockBlockGasLimit:mockState', + conversionRate: 'mockConversionRate:mockState', + editingTransactionId: 'mockEditingTransactionId:mockState', + from: 'mockFrom:mockState', + gasLimit: 'mockGasLimit:mockState', + gasPrice: 'mockGasPrice:mockState', + gasTotal: 'mockGasTotal:mockState', + network: 'mockNetwork:mockState', + primaryCurrency: 'mockPrimaryCurrency:mockState', + recentBlocks: 'mockRecentBlocks:mockState', + selectedAddress: 'mockSelectedAddress:mockState', + selectedToken: 'mockSelectedToken:mockState', + showHexData: 'mockSendHexDataFeatureFlagState:mockState', + to: 'mockTo:mockState', + tokenBalance: 'mockTokenBalance:mockState', + tokenContract: 'mockTokenContract:mockState', + tokenToFiatRate: 'mockTokenToFiatRate:mockState', + qrCodeData: 'mockQrCodeData:mockState', + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + }) + + describe('updateAndSetGasLimit()', () => { + const mockProps = { + blockGasLimit: 'mockBlockGasLimit', + editingTransactionId: '0x2', + gasLimit: '0x3', + gasPrice: '0x4', + recentBlocks: ['mockBlock'], + selectedAddress: '0x4', + selectedToken: { address: '0x1' }, + to: 'mockTo', + value: 'mockValue', + data: undefined, + } + + it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => { + mapDispatchToPropsObject.updateAndSetGasLimit(mockProps) + assert(dispatchSpy.calledOnce) + assert.equal( + actionSpies.setGasTotal.getCall(0).args[0], + '0x30x4' + ) + }) + + it('should dispatch an updateGasData action when editingTransactionId is falsy', () => { + 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], + { gasPrice, selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } + ) + }) + }) + + describe('updateSendTokenBalance()', () => { + const mockProps = { + address: '0x10', + tokenContract: '0x00a', + selectedToken: {address: '0x1'}, + } + + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendTokenBalance(Object.assign({}, mockProps)) + assert(dispatchSpy.calledOnce) + assert.deepEqual( + actionSpies.updateSendTokenBalance.getCall(0).args[0], + mockProps + ) + }) + }) + + describe('updateSendErrors()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendErrors('mockError') + assert(dispatchSpy.calledOnce) + assert.equal( + duckActionSpies.updateSendErrors.getCall(0).args[0], + 'mockError' + ) + }) + }) + + describe('resetSendState()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.resetSendState() + assert(dispatchSpy.calledOnce) + assert.equal( + duckActionSpies.resetSendState.getCall(0).args.length, + 0 + ) + }) + }) + + }) + +}) 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 d43d7c650..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 diff --git a/ui/app/components/send/tests/send-selectors.test.js b/ui/app/components/app/send/tests/send-selectors.test.js index cdc86fe59..cdc86fe59 100644 --- a/ui/app/components/send/tests/send-selectors.test.js +++ b/ui/app/components/app/send/tests/send-selectors.test.js diff --git a/ui/app/components/app/send/tests/send-utils.test.js b/ui/app/components/app/send/tests/send-utils.test.js new file mode 100644 index 000000000..fc4c6deed --- /dev/null +++ b/ui/app/components/app/send/tests/send-utils.test.js @@ -0,0 +1,527 @@ +import assert from 'assert' +import sinon from 'sinon' +import proxyquire from 'proxyquire' +import { + BASE_TOKEN_GAS_COST, + ONE_GWEI_IN_WEI_HEX, + SIMPLE_GAS_COST, +} from '../send.constants' +const { + addCurrencies, + subtractCurrencies, +} = require('../../../../helpers/utils/conversion-util') + +const { + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, +} = require('../send.constants') + +const stubs = { + addCurrencies: sinon.stub().callsFake((a, b, obj) => { + if (String(a).match(/^0x.+/)) a = Number(String(a).slice(2)) + if (String(b).match(/^0x.+/)) b = Number(String(b).slice(2)) + return a + b + }), + conversionUtil: sinon.stub().callsFake((val, obj) => parseInt(val, 16)), + conversionGTE: sinon.stub().callsFake((obj1, obj2) => obj1.value >= obj2.value), + multiplyCurrencies: sinon.stub().callsFake((a, b) => `${a}x${b}`), + calcTokenAmount: sinon.stub().callsFake((a, d) => 'calc:' + a + d), + rawEncode: sinon.stub().returns([16, 1100]), + conversionGreaterThan: sinon.stub().callsFake((obj1, obj2) => obj1.value > obj2.value), + conversionLessThan: sinon.stub().callsFake((obj1, obj2) => obj1.value < obj2.value), +} + +const sendUtils = proxyquire('../send.utils.js', { + '../../../helpers/utils/conversion-util': { + addCurrencies: stubs.addCurrencies, + conversionUtil: stubs.conversionUtil, + conversionGTE: stubs.conversionGTE, + multiplyCurrencies: stubs.multiplyCurrencies, + conversionGreaterThan: stubs.conversionGreaterThan, + conversionLessThan: stubs.conversionLessThan, + }, + '../../../helpers/utils/token-util': { calcTokenAmount: stubs.calcTokenAmount }, + 'ethereumjs-abi': { + rawEncode: stubs.rawEncode, + }, +}) + +const { + calcGasTotal, + estimateGas, + doesAmountErrorRequireUpdate, + estimateGasPriceFromRecentBlocks, + generateTokenTransferData, + getAmountErrorObject, + getGasFeeErrorObject, + getToAddressForGasUpdate, + calcTokenBalance, + isBalanceSufficient, + isTokenBalanceSufficient, + removeLeadingZeroes, +} = sendUtils + +describe('send utils', () => { + + describe('calcGasTotal()', () => { + it('should call multiplyCurrencies with the correct params and return the multiplyCurrencies return', () => { + const result = calcGasTotal(12, 15) + assert.equal(result, '12x15') + const call_ = stubs.multiplyCurrencies.getCall(0).args + assert.deepEqual( + call_, + [12, 15, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + } ] + ) + }) + }) + + describe('doesAmountErrorRequireUpdate()', () => { + const config = { + 'should return true if balances are different': { + balance: 0, + prevBalance: 1, + expectedResult: true, + }, + 'should return true if gasTotals are different': { + gasTotal: 0, + prevGasTotal: 1, + expectedResult: true, + }, + 'should return true if token balances are different': { + tokenBalance: 0, + prevTokenBalance: 1, + selectedToken: 'someToken', + expectedResult: true, + }, + 'should return false if they are all the same': { + balance: 1, + prevBalance: 1, + gasTotal: 1, + prevGasTotal: 1, + tokenBalance: 1, + prevTokenBalance: 1, + selectedToken: 'someToken', + expectedResult: false, + }, + } + Object.entries(config).map(([description, obj]) => { + it(description, () => { + assert.equal(doesAmountErrorRequireUpdate(obj), obj.expectedResult) + }) + }) + + }) + + describe('generateTokenTransferData()', () => { + it('should return undefined if not passed a selected token', () => { + assert.equal(generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: false}), undefined) + }) + + it('should call abi.rawEncode with the correct params', () => { + stubs.rawEncode.resetHistory() + generateTokenTransferData({ toAddress: 'mockAddress', amount: 'ab', selectedToken: true}) + assert.deepEqual( + stubs.rawEncode.getCall(0).args, + [['address', 'uint256'], ['mockAddress', '0xab']] + ) + }) + + it('should return encoded token transfer data', () => { + assert.equal( + generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: true}), + '0xa9059cbb104c' + ) + }) + }) + + describe('getAmountErrorObject()', () => { + const config = { + 'should return insufficientFunds error if isBalanceSufficient returns false': { + amount: 15, + amountConversionRate: 2, + balance: 1, + conversionRate: 3, + gasTotal: 17, + primaryCurrency: 'ABC', + expectedResult: { amount: INSUFFICIENT_FUNDS_ERROR }, + }, + 'should not return insufficientFunds error if selectedToken is truthy': { + amount: '0x0', + amountConversionRate: 2, + balance: 1, + conversionRate: 3, + gasTotal: 17, + primaryCurrency: 'ABC', + selectedToken: { symbole: 'DEF', decimals: 0 }, + decimals: 0, + tokenBalance: 'sometokenbalance', + expectedResult: { amount: null }, + }, + 'should return insufficientTokens error if token is selected and isTokenBalanceSufficient returns false': { + amount: '0x10', + amountConversionRate: 2, + balance: 100, + conversionRate: 3, + decimals: 10, + gasTotal: 17, + primaryCurrency: 'ABC', + selectedToken: 'someToken', + tokenBalance: 123, + expectedResult: { amount: INSUFFICIENT_TOKENS_ERROR }, + }, + } + Object.entries(config).map(([description, obj]) => { + it(description, () => { + assert.deepEqual(getAmountErrorObject(obj), obj.expectedResult) + }) + }) + }) + + describe('getGasFeeErrorObject()', () => { + const config = { + 'should return insufficientFunds error if isBalanceSufficient returns false': { + amountConversionRate: 2, + balance: 16, + conversionRate: 3, + gasTotal: 17, + primaryCurrency: 'ABC', + expectedResult: { gasFee: INSUFFICIENT_FUNDS_ERROR }, + }, + 'should return null error if isBalanceSufficient returns true': { + amountConversionRate: 2, + balance: 16, + conversionRate: 3, + gasTotal: 15, + primaryCurrency: 'ABC', + expectedResult: { gasFee: null }, + }, + } + Object.entries(config).map(([description, obj]) => { + it(description, () => { + assert.deepEqual(getGasFeeErrorObject(obj), obj.expectedResult) + }) + }) + }) + + describe('calcTokenBalance()', () => { + it('should return the calculated token blance', () => { + assert.equal(calcTokenBalance({ + selectedToken: { + decimals: 11, + }, + usersToken: { + balance: 20, + }, + }), 'calc:2011') + }) + }) + + describe('isBalanceSufficient()', () => { + it('should correctly call addCurrencies and return the result of calling conversionGTE', () => { + stubs.conversionGTE.resetHistory() + const result = isBalanceSufficient({ + amount: 15, + amountConversionRate: 2, + balance: 100, + conversionRate: 3, + gasTotal: 17, + primaryCurrency: 'ABC', + }) + assert.deepEqual( + stubs.addCurrencies.getCall(0).args, + [ + 15, 17, { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }, + ] + ) + assert.deepEqual( + stubs.conversionGTE.getCall(0).args, + [ + { + value: 100, + fromNumericBase: 'hex', + fromCurrency: 'ABC', + conversionRate: 3, + }, + { + value: 32, + fromNumericBase: 'hex', + conversionRate: 2, + fromCurrency: 'ABC', + }, + ] + ) + + assert.equal(result, true) + }) + }) + + describe('isTokenBalanceSufficient()', () => { + it('should correctly call conversionUtil and return the result of calling conversionGTE', () => { + stubs.conversionGTE.resetHistory() + stubs.conversionUtil.resetHistory() + const result = isTokenBalanceSufficient({ + amount: '0x10', + tokenBalance: 123, + decimals: 10, + }) + assert.deepEqual( + stubs.conversionUtil.getCall(0).args, + [ + '0x10', { + fromNumericBase: 'hex', + }, + ] + ) + assert.deepEqual( + stubs.conversionGTE.getCall(0).args, + [ + { + value: 123, + fromNumericBase: 'hex', + }, + { + value: 'calc:1610', + }, + ] + ) + + assert.equal(result, false) + }) + }) + + describe('estimateGas', () => { + const baseMockParams = { + blockGasLimit: '0x64', + selectedAddress: 'mockAddress', + to: '0xisContract', + estimateGasMethod: sinon.stub().callsFake( + ({to}, cb) => { + const err = typeof to === 'string' && to.match(/willFailBecauseOf:/) + ? new Error(to.match(/:(.+)$/)[1]) + : null + const result = { toString: (n) => `0xabc${n}` } + return cb(err, result) + } + ), + } + const baseExpectedCall = { + from: 'mockAddress', + gas: '0x64x0.95', + to: '0xisContract', + value: '0xff', + } + + beforeEach(() => { + global.eth = { + getCode: sinon.stub().callsFake( + (address) => Promise.resolve(address.match(/isContract/) ? 'not-0x' : '0x') + ), + } + }) + + afterEach(() => { + baseMockParams.estimateGasMethod.resetHistory() + global.eth.getCode.resetHistory() + }) + + it('should call ethQuery.estimateGas with the expected params', async () => { + const result = await sendUtils.estimateGas(baseMockParams) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall) + ) + assert.equal(result, '0xabc16') + }) + + it('should call ethQuery.estimateGas with the expected params when initialGasLimitHex is lower than the upperGasLimit', async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { blockGasLimit: '0xbcd' })) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall, { gas: '0xbcdx0.95' }) + ) + assert.equal(result, '0xabc16x1.5') + }) + + it('should call ethQuery.estimateGas with a value of 0x0 and the expected data and to if passed a selectedToken', async () => { + const result = await estimateGas(Object.assign({ data: 'mockData', selectedToken: { address: 'mockAddress' } }, baseMockParams)) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + Object.assign({}, baseExpectedCall, { + gasPrice: undefined, + value: '0x0', + data: '0xa9059cbb104c', + to: 'mockAddress', + }) + ) + assert.equal(result, '0xabc16') + }) + + it('should call ethQuery.estimateGas without a recipient if the recipient is empty and data passed', async () => { + const data = 'mockData' + const to = '' + const result = await estimateGas({...baseMockParams, data, to}) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + { gasPrice: undefined, value: '0xff', data, from: baseExpectedCall.from, gas: baseExpectedCall.gas}, + ) + assert.equal(result, '0xabc16') + }) + + it(`should return ${SIMPLE_GAS_COST} if ethQuery.getCode does not return '0x'`, async () => { + assert.equal(baseMockParams.estimateGasMethod.callCount, 0) + const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123' })) + assert.equal(result, SIMPLE_GAS_COST) + }) + + it(`should return ${SIMPLE_GAS_COST} if not passed a selectedToken or truthy to address`, async () => { + assert.equal(baseMockParams.estimateGasMethod.callCount, 0) + const result = await estimateGas(Object.assign({}, baseMockParams, { to: null })) + assert.equal(result, SIMPLE_GAS_COST) + }) + + it(`should not return ${SIMPLE_GAS_COST} if passed a selectedToken`, async () => { + assert.equal(baseMockParams.estimateGasMethod.callCount, 0) + const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123', selectedToken: { address: '' } })) + assert.notEqual(result, SIMPLE_GAS_COST) + }) + + it(`should return ${BASE_TOKEN_GAS_COST} if passed a selectedToken but no to address`, async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { to: null, selectedToken: { address: '' } })) + assert.equal(result, BASE_TOKEN_GAS_COST) + }) + + it(`should return the adjusted blockGasLimit if it fails with a 'Transaction execution error.'`, async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { + to: 'isContract willFailBecauseOf:Transaction execution error.', + })) + assert.equal(result, '0x64x0.95') + }) + + it(`should return the adjusted blockGasLimit if it fails with a 'gas required exceeds allowance or always failing transaction.'`, async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { + to: 'isContract willFailBecauseOf:gas required exceeds allowance or always failing transaction.', + })) + assert.equal(result, '0x64x0.95') + }) + + it(`should reject other errors`, async () => { + try { + await estimateGas(Object.assign({}, baseMockParams, { + to: 'isContract willFailBecauseOf:some other error', + })) + } catch (err) { + assert.equal(err.message, 'some other error') + } + }) + }) + + describe('estimateGasPriceFromRecentBlocks', () => { + const ONE_GWEI_IN_WEI_HEX_PLUS_ONE = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + const ONE_GWEI_IN_WEI_HEX_PLUS_TWO = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x2', { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + const ONE_GWEI_IN_WEI_HEX_MINUS_ONE = subtractCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + + it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is falsy`, () => { + assert.equal(estimateGasPriceFromRecentBlocks(), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is empty`, () => { + assert.equal(estimateGasPriceFromRecentBlocks([]), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has no gas prices`, () => { + const mockRecentBlocks = [ + { gasPrices: null }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has empty gas prices`, () => { + const mockRecentBlocks = [ + { gasPrices: [] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should return the middle value of all blocks lowest prices`, () => { + const mockRecentBlocks = [ + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_TWO ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX_PLUS_ONE) + }) + + it(`should work if a block has multiple gas prices`, () => { + const mockRecentBlocks = [ + { gasPrices: [ '0x1', '0x2', '0x3', '0x4', '0x5' ] }, + { gasPrices: [ '0x101', '0x100', '0x103', '0x104', '0x102' ] }, + { gasPrices: [ '0x150', '0x50', '0x100', '0x200', '0x5' ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), '0x5') + }) + }) + + describe('getToAddressForGasUpdate()', () => { + it('should return empty string if all params are undefined or null', () => { + assert.equal(getToAddressForGasUpdate(undefined, null), '') + }) + + it('should return the first string that is not defined or null in lower case', () => { + assert.equal(getToAddressForGasUpdate('A', null), 'a') + assert.equal(getToAddressForGasUpdate(undefined, 'B'), 'b') + }) + }) + + describe('removeLeadingZeroes()', () => { + it('should remove leading zeroes from int when user types', () => { + assert.equal(removeLeadingZeroes('0'), '0') + assert.equal(removeLeadingZeroes('1'), '1') + assert.equal(removeLeadingZeroes('00'), '0') + assert.equal(removeLeadingZeroes('01'), '1') + }) + + it('should remove leading zeroes from int when user copy/paste', () => { + assert.equal(removeLeadingZeroes('001'), '1') + }) + + it('should remove leading zeroes from float when user types', () => { + assert.equal(removeLeadingZeroes('0.'), '0.') + assert.equal(removeLeadingZeroes('0.0'), '0.0') + assert.equal(removeLeadingZeroes('0.00'), '0.00') + assert.equal(removeLeadingZeroes('0.001'), '0.001') + assert.equal(removeLeadingZeroes('0.10'), '0.10') + }) + + it('should remove leading zeroes from float when user copy/paste', () => { + assert.equal(removeLeadingZeroes('00.1'), '0.1') + }) + }) +}) diff --git a/ui/app/components/app/send/to-autocomplete.component.js b/ui/app/components/app/send/to-autocomplete.component.js new file mode 100644 index 000000000..183967c58 --- /dev/null +++ b/ui/app/components/app/send/to-autocomplete.component.js @@ -0,0 +1,141 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import AccountListItem from './account-list-item/account-list-item.component' + + +export default class ToAutoComplete extends Component { + + static propTypes = { + dropdownOpen: PropTypes.bool, + openDropdown: PropTypes.func, + closeDropdown: PropTypes.func, + onChange: PropTypes.func, + to: PropTypes.string, + accounts: PropTypes.array, + inError: PropTypes.bool, + } + + static contextTypes = { + t: PropTypes.func, + } + + state = { + accountsToRender: [], + } + + getListItemIcon (listItemAddress, toAddress) { + return toAddress && listItemAddress === toAddress + ? <i className={'fa fa-check fa-lg'} + style={{ + color: '#02c9b1', + }} + /> + : null + } + + renderDropdown () { + const { + closeDropdown, + onChange, + to, + } = this.props + const {accountsToRender} = this.state + + if (!accountsToRender.length) { + return null + } + + return ( + <div> + <div className={'send-v2__from-dropdown__close-area'} onClick={closeDropdown} /> + <div className={'send-v2__from-dropdown__list'}> + {accountsToRender.map((account, i) => ( + <AccountListItem + key={i} + account={account} + className={'account-list-item__dropdown'} + handleClick={() => { + onChange(account.address) + closeDropdown() + }} + icon={this.getListItemIcon(account.address, to)} + displayBalance={false} + displayAddress={true} + /> + ))} + </div> + </div> + ) + } + + handleInputEvent (event = {}, cb) { + const { + to, + accounts, + closeDropdown, + openDropdown, + } = this.props + + const matchingAccounts = accounts.filter(({address}) => address.match(to || '')) + const matches = matchingAccounts.length + + if (!matches || matchingAccounts[0].address === to) { + this.setState({accountsToRender: []}) + event.target && event.target.select() + closeDropdown() + } else { + this.setState({accountsToRender: matchingAccounts}) + openDropdown() + } + cb && cb(event.target.value) + } + + componentDidUpdate (nextProps) { + if (this.props.to !== nextProps.to) { + this.handleInputEvent() + } + } + + render () { + const { + to, + dropdownOpen, + onChange, + inError, + } = this.props + + return ( + <div className={'send-v2__to-autocomplete'}> + <input + className={classnames('send-v2__to-autocomplete__input', { + 'send-v2__error-border': inError, + })} + placeholder={this.context.t('recipientAddress')} + value={to} + onChange={event => onChange(event.target.value)} + onFocus={event => this.handleInputEvent(event)} + style={{ + borderColor: inError ? 'red' : null, + }} + /> + { + to + ? null + : <i className={'fa fa-caret-down fa-lg send-v2__to-autocomplete__down-caret'} + onClick={() => this.handleInputEvent()} + style={{ + style: {color: '#dedede'}, + }} + /> + } + { + dropdownOpen + ? this.renderDropdown() + : null + } + </div> + ) + } + +} 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/app/send/to-autocomplete/to-autocomplete.js b/ui/app/components/app/send/to-autocomplete/to-autocomplete.js new file mode 100644 index 000000000..d3db8cb59 --- /dev/null +++ b/ui/app/components/app/send/to-autocomplete/to-autocomplete.js @@ -0,0 +1,129 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +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('../../../ui/tooltip') +const checksumAddress = require('../../../../helpers/utils/util').checksumAddress + +ToAutoComplete.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect()(ToAutoComplete) + + +inherits(ToAutoComplete, Component) +function ToAutoComplete () { + Component.call(this) + + this.state = { accountsToRender: [] } +} + +ToAutoComplete.prototype.getListItemIcon = function (listItemAddress, toAddress) { + const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } }) + + return toAddress && listItemAddress === toAddress + ? listItemIcon + : null +} + +ToAutoComplete.prototype.renderDropdown = function () { + const { + closeDropdown, + onChange, + to, + } = this.props + const { accountsToRender } = this.state + + return accountsToRender.length && h('div', {}, [ + + h('div.send-v2__from-dropdown__close-area', { + onClick: closeDropdown, + }), + + h('div.send-v2__from-dropdown__list', {}, [ + + ...accountsToRender.map(account => h(AccountListItem, { + account, + className: 'account-list-item__dropdown', + handleClick: () => { + onChange(checksumAddress(account.address)) + closeDropdown() + }, + icon: this.getListItemIcon(account.address, to), + displayBalance: false, + displayAddress: true, + })), + + ]), + + ]) +} + +ToAutoComplete.prototype.handleInputEvent = function (event = {}, cb) { + const { + to, + accounts, + closeDropdown, + openDropdown, + } = this.props + + const matchingAccounts = accounts.filter(({ address }) => address.match(to || '')) + const matches = matchingAccounts.length + + if (!matches || matchingAccounts[0].address === to) { + this.setState({ accountsToRender: [] }) + event.target && event.target.select() + closeDropdown() + } else { + this.setState({ accountsToRender: matchingAccounts }) + openDropdown() + } + cb && cb(event.target.value) +} + +ToAutoComplete.prototype.componentDidUpdate = function (nextProps, nextState) { + if (this.props.to !== nextProps.to) { + this.handleInputEvent() + } +} + +ToAutoComplete.prototype.render = function () { + const { + to, + dropdownOpen, + onChange, + inError, + qrScanner, + } = this.props + + return h('div.send-v2__to-autocomplete', {}, [ + + h(`input.send-v2__to-autocomplete__input${qrScanner ? '.with-qr' : ''}`, { + placeholder: this.context.t('recipientAddress'), + className: inError ? `send-v2__error-border` : '', + value: to, + onChange: event => onChange(event.target.value), + onFocus: event => this.handleInputEvent(event), + style: { + borderColor: inError ? 'red' : null, + }, + }), + qrScanner && h(Tooltip, { + title: this.context.t('scanQrCode'), + position: 'bottom', + }, h(`i.fa.fa-qrcode.fa-lg.send-v2__to-autocomplete__qr-code`, { + style: { color: '#33333' }, + onClick: () => this.props.scanQrCode(), + })), + !to && h(`i.fa.fa-caret-down.fa-lg.send-v2__to-autocomplete__down-caret`, { + style: { color: '#dedede' }, + onClick: () => this.handleInputEvent(), + }), + + dropdownOpen && this.renderDropdown(), + + ]) +} diff --git a/ui/app/components/app/shapeshift-form.js b/ui/app/components/app/shapeshift-form.js new file mode 100644 index 000000000..11459fd5e --- /dev/null +++ b/ui/app/components/app/shapeshift-form.js @@ -0,0 +1,256 @@ +const h = require('react-hyperscript') +const inherits = require('util').inherits +const PropTypes = require('prop-types') +const Component = require('react').Component +const connect = require('react-redux').connect +const classnames = require('classnames') +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 '../ui/button' + +function mapStateToProps (state) { + const { + coinOptions, + tokenExchangeRates, + selectedAddress, + } = state.metamask + const { warning } = state.appState + + return { + coinOptions, + tokenExchangeRates, + selectedAddress, + warning, + } +} + +function mapDispatchToProps (dispatch) { + return { + shapeShiftSubview: () => dispatch(shapeShiftSubview()), + pairUpdate: coin => dispatch(pairUpdate(coin)), + buyWithShapeShift: data => dispatch(buyWithShapeShift(data)), + } +} + +ShapeshiftForm.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ShapeshiftForm) + + +inherits(ShapeshiftForm, Component) +function ShapeshiftForm () { + Component.call(this) + + this.state = { + depositCoin: 'btc', + refundAddress: '', + showQrCode: false, + depositAddress: '', + errorMessage: '', + isLoading: false, + bought: false, + } +} + +ShapeshiftForm.prototype.getCoinPair = function () { + return `${this.state.depositCoin.toUpperCase()}_ETH` +} + +ShapeshiftForm.prototype.componentWillMount = function () { + this.props.shapeShiftSubview() +} + +ShapeshiftForm.prototype.onCoinChange = function (coin) { + this.setState({ + depositCoin: coin, + errorMessage: '', + }) + this.props.pairUpdate(coin) +} + +ShapeshiftForm.prototype.onBuyWithShapeShift = function () { + this.setState({ + isLoading: true, + showQrCode: true, + }) + + const { + buyWithShapeShift, + selectedAddress: withdrawal, + } = this.props + const { + refundAddress: returnAddress, + depositCoin, + } = this.state + const pair = `${depositCoin}_eth` + const data = { + withdrawal, + pair, + returnAddress, + // Public api key + 'apiKey': '803d1f5df2ed1b1476e4b9e6bcd089e34d8874595dda6a23b67d93c56ea9cc2445e98a6748b219b2b6ad654d9f075f1f1db139abfa93158c04e825db122c14b6', + } + + if (isValidAddress(withdrawal)) { + buyWithShapeShift(data) + .then(d => this.setState({ + showQrCode: true, + depositAddress: d.deposit, + isLoading: false, + })) + .catch(() => this.setState({ + showQrCode: false, + errorMessage: this.context.t('invalidRequest'), + isLoading: false, + })) + } +} + +ShapeshiftForm.prototype.renderMetadata = function (label, value) { + return h('div', {className: 'shapeshift-form__metadata-wrapper'}, [ + + h('div.shapeshift-form__metadata-label', {}, [ + h('span', `${label}:`), + ]), + + h('div.shapeshift-form__metadata-value', {}, [ + h('span', value), + ]), + + ]) +} + +ShapeshiftForm.prototype.renderMarketInfo = function () { + const { tokenExchangeRates } = this.props + const { + limit, + rate, + minimum, + } = tokenExchangeRates[this.getCoinPair()] || {} + + return h('div.shapeshift-form__metadata', {}, [ + + this.renderMetadata(this.context.t('status'), limit ? this.context.t('available') : this.context.t('unavailable')), + this.renderMetadata(this.context.t('limit'), limit), + this.renderMetadata(this.context.t('exchangeRate'), rate), + this.renderMetadata(this.context.t('min'), minimum), + + ]) +} + +ShapeshiftForm.prototype.renderQrCode = function () { + const { depositAddress, isLoading, depositCoin } = this.state + const qrImage = qrcode(4, 'M') + qrImage.addData(depositAddress) + qrImage.make() + + return h('div.shapeshift-form', {}, [ + + h('div.shapeshift-form__deposit-instruction', [ + this.context.t('depositCoin', [depositCoin.toUpperCase()]), + ]), + + h('div', depositAddress), + + h('div.shapeshift-form__qr-code', [ + isLoading + ? h('img', { + src: 'images/loading.svg', + style: { width: '60px'}, + }) + : h('div', { + dangerouslySetInnerHTML: { __html: qrImage.createTableTag(4) }, + }), + ]), + + this.renderMarketInfo(), + + ]) +} + + +ShapeshiftForm.prototype.render = function () { + const { coinOptions, btnClass, warning } = this.props + const { errorMessage, showQrCode, depositAddress } = this.state + const { tokenExchangeRates } = this.props + const token = tokenExchangeRates[this.getCoinPair()] + + return h('div.shapeshift-form-wrapper', [ + showQrCode + ? this.renderQrCode() + : h('div.modal-shapeshift-form', [ + h('div.shapeshift-form__selectors', [ + + h('div.shapeshift-form__selector', [ + + h('div.shapeshift-form__selector-label', this.context.t('deposit')), + + h(SimpleDropdown, { + selectedOption: this.state.depositCoin, + onSelect: (coin) => this.onCoinChange(coin), + options: Object.entries(coinOptions).map(([coin]) => ({ + value: coin.toLowerCase(), + displayValue: coin, + })), + }), + + ]), + + h('div.icon.shapeshift-form__caret', { + style: { backgroundImage: 'url(images/caret-right.svg)'}, + }), + + h('div.shapeshift-form__selector', [ + + h('div.shapeshift-form__selector-label', [ + this.context.t('receive'), + ]), + + h('div.shapeshift-form__selector-input', ['ETH']), + + ]), + + ]), + + warning && h('div.shapeshift-form__address-input-label', warning), + + !warning && h('div', { + className: classnames('shapeshift-form__address-input-wrapper', { + 'shapeshift-form__address-input-wrapper--error': errorMessage, + }), + }, [ + + h('div.shapeshift-form__address-input-label', [ + this.context.t('refundAddress'), + ]), + + h('input.shapeshift-form__address-input', { + type: 'text', + onChange: e => this.setState({ + refundAddress: e.target.value, + errorMessage: '', + }), + }), + + h('divshapeshift-form__address-input-error-message', [errorMessage]), + ]), + + !warning && this.renderMarketInfo(), + + ]), + + !depositAddress && h(Button, { + type: 'primary', + large: true, + className: `${btnClass} shapeshift-form__shapeshift-buy-btn`, + disabled: !token, + onClick: () => this.onBuyWithShapeShift(), + }, [this.context.t('buy')]), + + ]) +} diff --git a/ui/app/components/app/shift-list-item.js b/ui/app/components/app/shift-list-item.js new file mode 100644 index 000000000..f5fa00047 --- /dev/null +++ b/ui/app/components/app/shift-list-item.js @@ -0,0 +1,204 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const explorerLink = require('etherscan-link').createExplorerLink +const actions = require('../../store/actions') +const { formatDate, addressSummary } = require('../../helpers/utils/util') + +const CopyButton = require('../ui/copyButton') +const EthBalance = require('../ui/eth-balance') +const Tooltip = require('../ui/tooltip') + + +ShiftListItem.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps)(ShiftListItem) + + +function mapStateToProps (state) { + return { + selectedAddress: state.metamask.selectedAddress, + conversionRate: state.metamask.conversionRate, + currentCurrency: state.metamask.currentCurrency, + } +} + +inherits(ShiftListItem, Component) + +function ShiftListItem () { + Component.call(this) +} + +ShiftListItem.prototype.render = function () { + return h('div.transaction-list-item.tx-list-clickable', { + style: { + paddingTop: '20px', + paddingBottom: '20px', + justifyContent: 'space-around', + alignItems: 'center', + flexDirection: 'row', + }, + }, [ + h('div', { + style: { + width: '0px', + position: 'relative', + bottom: '19px', + }, + }, [ + h('img', { + src: 'https://shapeshift.io/logo.png', + style: { + height: '35px', + width: '132px', + position: 'absolute', + clip: 'rect(0px,30px,34px,0px)', + }, + }), + ]), + + this.renderInfo(), + this.renderUtilComponents(), + ]) +} + +ShiftListItem.prototype.renderUtilComponents = function () { + var props = this.props + const { conversionRate, currentCurrency } = props + + switch (props.response.status) { + case 'no_deposits': + return h('.flex-row', [ + h(CopyButton, { + value: this.props.depositAddress, + }), + h(Tooltip, { + title: this.context.t('qrCode'), + }, [ + h('i.fa.fa-qrcode.pointer.pop-hover', { + onClick: () => props.dispatch(actions.reshowQrCode(props.depositAddress, props.depositType)), + style: { + margin: '5px', + marginLeft: '23px', + marginRight: '12px', + fontSize: '20px', + color: '#F7861C', + }, + }), + ]), + ]) + case 'received': + return h('.flex-row') + + case 'complete': + return h('.flex-row', [ + h(CopyButton, { + value: this.props.response.transaction, + }), + h(EthBalance, { + value: `${props.response.outgoingCoin}`, + conversionRate, + currentCurrency, + width: '55px', + shorten: true, + needsParse: false, + incoming: true, + style: { + fontSize: '15px', + color: '#01888C', + }, + }), + ]) + + case 'failed': + return '' + default: + return '' + } +} + +ShiftListItem.prototype.renderInfo = function () { + var props = this.props + switch (props.response.status) { + case 'no_deposits': + return h('.flex-column', { + style: { + overflow: 'hidden', + }, + }, [ + h('div', { + style: { + fontSize: 'x-small', + color: '#ABA9AA', + width: '100%', + }, + }, this.context.t('toETHviaShapeShift', [props.depositType])), + h('div', this.context.t('noDeposits')), + h('div', { + style: { + fontSize: 'x-small', + color: '#ABA9AA', + width: '100%', + }, + }, formatDate(props.time)), + ]) + case 'received': + return h('.flex-column', { + style: { + width: '200px', + overflow: 'hidden', + }, + }, [ + h('div', { + style: { + fontSize: 'x-small', + color: '#ABA9AA', + width: '100%', + }, + }, this.context.t('toETHviaShapeShift', [props.depositType])), + h('div', this.context.t('conversionProgress')), + h('div', { + style: { + fontSize: 'x-small', + color: '#ABA9AA', + width: '100%', + }, + }, formatDate(props.time)), + ]) + case 'complete': + var url = explorerLink(props.response.transaction, parseInt('1')) + + return h('.flex-column.pointer', { + style: { + width: '200px', + overflow: 'hidden', + }, + onClick: () => global.platform.openWindow({ url }), + }, [ + h('div', { + style: { + fontSize: 'x-small', + color: '#ABA9AA', + width: '100%', + }, + }, this.context.t('fromShapeShift')), + h('div', formatDate(props.time)), + h('div', { + style: { + fontSize: 'x-small', + color: '#ABA9AA', + width: '100%', + }, + }, addressSummary(props.response.transaction)), + ]) + + case 'failed': + return h('span.error', '(' + this.context.t('failed') + ')') + default: + return '' + } +} 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/app/sidebars/index.scss b/ui/app/components/app/sidebars/index.scss new file mode 100644 index 000000000..08181426f --- /dev/null +++ b/ui/app/components/app/sidebars/index.scss @@ -0,0 +1,81 @@ +@import 'sidebar-content'; + +.sidebar-right-enter { + transition: transform 300ms ease-in-out; + transform: translateX(-100%); +} + +.sidebar-right-enter.sidebar-right-enter-active { + transition: transform 300ms ease-in-out; + transform: translateX(0%); +} + +.sidebar-right-leave { + transition: transform 200ms ease-out; + transform: translateX(0%); +} + +.sidebar-right-leave.sidebar-right-leave-active { + transition: transform 200ms ease-out; + transform: translateX(-100%); +} + +.sidebar-left-enter { + transition: transform 300ms ease-in-out; + transform: translateX(100%); +} + +.sidebar-left-enter.sidebar-left-enter-active { + transition: transform 300ms ease-in-out; + transform: translateX(0%); +} + +.sidebar-left-leave { + transition: transform 200ms ease-out; + transform: translateX(0%); +} + +.sidebar-left-leave.sidebar-left-leave-active { + transition: transform 200ms ease-out; + transform: translateX(100%); +} + +.sidebar-left { + flex: 1 0 230px; + background: rgb(250, 250, 250); + z-index: $sidebar-z-index; + position: fixed; + left: 15%; + right: 0; + bottom: 0; + opacity: 1; + visibility: visible; + will-change: transform; + overflow-y: auto; + box-shadow: rgba(0, 0, 0, .15) 2px 2px 4px; + width: 85%; + height: 100%; + + @media screen and (min-width: 769px) { + width: 408px; + left: calc(100% - 408px); + } + + @media screen and (max-width: $break-small) { + width: 100%; + left: 0%; + } +} + +.sidebar-overlay { + z-index: $sidebar-overlay-z-index; + position: fixed; + height: 100%; + width: 100%; + left: 0; + right: 0; + bottom: 0; + opacity: 1; + visibility: visible; + background-color: rgba(0, 0, 0, .3); +} diff --git a/ui/app/components/sidebars/sidebar-content.scss b/ui/app/components/app/sidebars/sidebar-content.scss index ca6b0a458..ca6b0a458 100644 --- a/ui/app/components/sidebars/sidebar-content.scss +++ b/ui/app/components/app/sidebars/sidebar-content.scss diff --git a/ui/app/components/sidebars/sidebar.component.js b/ui/app/components/app/sidebars/sidebar.component.js index b9e0f9e81..b9e0f9e81 100644 --- a/ui/app/components/sidebars/sidebar.component.js +++ b/ui/app/components/app/sidebars/sidebar.component.js 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 cee22aca8..cee22aca8 100644 --- a/ui/app/components/sidebars/tests/sidebars-component.test.js +++ b/ui/app/components/app/sidebars/tests/sidebars-component.test.js diff --git a/ui/app/components/app/signature-request.js b/ui/app/components/app/signature-request.js new file mode 100644 index 000000000..4415ecd4f --- /dev/null +++ b/ui/app/components/app/signature-request.js @@ -0,0 +1,316 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const inherits = require('util').inherits +import Identicon from '../ui/identicon' +const connect = require('react-redux').connect +const ethUtil = require('ethereumjs-util') +const classnames = require('classnames') +const { compose } = require('recompose') +const { withRouter } = require('react-router-dom') +const { ObjectInspector } = require('react-inspector') + +import AccountDropdownMini from '../ui/account-dropdown-mini' + +const actions = require('../../store/actions') +const { conversionUtil } = require('../../helpers/utils/conversion-util') + +const { + getSelectedAccount, + getCurrentAccountWithSendEtherInfo, + getSelectedAddress, + accountsWithSendEtherInfoSelector, + conversionRateSelector, +} = require('../../selectors/selectors.js') + +import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck' +import Button from '../ui/button' + +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') + +function mapStateToProps (state) { + return { + balance: getSelectedAccount(state).balance, + selectedAccount: getCurrentAccountWithSendEtherInfo(state), + selectedAddress: getSelectedAddress(state), + requester: null, + requesterAddress: null, + accounts: accountsWithSendEtherInfoSelector(state), + conversionRate: conversionRateSelector(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + goHome: () => dispatch(actions.goHome()), + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + } +} + +SignatureRequest.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(SignatureRequest) + + +inherits(SignatureRequest, Component) +function SignatureRequest (props) { + Component.call(this) + + this.state = { + selectedAccount: props.selectedAccount, + } +} + +SignatureRequest.prototype.renderHeader = function () { + return h('div.request-signature__header', [ + + h('div.request-signature__header-background'), + + h('div.request-signature__header__text', this.context.t('sigRequest')), + + h('div.request-signature__header__tip-container', [ + h('div.request-signature__header__tip'), + ]), + + ]) +} + +SignatureRequest.prototype.renderAccountDropdown = function () { + const { selectedAccount } = this.state + + const { + accounts, + } = this.props + + return h('div.request-signature__account', [ + + h('div.request-signature__account-text', [this.context.t('account') + ':']), + + h(AccountDropdownMini, { + selectedAccount, + accounts, + disabled: true, + }), + + ]) +} + +SignatureRequest.prototype.renderBalance = function () { + const { balance, conversionRate } = this.props + + const balanceInEther = conversionUtil(balance, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + numberOfDecimals: 6, + conversionRate, + }) + + return h('div.request-signature__balance', [ + + h('div.request-signature__balance-text', `${this.context.t('balance')}:`), + + h('div.request-signature__balance-value', `${balanceInEther} ETH`), + + ]) +} + +SignatureRequest.prototype.renderAccountInfo = function () { + return h('div.request-signature__account-info', [ + + this.renderAccountDropdown(), + + this.renderRequestIcon(), + + this.renderBalance(), + + ]) +} + +SignatureRequest.prototype.renderRequestIcon = function () { + const { requesterAddress } = this.props + + return h('div.request-signature__request-icon', [ + h(Identicon, { + diameter: 40, + address: requesterAddress, + }), + ]) +} + +SignatureRequest.prototype.renderRequestInfo = function () { + return h('div.request-signature__request-info', [ + + h('div.request-signature__headline', [ + this.context.t('yourSigRequested'), + ]), + + ]) +} + +SignatureRequest.prototype.msgHexToText = function (hex) { + try { + const stripped = ethUtil.stripHexPrefix(hex) + const buff = Buffer.from(stripped, 'hex') + return buff.length === 32 ? hex : buff.toString('utf8') + } catch (e) { + return hex + } +} + +// eslint-disable-next-line react/display-name +SignatureRequest.prototype.renderTypedDataV3 = function (data) { + const { domain, message } = JSON.parse(data) + return [ + h('div.request-signature__typed-container', [ + domain ? h('div', [ + h('h1', 'Domain'), + h(ObjectInspector, { data: domain, expandLevel: 1, name: 'domain' }), + ]) : '', + message ? h('div', [ + h('h1', 'Message'), + h(ObjectInspector, { data: message, expandLevel: 1, name: 'message' }), + ]) : '', + ]), + ] +} + +SignatureRequest.prototype.renderBody = function () { + let rows + let notice = this.context.t('youSign') + ':' + + const { txData } = this.props + const { type, msgParams: { data, version } } = txData + + if (type === 'personal_sign') { + rows = [{ name: this.context.t('message'), value: this.msgHexToText(data) }] + } else if (type === 'eth_signTypedData') { + rows = data + } else if (type === 'eth_sign') { + rows = [{ name: this.context.t('message'), value: data }] + notice = [this.context.t('signNotice'), + h('span.request-signature__help-link', { + onClick: () => { + global.platform.openWindow({ + url: 'https://metamask.zendesk.com/hc/en-us/articles/360015488751', + }) + }, + }, this.context.t('learnMore'))] + } + + return h('div.request-signature__body', {}, [ + + this.renderAccountInfo(), + + this.renderRequestInfo(), + + h('div.request-signature__notice', { + className: classnames({ + 'request-signature__notice': type === 'personal_sign' || type === 'eth_signTypedData', + 'request-signature__warning': type === 'eth_sign', + }), + }, [notice]), + + h('div.request-signature__rows', type === 'eth_signTypedData' && version === 'V3' ? + this.renderTypedDataV3(data) : + rows.map(({ name, value }) => { + if (typeof value === 'boolean') { + value = value.toString() + } + return h('div.request-signature__row', [ + h('div.request-signature__row-title', [`${name}:`]), + h('div.request-signature__row-value', value), + ]) + }), + ), + ]) +} + +SignatureRequest.prototype.renderFooter = function () { + const { + signPersonalMessage, + signTypedMessage, + cancelPersonalMessage, + cancelTypedMessage, + signMessage, + cancelMessage, + } = this.props + + const { txData } = this.props + const { type } = txData + + let cancel + let sign + if (type === 'personal_sign') { + cancel = cancelPersonalMessage + sign = signPersonalMessage + } else if (type === 'eth_signTypedData') { + cancel = cancelTypedMessage + sign = signTypedMessage + } else if (type === 'eth_sign') { + cancel = cancelMessage + sign = signMessage + } + + return h('div.request-signature__footer', [ + h(Button, { + type: 'default', + large: true, + 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) + }) + }, + }, this.context.t('cancel')), + 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) + }) + }, + }, this.context.t('sign')), + ]) +} + +SignatureRequest.prototype.render = function () { + return ( + + h('div.request-signature__container', [ + + this.renderHeader(), + + this.renderBody(), + + this.renderFooter(), + + ]) + + ) + +} diff --git a/ui/app/components/tab-bar.js b/ui/app/components/app/tab-bar.js index 0016a09c1..0016a09c1 100644 --- a/ui/app/components/tab-bar.js +++ b/ui/app/components/app/tab-bar.js diff --git a/ui/app/components/app/token-cell.js b/ui/app/components/app/token-cell.js new file mode 100644 index 000000000..cef809e8a --- /dev/null +++ b/ui/app/components/app/token-cell.js @@ -0,0 +1,177 @@ +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 '../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') + +function mapStateToProps (state) { + return { + network: state.metamask.network, + currentCurrency: state.metamask.currentCurrency, + selectedTokenAddress: state.metamask.selectedTokenAddress, + userAddress: selectors.getSelectedAddress(state), + contractExchangeRates: state.metamask.contractExchangeRates, + conversionRate: state.metamask.conversionRate, + sidebarOpen: state.appState.sidebar.isOpen, + } +} + +function mapDispatchToProps (dispatch) { + return { + setSelectedToken: address => dispatch(actions.setSelectedToken(address)), + hideSidebar: () => dispatch(actions.hideSidebar()), + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(TokenCell) + +inherits(TokenCell, Component) +function TokenCell () { + Component.call(this) + + this.state = { + tokenMenuOpen: false, + } +} + +TokenCell.contextTypes = { + metricsEvent: PropTypes.func, +} + +TokenCell.prototype.render = function () { + const { tokenMenuOpen } = this.state + const props = this.props + const { + address, + symbol, + string, + network, + setSelectedToken, + selectedTokenAddress, + contractExchangeRates, + conversionRate, + hideSidebar, + sidebarOpen, + currentCurrency, + // userAddress, + image, + } = props + let currentTokenToFiatRate + let currentTokenInFiat + let formattedFiat = '' + + if (contractExchangeRates[address]) { + currentTokenToFiatRate = multiplyCurrencies( + contractExchangeRates[address], + conversionRate + ) + currentTokenInFiat = conversionUtil(string, { + fromNumericBase: 'dec', + fromCurrency: symbol, + toCurrency: currentCurrency.toUpperCase(), + numberOfDecimals: 2, + conversionRate: currentTokenToFiatRate, + }) + formattedFiat = currentTokenInFiat.toString() === '0' + ? '' + : `${currentTokenInFiat} ${currentCurrency.toUpperCase()}` + } + + const showFiat = Boolean(currentTokenInFiat) && currentCurrency.toUpperCase() !== symbol + + return ( + h('div.token-list-item', { + className: `token-list-item ${selectedTokenAddress === address ? 'token-list-item--active' : ''}`, + // style: { cursor: network === '1' ? 'pointer' : 'default' }, + // onClick: this.view.bind(this, address, userAddress, network), + onClick: () => { + setSelectedToken(address) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Token Menu', + name: 'Clicked Token', + }, + }) + selectedTokenAddress !== address && sidebarOpen && hideSidebar() + }, + }, [ + + h(Identicon, { + className: 'token-list-item__identicon', + diameter: 50, + address, + network, + image, + }), + + h('div.token-list-item__balance-ellipsis', null, [ + h('div.token-list-item__balance-wrapper', null, [ + h('div.token-list-item__token-balance', `${string || 0}`), + h('div.token-list-item__token-symbol', symbol), + showFiat && h('div.token-list-item__fiat-amount', { + style: {}, + }, formattedFiat), + ]), + + h('i.fa.fa-ellipsis-h.fa-lg.token-list-item__ellipsis.cursor-pointer', { + onClick: (e) => { + e.stopPropagation() + this.setState({ tokenMenuOpen: true }) + }, + }), + + ]), + + + tokenMenuOpen && h(TokenMenuDropdown, { + onClose: () => this.setState({ tokenMenuOpen: false }), + token: { symbol, address }, + }), + + /* + h('button', { + onClick: this.send.bind(this, address), + }, 'SEND'), + */ + + ]) + ) +} + +TokenCell.prototype.send = function (address, event) { + event.preventDefault() + event.stopPropagation() + const url = tokenFactoryFor(address) + if (url) { + navigateTo(url) + } +} + +TokenCell.prototype.view = function (address, userAddress, network, event) { + const url = etherscanLinkFor(address, userAddress, network) + if (url) { + navigateTo(url) + } +} + +function navigateTo (url) { + global.platform.openWindow({ url }) +} + +function etherscanLinkFor (tokenAddress, address, network) { + const prefix = prefixForNetwork(network) + return `https://${prefix}etherscan.io/token/${tokenAddress}?a=${address}` +} + +function tokenFactoryFor (tokenAddress) { + return `https://tokenfactory.surge.sh/#/token/${tokenAddress}` +} + diff --git a/ui/app/components/app/token-list.js b/ui/app/components/app/token-list.js new file mode 100644 index 000000000..2188e7020 --- /dev/null +++ b/ui/app/components/app/token-list.js @@ -0,0 +1,188 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const inherits = require('util').inherits +const TokenTracker = require('eth-token-tracker') +const TokenCell = require('./token-cell.js') +const connect = require('react-redux').connect +const selectors = require('../../selectors/selectors') +const log = require('loglevel') + +function mapStateToProps (state) { + return { + network: state.metamask.network, + tokens: state.metamask.tokens, + userAddress: selectors.getSelectedAddress(state), + assetImages: state.metamask.assetImages, + } +} + +const defaultTokens = [] +const contracts = require('eth-contract-metadata') +for (const address in contracts) { + const contract = contracts[address] + if (contract.erc20) { + contract.address = address + defaultTokens.push(contract) + } +} + +TokenList.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps)(TokenList) + + +inherits(TokenList, Component) +function TokenList () { + this.state = { + tokens: [], + isLoading: true, + network: null, + } + Component.call(this) +} + +TokenList.prototype.render = function () { + const { userAddress, assetImages } = this.props + const state = this.state + const { tokens, isLoading, error } = state + if (isLoading) { + return this.message(this.context.t('loadingTokens')) + } + + if (error) { + log.error(error) + return h('.hotFix', { + style: { + padding: '80px', + }, + }, [ + this.context.t('troubleTokenBalances'), + h('span.hotFix', { + style: { + color: 'rgba(247, 134, 28, 1)', + cursor: 'pointer', + }, + onClick: () => { + global.platform.openWindow({ + url: `https://ethplorer.io/address/${userAddress}`, + }) + }, + }, this.context.t('here')), + ]) + } + + return h('div', tokens.map((tokenData) => { + tokenData.image = assetImages[tokenData.address] + return h(TokenCell, tokenData) + })) + +} + +TokenList.prototype.message = function (body) { + return h('div', { + style: { + display: 'flex', + height: '250px', + alignItems: 'center', + justifyContent: 'center', + padding: '30px', + }, + }, body) +} + +TokenList.prototype.componentDidMount = function () { + this.createFreshTokenTracker() +} + +TokenList.prototype.createFreshTokenTracker = function () { + if (this.tracker) { + // Clean up old trackers when refreshing: + this.tracker.stop() + this.tracker.removeListener('update', this.balanceUpdater) + this.tracker.removeListener('error', this.showError) + } + + if (!global.ethereumProvider) return + const { userAddress } = this.props + + this.tracker = new TokenTracker({ + userAddress, + provider: global.ethereumProvider, + tokens: this.props.tokens, + pollingInterval: 8000, + }) + + + // Set up listener instances for cleaning up + this.balanceUpdater = this.updateBalances.bind(this) + this.showError = (error) => { + this.setState({ error, isLoading: false }) + } + this.tracker.on('update', this.balanceUpdater) + this.tracker.on('error', this.showError) + + this.tracker.updateBalances() + .then(() => { + this.updateBalances(this.tracker.serialize()) + }) + .catch((reason) => { + log.error(`Problem updating balances`, reason) + this.setState({ isLoading: false }) + }) +} + +TokenList.prototype.componentDidUpdate = function (prevProps) { + const { + network: oldNet, + userAddress: oldAddress, + tokens, + } = prevProps + const { + network: newNet, + userAddress: newAddress, + tokens: newTokens, + } = this.props + + const isLoading = newNet === 'loading' + const missingInfo = !oldNet || !newNet || !oldAddress || !newAddress + const sameUserAndNetwork = oldAddress === newAddress && oldNet === newNet + const shouldUpdateTokens = isLoading || missingInfo || sameUserAndNetwork + + const oldTokensLength = tokens ? tokens.length : 0 + const tokensLengthUnchanged = oldTokensLength === newTokens.length + + if (tokensLengthUnchanged && shouldUpdateTokens) return + + this.setState({ isLoading: true }) + this.createFreshTokenTracker() +} + +TokenList.prototype.updateBalances = function (tokens) { + if (!this.tracker.running) { + return + } + this.setState({ tokens, isLoading: false }) +} + +TokenList.prototype.componentWillUnmount = function () { + if (!this.tracker) return + this.tracker.stop() + this.tracker.removeListener('update', this.balanceUpdater) + this.tracker.removeListener('error', this.showError) +} + +// function uniqueMergeTokens (tokensA, tokensB = []) { +// const uniqueAddresses = [] +// const result = [] +// tokensA.concat(tokensB).forEach((token) => { +// const normal = normalizeAddress(token.address) +// if (!uniqueAddresses.includes(normal)) { +// uniqueAddresses.push(normal) +// result.push(token) +// } +// }) +// return result +// } diff --git a/ui/app/components/transaction-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/app/transaction-action/transaction-action.component.js b/ui/app/components/app/transaction-action/transaction-action.component.js new file mode 100644 index 000000000..4a5efdaae --- /dev/null +++ b/ui/app/components/app/transaction-action/transaction-action.component.js @@ -0,0 +1,58 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { getTransactionActionKey } from '../../../helpers/utils/transactions.util' +import { camelCaseToCapitalize } from '../../../helpers/utils/common.util' + +export default class TransactionAction extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + className: PropTypes.string, + transaction: PropTypes.object, + methodData: PropTypes.object, + } + + state = { + transactionAction: '', + } + + componentDidMount () { + this.getTransactionAction() + } + + componentDidUpdate () { + this.getTransactionAction() + } + + async getTransactionAction () { + const { transactionAction } = this.state + const { transaction, methodData } = this.props + const { data, done } = methodData + const { name = '' } = data + + if (!done || transactionAction) { + return + } + + const actionKey = await getTransactionActionKey(transaction, data) + const action = actionKey + ? this.context.t(actionKey) + : camelCaseToCapitalize(name) + + this.setState({ transactionAction: action }) + } + + render () { + const { className, methodData: { done } } = this.props + const { transactionAction } = this.state + + return ( + <div className={classnames('transaction-action', className)}> + { (done && transactionAction) || '--' } + </div> + ) + } +} 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 00c17e6aa..00c17e6aa 100644 --- a/ui/app/components/transaction-activity-log/index.scss +++ b/ui/app/components/app/transaction-activity-log/index.scss diff --git a/ui/app/components/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 index a2946e53d..a2946e53d 100644 --- a/ui/app/components/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 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/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 index d014b8886..d014b8886 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/index.js index 86b12360a..86b12360a 100644 --- a/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log-icon/index.js diff --git a/ui/app/components/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 index 871716002..871716002 100644 --- a/ui/app/components/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 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/transaction-activity-log/transaction-activity-log.constants.js b/ui/app/components/app/transaction-activity-log/transaction-activity-log.constants.js index 72e63d85c..72e63d85c 100644 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.constants.js +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log.constants.js 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..ac5a00c31 --- /dev/null +++ b/ui/app/components/app/transaction-activity-log/transaction-activity-log.util.js @@ -0,0 +1,224 @@ +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 = [], txReceipt: { 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 = 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/app/transaction-breakdown/index.scss b/ui/app/components/app/transaction-breakdown/index.scss new file mode 100644 index 000000000..c8144eac2 --- /dev/null +++ b/ui/app/components/app/transaction-breakdown/index.scss @@ -0,0 +1,24 @@ +@import 'transaction-breakdown-row/index'; + +.transaction-breakdown { + &__title { + border-bottom: 1px solid #d8d8d8; + padding-bottom: 4px; + text-transform: capitalize; + } + + &__row-title { + text-transform: capitalize; + } + + &__value { + text-align: end; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &--eth-total { + font-weight: 500; + } + } +} 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 4512b84f0..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 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/app/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 new file mode 100644 index 000000000..82e40fce2 --- /dev/null +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js @@ -0,0 +1,39 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionBreakdownRow from '../transaction-breakdown-row.component' +import Button from '../../../../ui/button' + +describe('TransactionBreakdownRow Component', () => { + it('should render text properly', () => { + const wrapper = shallow( + <TransactionBreakdownRow + title="test" + className="test-class" + > + Test + </TransactionBreakdownRow>, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown-row')) + assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') + assert.equal(wrapper.find('.transaction-breakdown-row__value').text(), 'Test') + }) + + it('should render components properly', () => { + const wrapper = shallow( + <TransactionBreakdownRow + title="test" + className="test-class" + > + <Button onClick={() => {}} >Button</Button> + </TransactionBreakdownRow>, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown-row')) + assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') + assert.ok(wrapper.find('.transaction-breakdown-row__value').find(Button)) + }) +}) 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 7cb253e4e..7cb253e4e 100644 --- a/ui/app/components/transaction-list-item-details/index.scss +++ b/ui/app/components/app/transaction-list-item-details/index.scss diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..c4e118b01 --- /dev/null +++ b/ui/app/components/app/transaction-list-item-details/tests/transaction-list-item-details.component.test.js @@ -0,0 +1,81 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionListItemDetails from '../transaction-list-item-details.component' +import Button from '../../../ui/button' +import SenderToRecipient from '../../../ui/sender-to-recipient' +import TransactionBreakdown from '../../transaction-breakdown' +import TransactionActivityLog from '../../transaction-activity-log' + +describe('TransactionListItemDetails 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 transactionGroup = { + transactions: [transaction], + primaryTransaction: transaction, + initialTransaction: transaction, + } + + const wrapper = shallow( + <TransactionListItemDetails + transactionGroup={transactionGroup} + />, + { 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(SenderToRecipient).length, 1) + assert.equal(wrapper.find(TransactionBreakdown).length, 1) + assert.equal(wrapper.find(TransactionActivityLog).length, 1) + }) + + it('should render a retry button', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const transactionGroup = { + transactions: [transaction], + primaryTransaction: transaction, + initialTransaction: transaction, + nonce: '0xa4', + hasRetried: false, + hasCancelled: false, + } + + const wrapper = shallow( + <TransactionListItemDetails + 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, 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..b08e0bbe3 --- /dev/null +++ b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -0,0 +1,181 @@ +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, + transactionGroup: PropTypes.object, + } + + state = { + justCopied: 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) + }) + } + + render () { + const { t } = this.context + const { justCopied } = this.state + const { transactionGroup, showCancel, 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> + ) + } + { + showCancel && ( + <Button + type="raised" + onClick={this.handleCancel} + className="transaction-list-item-details__header-button" + > + { t('cancel') } + </Button> + ) + } + <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 9e73a546c..9e73a546c 100644 --- a/ui/app/components/transaction-list-item/index.scss +++ b/ui/app/components/app/transaction-list-item/index.scss diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js new file mode 100644 index 000000000..c0a911cda --- /dev/null +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js @@ -0,0 +1,224 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +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 '../../ui/token-currency-display' +import TransactionListItemDetails from '../transaction-list-item-details' +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 = { + assetImages: PropTypes.object, + history: PropTypes.object, + methodData: PropTypes.object, + nonceAndDate: PropTypes.string, + primaryTransaction: PropTypes.object, + retryTransaction: PropTypes.func, + setSelectedToken: PropTypes.func, + showCancelModal: PropTypes.func, + showCancel: 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 = { + showTransactionDetails: false, + } + + handleClick = () => { + const { + transaction, + history, + } = this.props + const { id, status } = transaction + const { showTransactionDetails } = this.state + + if (status === UNAPPROVED_STATUS) { + history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`) + return + } + + if (!showTransactionDetails) { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Expand Transaction', + }, + }) + } + + this.setState({ showTransactionDetails: !showTransactionDetails }) + } + + handleCancel = id => { + const { + primaryTransaction: { txParams: { gasPrice } } = {}, + transaction: { id: initialTransactionId }, + showCancelModal, + } = this.props + + const cancelId = id || initialTransactionId + showCancelModal(cancelId, gasPrice) + } + + /** + * @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 { + primaryTransaction: { txParams: { gasPrice } } = {}, + transaction: { txParams: { to } = {}, id: initialTransactionId }, + methodData: { name } = {}, + setSelectedToken, + retryTransaction, + fetchBasicGasAndTimeEstimates, + fetchGasEstimates, + } = this.props + + if (name === TOKEN_METHOD_TRANSFER) { + setSelectedToken(to) + } + + const retryId = id || initialTransactionId + + return fetchBasicGasAndTimeEstimates() + .then(basicEstimates => fetchGasEstimates(basicEstimates.blockTime)) + .then(retryTransaction(retryId, gasPrice)) + } + + renderPrimaryCurrency () { + const { token, primaryTransaction: { txParams: { data } = {} } = {}, value } = this.props + + return token + ? ( + <TokenCurrencyDisplay + className="transaction-list-item__amount transaction-list-item__amount--primary" + token={token} + transactionData={data} + prefix="-" + /> + ) : ( + <UserPreferencedCurrencyDisplay + className="transaction-list-item__amount transaction-list-item__amount--primary" + value={value} + type={PRIMARY} + prefix="-" + /> + ) + } + + renderSecondaryCurrency () { + const { token, value, showFiat } = this.props + + return token || !showFiat + ? null + : ( + <UserPreferencedCurrencyDisplay + className="transaction-list-item__amount transaction-list-item__amount--secondary" + value={value} + prefix="-" + type={SECONDARY} + /> + ) + } + + render () { + const { + assetImages, + transaction, + methodData, + nonceAndDate, + primaryTransaction, + showCancel, + showRetry, + tokenData, + transactionGroup, + } = this.props + const { txParams = {} } = transaction + const { showTransactionDetails } = this.state + const toAddress = tokenData + ? tokenData.params && tokenData.params[0] && tokenData.params[0].value || txParams.to + : txParams.to + + return ( + <div className="transaction-list-item"> + <div + className="transaction-list-item__grid" + onClick={this.handleClick} + > + <Identicon + className="transaction-list-item__identicon" + address={toAddress} + diameter={34} + image={assetImages[toAddress]} + /> + <TransactionAction + transaction={transaction} + methodData={methodData} + className="transaction-list-item__action" + /> + <div + className="transaction-list-item__nonce" + title={nonceAndDate} + > + { nonceAndDate } + </div> + <TransactionStatus + className="transaction-list-item__status" + statusKey={getStatusKey(primaryTransaction)} + title={( + (primaryTransaction.err && primaryTransaction.err.rpc) + ? primaryTransaction.err.rpc.message + : primaryTransaction.err && primaryTransaction.err.message + )} + /> + { this.renderPrimaryCurrency() } + { this.renderSecondaryCurrency() } + </div> + <div className={classnames('transaction-list-item__expander', { + 'transaction-list-item__expander--show': showTransactionDetails, + })}> + { + showTransactionDetails && ( + <div className="transaction-list-item__details-container"> + <TransactionListItemDetails + transactionGroup={transactionGroup} + onRetry={this.handleRetry} + showRetry={showRetry && methodData.done} + onCancel={this.handleCancel} + showCancel={showCancel} + /> + </div> + ) + } + </div> + </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..499558a9b --- /dev/null +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.container.js @@ -0,0 +1,82 @@ +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 { 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} from '../../../selectors/selectors' + +const mapStateToProps = state => { + const { metamask: { knownMethodData } } = state + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + + return { + knownMethodData, + showFiat: (isMainnet || !!showFiatInTestnets), + } +} + +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 a486f4112..a486f4112 100644 --- a/ui/app/components/transaction-list/index.scss +++ b/ui/app/components/app/transaction-list/index.scss diff --git a/ui/app/components/app/transaction-list/transaction-list.component.js b/ui/app/components/app/transaction-list/transaction-list.component.js new file mode 100644 index 000000000..fc5488884 --- /dev/null +++ b/ui/app/components/app/transaction-list/transaction-list.component.js @@ -0,0 +1,126 @@ +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 '../../../helpers/constants/transactions' + +export default class TransactionList extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static defaultProps = { + pendingTransactions: [], + completedTransactions: [], + } + + static propTypes = { + pendingTransactions: PropTypes.array, + completedTransactions: PropTypes.array, + selectedToken: PropTypes.object, + updateNetworkNonce: PropTypes.func, + assetImages: PropTypes.object, + } + + componentDidMount () { + this.props.updateNetworkNonce() + } + + componentDidUpdate (prevProps) { + const { pendingTransactions: prevPendingTransactions = [] } = prevProps + const { pendingTransactions = [], updateNetworkNonce } = this.props + + if (pendingTransactions.length > prevPendingTransactions.length) { + updateNetworkNonce() + } + } + + 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"> + { + pendingLength > 0 && ( + <div className="transaction-list__pending-transactions"> + <div className="transaction-list__header"> + { `${t('queue')} (${pendingTransactions.length})` } + </div> + { + pendingTransactions.map((transactionGroup, index) => ( + this.renderTransaction(transactionGroup, index, true) + )) + } + </div> + ) + } + <div className="transaction-list__completed-transactions"> + <div className="transaction-list__header"> + { t('history') } + </div> + { + completedTransactions.length > 0 + ? completedTransactions.map((transactionGroup, index) => ( + this.renderTransaction(transactionGroup, index) + )) + : this.renderEmpty() + } + </div> + </div> + ) + } + + renderTransaction (transactionGroup, index, isPendingTx = false) { + const { selectedToken, assetImages } = this.props + const { transactions = [] } = transactionGroup + + return transactions[0].key === TRANSACTION_TYPE_SHAPESHIFT + ? ( + <ShapeShiftTransactionListItem + { ...transactions[0] } + key={`shapeshift${index}`} + /> + ) : ( + <TransactionListItem + transactionGroup={transactionGroup} + key={`${transactionGroup.nonce}:${index}`} + showRetry={isPendingTx && this.shouldShowRetry(transactionGroup, index === 0)} + showCancel={isPendingTx && this.shouldShowCancel(transactionGroup)} + token={selectedToken} + assetImages={assetImages} + /> + ) + } + + renderEmpty () { + return ( + <div className="transaction-list__empty"> + <div className="transaction-list__empty-text"> + { this.context.t('noTransactions') } + </div> + </div> + ) + } + + render () { + return ( + <div className="transaction-list"> + { this.renderTransactions() } + </div> + ) + } +} diff --git a/ui/app/components/app/transaction-list/transaction-list.container.js b/ui/app/components/app/transaction-list/transaction-list.container.js new file mode 100644 index 000000000..67a24588b --- /dev/null +++ b/ui/app/components/app/transaction-list/transaction-list.container.js @@ -0,0 +1,44 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import TransactionList from './transaction-list.component' +import { + nonceSortedCompletedTransactionsSelector, + nonceSortedPendingTransactionsSelector, +} from '../../../selectors/transactions' +import { getSelectedAddress, getAssetImages } from '../../../selectors/selectors' +import { selectedTokenSelector } from '../../../selectors/tokens' +import { updateNetworkNonce } from '../../../store/actions' + +const mapStateToProps = state => { + return { + completedTransactions: nonceSortedCompletedTransactionsSelector(state), + pendingTransactions: nonceSortedPendingTransactionsSelector(state), + selectedToken: selectedTokenSelector(state), + selectedAddress: getSelectedAddress(state), + assetImages: getAssetImages(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + updateNetworkNonce: address => dispatch(updateNetworkNonce(address)), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { selectedAddress, ...restStateProps } = stateProps + const { updateNetworkNonce, ...restDispatchProps } = dispatchProps + + return { + ...restStateProps, + ...restDispatchProps, + ...ownProps, + updateNetworkNonce: () => updateNetworkNonce(selectedAddress), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(TransactionList) 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 e7daafeef..e7daafeef 100644 --- a/ui/app/components/transaction-status/index.scss +++ b/ui/app/components/app/transaction-status/index.scss 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/app/transaction-status/transaction-status.component.js b/ui/app/components/app/transaction-status/transaction-status.component.js new file mode 100644 index 000000000..d3a239539 --- /dev/null +++ b/ui/app/components/app/transaction-status/transaction-status.component.js @@ -0,0 +1,63 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Tooltip from '../../ui/tooltip-v2' +import { + UNAPPROVED_STATUS, + REJECTED_STATUS, + APPROVED_STATUS, + SIGNED_STATUS, + SUBMITTED_STATUS, + CONFIRMED_STATUS, + FAILED_STATUS, + DROPPED_STATUS, + CANCELLED_STATUS, +} from '../../../helpers/constants/transactions' + +const statusToClassNameHash = { + [UNAPPROVED_STATUS]: 'transaction-status--unapproved', + [REJECTED_STATUS]: 'transaction-status--rejected', + [APPROVED_STATUS]: 'transaction-status--approved', + [SIGNED_STATUS]: 'transaction-status--signed', + [SUBMITTED_STATUS]: 'transaction-status--submitted', + [CONFIRMED_STATUS]: 'transaction-status--confirmed', + [FAILED_STATUS]: 'transaction-status--failed', + [DROPPED_STATUS]: 'transaction-status--dropped', + [CANCELLED_STATUS]: 'transaction-status--failed', +} + +const statusToTextHash = { + [SUBMITTED_STATUS]: 'pending', +} + +export default class TransactionStatus extends PureComponent { + static defaultProps = { + title: null, + } + + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + statusKey: PropTypes.string, + className: PropTypes.string, + title: PropTypes.string, + } + + render () { + const { className, statusKey, title } = this.props + const statusText = this.context.t(statusToTextHash[statusKey] || statusKey) + + return ( + <div className={classnames('transaction-status', className, statusToClassNameHash[statusKey])}> + <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 bdcd536b0..bdcd536b0 100644 --- a/ui/app/components/transaction-view-balance/index.scss +++ b/ui/app/components/app/transaction-view-balance/index.scss diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..0e2882e9c --- /dev/null +++ b/ui/app/components/app/transaction-view-balance/tests/token-view-balance.component.test.js @@ -0,0 +1,72 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import TokenBalance from '../../../ui/token-balance' +import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' +import { SEND_ROUTE } from '../../../../helpers/constants/routes' +import TransactionViewBalance from '../transaction-view-balance.component' + +const propsMethodSpies = { + showDepositModal: sinon.spy(), +} + +const historySpies = { + push: sinon.spy(), +} + +const t = (str1, str2) => str2 ? str1 + str2 : str1 +const metricsEvent = () => ({}) + +describe('TransactionViewBalance Component', () => { + afterEach(() => { + propsMethodSpies.showDepositModal.resetHistory() + historySpies.push.resetHistory() + }) + + it('should render ETH balance properly', () => { + const wrapper = shallow(<TransactionViewBalance + showDepositModal={propsMethodSpies.showDepositModal} + history={historySpies} + network="3" + ethBalance={123} + fiatBalance={456} + currentCurrency="usd" + />, { context: { t, metricsEvent } }) + + assert.equal(wrapper.find('.transaction-view-balance').length, 1) + assert.equal(wrapper.find('.transaction-view-balance__button').length, 2) + assert.equal(wrapper.find(UserPreferencedCurrencyDisplay).length, 2) + + const buttons = wrapper.find('.transaction-view-balance__buttons') + assert.equal(propsMethodSpies.showDepositModal.callCount, 0) + buttons.childAt(0).simulate('click') + assert.equal(propsMethodSpies.showDepositModal.callCount, 1) + assert.equal(historySpies.push.callCount, 0) + buttons.childAt(1).simulate('click') + assert.equal(historySpies.push.callCount, 1) + assert.equal(historySpies.push.getCall(0).args[0], SEND_ROUTE) + }) + + it('should render token balance properly', () => { + const token = { + address: '0x35865238f0bec9d5ce6abff0fdaebe7b853dfcc5', + decimals: '2', + symbol: 'ABC', + } + + const wrapper = shallow(<TransactionViewBalance + showDepositModal={propsMethodSpies.showDepositModal} + history={historySpies} + network="3" + ethBalance={123} + fiatBalance={456} + currentCurrency="usd" + selectedToken={token} + />, { context: { t } }) + + assert.equal(wrapper.find('.transaction-view-balance').length, 1) + assert.equal(wrapper.find('.transaction-view-balance__button').length, 1) + assert.equal(wrapper.find(TokenBalance).length, 1) + }) +}) 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/app/transaction-view-balance/transaction-view-balance.container.js b/ui/app/components/app/transaction-view-balance/transaction-view-balance.container.js new file mode 100644 index 000000000..41a4525dc --- /dev/null +++ b/ui/app/components/app/transaction-view-balance/transaction-view-balance.container.js @@ -0,0 +1,46 @@ +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, + 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 } } = state + const accounts = getMetaMaskAccounts(state) + const account = accounts[selectedAddress] + const { balance } = account + + return { + selectedToken: getSelectedToken(state), + network, + balance, + nativeCurrency: getNativeCurrency(state), + assetImage: getSelectedTokenAssetImage(state), + balanceIsCached: isBalanceCached(state), + showFiat: (isMainnet || !!showFiatInTestnets), + } +} + +const mapDispatchToProps = dispatch => { + return { + showDepositModal: () => dispatch(showModal({ name: 'DEPOSIT_ETHER' })), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(TransactionViewBalance) 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/ui-migration-annoucement/index.js b/ui/app/components/app/ui-migration-annoucement/index.js index c6c8cc619..c6c8cc619 100644 --- a/ui/app/components/ui-migration-annoucement/index.js +++ b/ui/app/components/app/ui-migration-annoucement/index.js diff --git a/ui/app/components/ui-migration-annoucement/index.scss b/ui/app/components/app/ui-migration-annoucement/index.scss index 6138a3079..6138a3079 100644 --- a/ui/app/components/ui-migration-annoucement/index.scss +++ b/ui/app/components/app/ui-migration-annoucement/index.scss diff --git a/ui/app/components/ui-migration-annoucement/ui-migration-annoucement.component.js b/ui/app/components/app/ui-migration-annoucement/ui-migration-annoucement.component.js index 7a4124972..7a4124972 100644 --- a/ui/app/components/ui-migration-annoucement/ui-migration-annoucement.component.js +++ b/ui/app/components/app/ui-migration-annoucement/ui-migration-annoucement.component.js 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/app/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 new file mode 100644 index 000000000..51b2a3c4f --- /dev/null +++ b/ui/app/components/app/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js @@ -0,0 +1,34 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display.component' +import CurrencyDisplay from '../../../ui/currency-display' + +describe('UserPreferencedCurrencyDisplay Component', () => { + describe('rendering', () => { + it('should render properly', () => { + const wrapper = shallow( + <UserPreferencedCurrencyDisplay /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(CurrencyDisplay).length, 1) + }) + + it('should pass all props to the CurrencyDisplay child component', () => { + const wrapper = shallow( + <UserPreferencedCurrencyDisplay + prop1={true} + prop2="test" + prop3={1} + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(CurrencyDisplay).length, 1) + assert.equal(wrapper.find(CurrencyDisplay).props().prop1, true) + assert.equal(wrapper.find(CurrencyDisplay).props().prop2, 'test') + assert.equal(wrapper.find(CurrencyDisplay).props().prop3, 1) + }) + }) +}) 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 88d63baae..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 diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..4b64b26c0 --- /dev/null +++ b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js @@ -0,0 +1,47 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { PRIMARY, SECONDARY, ETH } from '../../../helpers/constants/common' +import CurrencyDisplay from '../../ui/currency-display' + +export default class UserPreferencedCurrencyDisplay extends PureComponent { + static propTypes = { + className: PropTypes.string, + prefix: PropTypes.string, + 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]), + // Used in container + type: PropTypes.oneOf([PRIMARY, SECONDARY]), + ethNumberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + fiatNumberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + ethPrefix: PropTypes.string, + fiatPrefix: PropTypes.string, + // From container + currency: PropTypes.string, + nativeCurrency: PropTypes.string, + } + + renderEthLogo () { + const { currency, showEthLogo, ethLogoHeight = 12 } = this.props + + return currency === ETH && showEthLogo && ( + <img + src="/images/eth.svg" + height={ethLogoHeight} + /> + ) + } + + render () { + return ( + <CurrencyDisplay + {...this.props} + prefixComponent={this.renderEthLogo()} + /> + ) + } +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..42d156f92 --- /dev/null +++ b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.container.js @@ -0,0 +1,67 @@ +import { connect } from 'react-redux' +import UserPreferencedCurrencyDisplay from './user-preferenced-currency-display.component' +import { preferencesSelector, getIsMainnet } from '../../../selectors/selectors' +import { ETH, PRIMARY, SECONDARY } from '../../../helpers/constants/common' + +const mapStateToProps = (state, ownProps) => { + const { + useNativeCurrencyAsPrimaryCurrency, + showFiatInTestnets, + } = preferencesSelector(state) + + const isMainnet = getIsMainnet(state) + + return { + useNativeCurrencyAsPrimaryCurrency, + showFiatInTestnets, + isMainnet, + nativeCurrency: state.metamask.nativeCurrency, + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { useNativeCurrencyAsPrimaryCurrency, showFiatInTestnets, isMainnet, nativeCurrency, ...restStateProps } = stateProps + const { + type, + numberOfDecimals: propsNumberOfDecimals, + ethNumberOfDecimals, + fiatNumberOfDecimals, + ethPrefix, + fiatPrefix, + prefix: propsPrefix, + ...restOwnProps + } = ownProps + + let currency, numberOfDecimals, prefix + + if (type === PRIMARY && useNativeCurrencyAsPrimaryCurrency || + type === SECONDARY && !useNativeCurrencyAsPrimaryCurrency) { + // Display ETH + currency = nativeCurrency || ETH + numberOfDecimals = propsNumberOfDecimals || ethNumberOfDecimals || 6 + prefix = propsPrefix || ethPrefix + } else if (type === SECONDARY && useNativeCurrencyAsPrimaryCurrency || + type === PRIMARY && !useNativeCurrencyAsPrimaryCurrency) { + // Display Fiat + numberOfDecimals = propsNumberOfDecimals || fiatNumberOfDecimals || 2 + prefix = propsPrefix || fiatPrefix + } + + if (!isMainnet && !showFiatInTestnets) { + currency = nativeCurrency || ETH + numberOfDecimals = propsNumberOfDecimals || ethNumberOfDecimals || 6 + prefix = propsPrefix || ethPrefix + } + + return { + ...restStateProps, + ...dispatchProps, + ...restOwnProps, + nativeCurrency, + currency, + numberOfDecimals, + prefix, + } +} + +export default connect(mapStateToProps, null, mergeProps)(UserPreferencedCurrencyDisplay) 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/app/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 new file mode 100644 index 000000000..3802e16f3 --- /dev/null +++ b/ui/app/components/app/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js @@ -0,0 +1,32 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import UserPreferencedCurrencyInput from '../user-preferenced-currency-input.component' +import CurrencyInput from '../../../ui/currency-input' + +describe('UserPreferencedCurrencyInput Component', () => { + describe('rendering', () => { + it('should render properly', () => { + const wrapper = shallow( + <UserPreferencedCurrencyInput /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(CurrencyInput).length, 1) + }) + + it('should render useFiat for CurrencyInput based on preferences.useNativeCurrencyAsPrimaryCurrency', () => { + const wrapper = shallow( + <UserPreferencedCurrencyInput + useNativeCurrencyAsPrimaryCurrency + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(CurrencyInput).length, 1) + assert.equal(wrapper.find(CurrencyInput).props().useFiat, false) + wrapper.setProps({ useNativeCurrencyAsPrimaryCurrency: false }) + assert.equal(wrapper.find(CurrencyInput).props().useFiat, true) + }) + }) +}) 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/app/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 new file mode 100644 index 000000000..7c0ec1734 --- /dev/null +++ b/ui/app/components/app/user-preferenced-currency-input/user-preferenced-currency-input.component.js @@ -0,0 +1,20 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import CurrencyInput from '../../ui/currency-input' + +export default class UserPreferencedCurrencyInput extends PureComponent { + static propTypes = { + useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, + } + + render () { + const { useNativeCurrencyAsPrimaryCurrency, ...restProps } = this.props + + return ( + <CurrencyInput + {...restProps} + useFiat={!useNativeCurrencyAsPrimaryCurrency} + /> + ) + } +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..72f17fde4 --- /dev/null +++ b/ui/app/components/app/user-preferenced-currency-input/user-preferenced-currency-input.container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux' +import UserPreferencedCurrencyInput from './user-preferenced-currency-input.component' +import { preferencesSelector } from '../../../selectors/selectors' + +const mapStateToProps = state => { + const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) + + return { + useNativeCurrencyAsPrimaryCurrency, + } +} + +export default connect(mapStateToProps)(UserPreferencedCurrencyInput) 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/app/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 new file mode 100644 index 000000000..41cfd51f9 --- /dev/null +++ b/ui/app/components/app/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js @@ -0,0 +1,32 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import UserPreferencedTokenInput from '../user-preferenced-token-input.component' +import TokenInput from '../../../ui/token-input' + +describe('UserPreferencedCurrencyInput Component', () => { + describe('rendering', () => { + it('should render properly', () => { + const wrapper = shallow( + <UserPreferencedTokenInput /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(TokenInput).length, 1) + }) + + it('should render showFiat for TokenInput based on preferences.useNativeCurrencyAsPrimaryCurrency', () => { + const wrapper = shallow( + <UserPreferencedTokenInput + useNativeCurrencyAsPrimaryCurrency + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(TokenInput).length, 1) + assert.equal(wrapper.find(TokenInput).props().showFiat, false) + wrapper.setProps({ useNativeCurrencyAsPrimaryCurrency: false }) + assert.equal(wrapper.find(TokenInput).props().showFiat, true) + }) + }) +}) 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/app/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 new file mode 100644 index 000000000..24133188d --- /dev/null +++ b/ui/app/components/app/user-preferenced-token-input/user-preferenced-token-input.component.js @@ -0,0 +1,20 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import TokenInput from '../../ui/token-input' + +export default class UserPreferencedTokenInput extends PureComponent { + static propTypes = { + useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, + } + + render () { + const { useNativeCurrencyAsPrimaryCurrency, ...restProps } = this.props + + return ( + <TokenInput + {...restProps} + showFiat={!useNativeCurrencyAsPrimaryCurrency} + /> + ) + } +} diff --git a/ui/app/components/app/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 new file mode 100644 index 000000000..4a20b20d9 --- /dev/null +++ b/ui/app/components/app/user-preferenced-token-input/user-preferenced-token-input.container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux' +import UserPreferencedTokenInput from './user-preferenced-token-input.component' +import { preferencesSelector } from '../../../selectors/selectors' + +const mapStateToProps = state => { + const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) + + return { + useNativeCurrencyAsPrimaryCurrency, + } +} + +export default connect(mapStateToProps)(UserPreferencedTokenInput) diff --git a/ui/app/components/app/wallet-view.js b/ui/app/components/app/wallet-view.js new file mode 100644 index 000000000..cec8228b1 --- /dev/null +++ b/ui/app/components/app/wallet-view.js @@ -0,0 +1,246 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const { withRouter } = require('react-router-dom') +const { compose } = require('recompose') +const inherits = require('util').inherits +const classnames = require('classnames') +const { checksumAddress } = require('../../helpers/utils/util') +import Identicon from '../ui/identicon' +// const AccountDropdowns = require('./dropdowns/index.js').AccountDropdowns +const Tooltip = require('../ui/tooltip-v2.js').default +const copyToClipboard = require('copy-to-clipboard') +const actions = require('../../store/actions') +import BalanceComponent from '../ui/balance' +const TokenList = require('./token-list') +const selectors = require('../../selectors/selectors') +const { ADD_TOKEN_ROUTE } = require('../../helpers/constants/routes') + +import AddTokenButton from './add-token-button' + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(WalletView) + +WalletView.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +WalletView.defaultProps = { + responsiveDisplayClassname: '', +} + +function mapStateToProps (state) { + + return { + network: state.metamask.network, + sidebarOpen: state.appState.sidebar.isOpen, + identities: state.metamask.identities, + accounts: selectors.getMetaMaskAccounts(state), + keyrings: state.metamask.keyrings, + selectedAddress: selectors.getSelectedAddress(state), + selectedAccount: selectors.getSelectedAccount(state), + selectedTokenAddress: state.metamask.selectedTokenAddress, + } +} + +function mapDispatchToProps (dispatch) { + return { + showSendPage: () => dispatch(actions.showSendPage()), + hideSidebar: () => dispatch(actions.hideSidebar()), + unsetSelectedToken: () => dispatch(actions.setSelectedToken()), + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + showAddTokenPage: () => dispatch(actions.showAddTokenPage()), + } +} + +inherits(WalletView, Component) +function WalletView () { + Component.call(this) + this.state = { + hasCopied: false, + copyToClipboardPressed: false, + } +} + +WalletView.prototype.renderWalletBalance = function () { + const { + selectedTokenAddress, + selectedAccount, + unsetSelectedToken, + hideSidebar, + sidebarOpen, + } = this.props + + const selectedClass = selectedTokenAddress + ? '' + : 'wallet-balance-wrapper--active' + const className = `flex-column wallet-balance-wrapper ${selectedClass}` + + return h('div', { className }, [ + h('div.wallet-balance', + { + onClick: () => { + unsetSelectedToken() + selectedTokenAddress && sidebarOpen && hideSidebar() + }, + }, + [ + h(BalanceComponent, { + balanceValue: selectedAccount ? selectedAccount.balance : '', + style: {}, + }), + ] + ), + ]) +} + +WalletView.prototype.renderAddToken = function () { + const { + sidebarOpen, + 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() + } + }, + }) +} + +WalletView.prototype.render = function () { + const { + responsiveDisplayClassname, + selectedAddress, + keyrings, + showAccountDetailModal, + hideSidebar, + identities, + network, + } = this.props + // temporary logs + fake extra wallets + + const checksummedAddress = checksumAddress(selectedAddress, network) + + if (!selectedAddress) { + throw new Error('selectedAddress should not be ' + String(selectedAddress)) + } + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(selectedAddress) + }) + + let label = '' + let type + if (keyring) { + type = keyring.type + if (type !== 'HD Key Tree') { + if (type.toLowerCase().search('hardware') !== -1) { + label = this.context.t('hardware') + } else { + label = this.context.t('imported') + } + } + } + + return h('div.wallet-view.flex-column', { + style: {}, + className: responsiveDisplayClassname, + }, [ + + // TODO: Separate component: wallet account details + h('div.flex-column.wallet-view-account-details', { + style: {}, + }, [ + h('div.wallet-view__sidebar-close', { + onClick: hideSidebar, + }), + + h('div.wallet-view__keyring-label.allcaps', label), + + h('div.flex-column.flex-center.wallet-view__name-container', { + style: { margin: '0 auto' }, + onClick: showAccountDetailModal, + }, [ + h(Identicon, { + diameter: 54, + address: checksummedAddress, + }), + + h('span.account-name', { + style: {}, + }, [ + identities[selectedAddress].name, + ]), + + h('button.btn-clear.wallet-view__details-button.allcaps', this.context.t('details')), + ]), + ]), + + h(Tooltip, { + position: 'bottom', + title: this.state.hasCopied ? this.context.t('copiedExclamation') : this.context.t('copyToClipboard'), + wrapperClassName: 'wallet-view__tooltip', + }, [ + h('button.wallet-view__address', { + className: classnames({ + 'wallet-view__address__pressed': this.state.copyToClipboardPressed, + }), + onClick: () => { + copyToClipboard(checksummedAddress) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Copied Address', + }, + }) + this.setState({ hasCopied: true }) + setTimeout(() => this.setState({ hasCopied: false }), 3000) + }, + onMouseDown: () => { + this.setState({ copyToClipboardPressed: true }) + }, + onMouseUp: () => { + this.setState({ copyToClipboardPressed: false }) + }, + }, [ + `${checksummedAddress.slice(0, 6)}...${checksummedAddress.slice(-4)}`, + h('i.fa.fa-clipboard', { style: { marginLeft: '8px' } }), + ]), + ]), + + this.renderWalletBalance(), + + h(TokenList), + + this.renderAddToken(), + ]) +} + +// TODO: Extra wallets, for dev testing. Remove when PRing to master. +// const extraWallet = h('div.flex-column.wallet-balance-wrapper', {}, [ +// h('div.wallet-balance', {}, [ +// h(BalanceComponent, { +// balanceValue: selectedAccount.balance, +// style: {}, +// }), +// ]), +// ]) diff --git a/ui/app/components/balance/balance.component.js b/ui/app/components/balance/balance.component.js deleted file mode 100644 index 9d0018add..000000000 --- a/ui/app/components/balance/balance.component.js +++ /dev/null @@ -1,92 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import TokenBalance from '../token-balance' -import Identicon from '../identicon' -import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../constants/common' -import { formatBalance } from '../../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/balance/balance.container.js b/ui/app/components/balance/balance.container.js deleted file mode 100644 index 1cd6df5ce..000000000 --- a/ui/app/components/balance/balance.container.js +++ /dev/null @@ -1,32 +0,0 @@ -import { connect } from 'react-redux' -import Balance from './balance.component' -import { - getNativeCurrency, - getAssetImages, - conversionRateSelector, - getCurrentCurrency, - getMetaMaskAccounts, - getIsMainnet, - preferencesSelector, -} from '../../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/button-group/button-group.stories.js b/ui/app/components/button-group/button-group.stories.js deleted file mode 100644 index 14e1a7e49..000000000 --- a/ui/app/components/button-group/button-group.stories.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react' -import { storiesOf } from '@storybook/react' -import { action } from '@storybook/addon-actions' -import ButtonGroup from './' -import Button from '../button' -import { text, boolean } from '@storybook/addon-knobs/react' - -storiesOf('ButtonGroup', module) - .add('with Buttons', () => - <ButtonGroup - style={{ width: '300px' }} - disabled={boolean('Disabled', false)} - defaultActiveButtonIndex={1} - > - <Button - onClick={action('cheap')} - > - {text('Button1', 'Cheap')} - </Button> - <Button - onClick={action('average')} - > - {text('Button2', 'Average')} - </Button> - <Button - onClick={action('fast')} - > - {text('Button3', 'Fast')} - </Button> - </ButtonGroup> - ) - .add('with a disabled Button', () => - <ButtonGroup - style={{ width: '300px' }} - disabled={boolean('Disabled', false)} - > - <Button - onClick={action('enabled')} - > - {text('Button1', 'Enabled')} - </Button> - <Button - onClick={action('disabled')} - disabled - > - {text('Button2', 'Disabled')} - </Button> - </ButtonGroup> - ) diff --git a/ui/app/components/button/button.stories.js b/ui/app/components/button/button.stories.js deleted file mode 100644 index dec084a25..000000000 --- a/ui/app/components/button/button.stories.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import { storiesOf } from '@storybook/react' -import { action } from '@storybook/addon-actions' -import Button from './' -import { text } from '@storybook/addon-knobs/react' - -storiesOf('Button', module) - .add('primary', () => - <Button - onClick={action('clicked')} - type="primary" - > - {text('text', 'Click me')} - </Button> - ) - .add('secondary', () => - <Button - onClick={action('clicked')} - type="secondary" - > - {text('text', 'Click me')} - </Button> - ) - .add('default', () => ( - <Button - onClick={action('clicked')} - type="default" - > - {text('text', 'Click me')} - </Button> - )) - .add('large primary', () => ( - <Button - onClick={action('clicked')} - type="primary" - large - > - {text('text', 'Click me')} - </Button> - )) - .add('large secondary', () => ( - <Button - onClick={action('clicked')} - type="secondary" - large - > - {text('text', 'Click me')} - </Button> - )) - .add('large default', () => ( - <Button - onClick={action('clicked')} - type="default" - large - > - {text('text', 'Click me')} - </Button> - )) diff --git a/ui/app/components/coinbase-form.js b/ui/app/components/coinbase-form.js deleted file mode 100644 index d5915292e..000000000 --- a/ui/app/components/coinbase-form.js +++ /dev/null @@ -1,69 +0,0 @@ -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 -const actions = require('../actions') - -CoinbaseForm.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(CoinbaseForm) - - -function mapStateToProps (state) { - return { - warning: state.appState.warning, - } -} - -inherits(CoinbaseForm, Component) - -function CoinbaseForm () { - Component.call(this) -} - -CoinbaseForm.prototype.render = function () { - var props = this.props - - return h('.flex-column', { - style: { - marginTop: '35px', - padding: '25px', - width: '100%', - }, - }, [ - h('.flex-row', { - style: { - justifyContent: 'space-around', - margin: '33px', - marginTop: '0px', - }, - }, [ - h('button.btn-green', { - onClick: this.toCoinbase.bind(this), - }, this.context.t('continueToCoinbase')), - - h('button.btn-red', { - onClick: () => props.dispatch(actions.goHome()), - }, this.context.t('cancel')), - ]), - ]) -} - -CoinbaseForm.prototype.toCoinbase = function () { - const props = this.props - const address = props.buyView.buyAddress - props.dispatch(actions.buyEth({ network: '1', address, amount: 0 })) -} - -CoinbaseForm.prototype.renderLoading = function () { - return h('img', { - style: { - width: '27px', - marginRight: '-27px', - }, - src: 'images/loading.svg', - }) -} diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js b/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js deleted file mode 100644 index c7262d2a9..000000000 --- a/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js +++ /dev/null @@ -1,84 +0,0 @@ -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' - -const ConfirmDetailRow = props => { - const { - label, - primaryText, - secondaryText, - onHeaderClick, - primaryValueTextColor, - headerText, - headerTextClassName, - value, - } = props - - return ( - <div className="confirm-detail-row"> - <div className="confirm-detail-row__label"> - { label } - </div> - <div className="confirm-detail-row__details"> - <div - className={classnames('confirm-detail-row__header-text', headerTextClassName)} - onClick={() => onHeaderClick && onHeaderClick()} - > - { headerText } - </div> - { - primaryText - ? ( - <div - className="confirm-detail-row__primary" - style={{ color: primaryValueTextColor }} - > - { primaryText } - </div> - ) : ( - <UserPreferencedCurrencyDisplay - className="confirm-detail-row__primary" - type={PRIMARY} - value={value} - showEthLogo - ethLogoHeight="18" - style={{ color: primaryValueTextColor }} - hideLabel - /> - ) - } - { - secondaryText - ? ( - <div className="confirm-detail-row__secondary"> - { secondaryText } - </div> - ) : ( - <UserPreferencedCurrencyDisplay - className="confirm-detail-row__secondary" - type={SECONDARY} - value={value} - showEthLogo - hideLabel - /> - ) - } - </div> - </div> - ) -} - -ConfirmDetailRow.propTypes = { - headerText: PropTypes.string, - headerTextClassName: PropTypes.string, - label: PropTypes.string, - onHeaderClick: PropTypes.func, - primaryValueTextColor: PropTypes.string, - primaryText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - secondaryText: PropTypes.string, - value: PropTypes.string, -} - -export default ConfirmDetailRow diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js deleted file mode 100644 index 1dca81560..000000000 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js +++ /dev/null @@ -1,110 +0,0 @@ -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' - -export default class ConfirmPageContainerContent extends Component { - static propTypes = { - action: PropTypes.string, - dataComponent: PropTypes.node, - detailsComponent: PropTypes.node, - errorKey: PropTypes.string, - errorMessage: PropTypes.string, - hideSubtitle: PropTypes.bool, - identiconAddress: PropTypes.string, - nonce: PropTypes.string, - assetImage: PropTypes.string, - subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - subtitleComponent: PropTypes.node, - summaryComponent: PropTypes.node, - title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - titleComponent: PropTypes.node, - warning: PropTypes.string, - } - - renderContent () { - const { detailsComponent, dataComponent } = this.props - - if (detailsComponent && dataComponent) { - return this.renderTabs() - } else { - return detailsComponent || dataComponent - } - } - - renderTabs () { - const { detailsComponent, dataComponent } = this.props - - return ( - <Tabs> - <Tab name="Details"> - { detailsComponent } - </Tab> - <Tab name="Data"> - { dataComponent } - </Tab> - </Tabs> - ) - } - - render () { - const { - action, - errorKey, - errorMessage, - title, - titleComponent, - subtitle, - subtitleComponent, - hideSubtitle, - identiconAddress, - nonce, - assetImage, - summaryComponent, - detailsComponent, - dataComponent, - warning, - } = this.props - - return ( - <div className="confirm-page-container-content"> - { - warning && ( - <ConfirmPageContainerWarning warning={warning} /> - ) - } - { - summaryComponent || ( - <ConfirmPageContainerSummary - className={classnames({ - 'confirm-page-container-summary--border': !detailsComponent || !dataComponent, - })} - action={action} - title={title} - titleComponent={titleComponent} - subtitle={subtitle} - subtitleComponent={subtitleComponent} - hideSubtitle={hideSubtitle} - identiconAddress={identiconAddress} - nonce={nonce} - assetImage={assetImage} - /> - ) - } - { this.renderContent() } - { - (errorKey || errorMessage) && ( - <div className="confirm-page-container-content__error-container"> - <ErrorMessage - errorMessage={errorMessage} - errorKey={errorKey} - /> - </div> - ) - } - </div> - ) - } -} 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/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js deleted file mode 100644 index 89ceb015f..000000000 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import Identicon from '../../../identicon' - -const ConfirmPageContainerSummary = props => { - const { - action, - title, - titleComponent, - subtitle, - subtitleComponent, - hideSubtitle, - className, - identiconAddress, - nonce, - assetImage, - } = props - - return ( - <div className={classnames('confirm-page-container-summary', className)}> - <div className="confirm-page-container-summary__action-row"> - <div className="confirm-page-container-summary__action"> - { action } - </div> - { - nonce && ( - <div className="confirm-page-container-summary__nonce"> - { `#${nonce}` } - </div> - ) - } - </div> - <div className="confirm-page-container-summary__title"> - { - identiconAddress && ( - <Identicon - className="confirm-page-container-summary__identicon" - diameter={36} - address={identiconAddress} - image={assetImage} - /> - ) - } - <div className="confirm-page-container-summary__title-text"> - { titleComponent || title } - </div> - </div> - { - hideSubtitle || <div className="confirm-page-container-summary__subtitle"> - { subtitleComponent || subtitle } - </div> - } - </div> - ) -} - -ConfirmPageContainerSummary.propTypes = { - action: PropTypes.string, - title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - titleComponent: PropTypes.node, - subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - subtitleComponent: PropTypes.node, - hideSubtitle: PropTypes.bool, - className: PropTypes.string, - identiconAddress: PropTypes.string, - nonce: PropTypes.string, - assetImage: PropTypes.string, -} - -export default ConfirmPageContainerSummary diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss deleted file mode 100644 index 78639a435..000000000 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss +++ /dev/null @@ -1,68 +0,0 @@ -@import './confirm-page-container-warning/index'; - -@import './confirm-page-container-summary/index'; - -.confirm-page-container-content { - overflow-y: auto; - flex: 1; - - &__error-container { - padding: 0 16px 16px 16px; - } - - &__details { - box-sizing: border-box; - padding: 0 24px; - } - - &__data { - padding: 16px; - color: $oslo-gray; - } - - &__data-box { - background-color: #f9fafa; - padding: 12px; - font-size: .75rem; - margin-bottom: 16px; - word-wrap: break-word; - max-height: 200px; - overflow-y: auto; - - &-label { - text-transform: uppercase; - padding: 8px 0 12px; - font-size: 12px; - } - } - - &__data-field { - display: flex; - flex-direction: row; - - &-label { - font-weight: 500; - padding-right: 16px; - } - - &:not(:last-child) { - margin-bottom: 5px; - } - } - - &__gas-fee { - border-bottom: 1px solid $geyser; - - .advanced-gas-inputs__gas-edit-rows { - margin-bottom: 16px; - } - } - - &__function-type { - font-size: .875rem; - font-weight: 500; - text-transform: capitalize; - color: $black; - padding-left: 5px; - } -} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js b/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js deleted file mode 100644 index e6fe8f82c..000000000 --- a/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js +++ /dev/null @@ -1,63 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import { - ENVIRONMENT_TYPE_POPUP, - ENVIRONMENT_TYPE_NOTIFICATION, -} from '../../../../../app/scripts/lib/enums' -import NetworkDisplay from '../../network-display' - -export default class ConfirmPageContainer extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - showEdit: PropTypes.bool, - onEdit: PropTypes.func, - children: PropTypes.node, - } - - renderTop () { - const { onEdit, showEdit } = this.props - const windowType = window.METAMASK_UI_TYPE - const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && - windowType !== ENVIRONMENT_TYPE_POPUP - - if (!showEdit && isFullScreen) { - return null - } - - return ( - <div className="confirm-page-container-header__row"> - <div - className="confirm-page-container-header__back-button-container" - style={{ - visibility: showEdit ? 'initial' : 'hidden', - }} - > - <img - src="/images/caret-left.svg" - /> - <span - className="confirm-page-container-header__back-button" - onClick={() => onEdit()} - > - { this.context.t('edit') } - </span> - </div> - { !isFullScreen && <NetworkDisplay /> } - </div> - ) - } - - render () { - const { children } = this.props - - return ( - <div className="confirm-page-container-header"> - { this.renderTop() } - { children } - </div> - ) - } -} diff --git a/ui/app/components/confirm-page-container/confirm-page-container.component.js b/ui/app/components/confirm-page-container/confirm-page-container.component.js deleted file mode 100644 index 10edf3b16..000000000 --- a/ui/app/components/confirm-page-container/confirm-page-container.component.js +++ /dev/null @@ -1,170 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import SenderToRecipient from '../sender-to-recipient' -import { PageContainerFooter } from '../page-container' -import { ConfirmPageContainerHeader, ConfirmPageContainerContent, ConfirmPageContainerNavigation } from './' - -export default class ConfirmPageContainer extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - // Header - action: PropTypes.string, - hideSubtitle: PropTypes.bool, - onEdit: PropTypes.func, - showEdit: PropTypes.bool, - subtitle: PropTypes.string, - subtitleComponent: PropTypes.node, - title: PropTypes.string, - titleComponent: PropTypes.node, - // Sender to Recipient - fromAddress: PropTypes.string, - fromName: PropTypes.string, - toAddress: PropTypes.string, - toName: PropTypes.string, - // Content - contentComponent: PropTypes.node, - errorKey: PropTypes.string, - errorMessage: PropTypes.string, - fiatTransactionAmount: PropTypes.string, - fiatTransactionFee: PropTypes.string, - fiatTransactionTotal: PropTypes.string, - ethTransactionAmount: PropTypes.string, - ethTransactionFee: PropTypes.string, - ethTransactionTotal: PropTypes.string, - onEditGas: PropTypes.func, - dataComponent: PropTypes.node, - detailsComponent: PropTypes.node, - identiconAddress: PropTypes.string, - nonce: PropTypes.string, - assetImage: PropTypes.string, - 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, - onSubmit: PropTypes.func, - disabled: PropTypes.bool, - } - - render () { - const { - showEdit, - onEdit, - fromName, - fromAddress, - toName, - toAddress, - disabled, - errorKey, - errorMessage, - contentComponent, - action, - title, - titleComponent, - subtitle, - subtitleComponent, - hideSubtitle, - summaryComponent, - detailsComponent, - dataComponent, - onCancelAll, - onCancel, - onSubmit, - identiconAddress, - nonce, - 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()} - > - <SenderToRecipient - senderName={fromName} - senderAddress={fromAddress} - recipientName={toName} - recipientAddress={toAddress} - assetImage={renderAssetImage ? assetImage : undefined} - /> - </ConfirmPageContainerHeader> - { - contentComponent || ( - <ConfirmPageContainerContent - action={action} - title={title} - titleComponent={titleComponent} - subtitle={subtitle} - subtitleComponent={subtitleComponent} - hideSubtitle={hideSubtitle} - summaryComponent={summaryComponent} - detailsComponent={detailsComponent} - dataComponent={dataComponent} - errorMessage={errorMessage} - errorKey={errorKey} - identiconAddress={identiconAddress} - nonce={nonce} - assetImage={assetImage} - warning={warning} - /> - ) - } - <PageContainerFooter - onCancel={() => onCancel()} - cancelText={this.context.t('reject')} - onSubmit={() => onSubmit()} - submitText={this.context.t('confirm')} - submitButtonType="confirm" - disabled={disabled} - > - {unapprovedTxCount > 1 && ( - <a onClick={() => onCancelAll()}> - {this.context.t('rejectTxsN', [unapprovedTxCount])} - </a> - )} - </PageContainerFooter> - </div> - ) - } -} 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 d41cd4423..000000000 --- a/ui/app/components/confirm-page-container/index.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import './confirm-page-container-content/index'; - -@import './confirm-page-container-header/index'; - -@import './confirm-detail-row/index'; - -@import './confirm-page-container-navigation/index';
\ No newline at end of file diff --git a/ui/app/components/copyable.js b/ui/app/components/copyable.js deleted file mode 100644 index ad504deb8..000000000 --- a/ui/app/components/copyable.js +++ /dev/null @@ -1,53 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits - -const Tooltip = require('./tooltip') -const copyToClipboard = require('copy-to-clipboard') -const connect = require('react-redux').connect - -Copyable.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(Copyable) - - -inherits(Copyable, Component) -function Copyable () { - Component.call(this) - this.state = { - copied: false, - } -} - -Copyable.prototype.render = function () { - const props = this.props - const state = this.state - const { value, children } = props - const { copied } = state - - return h(Tooltip, { - title: copied ? this.context.t('copiedExclamation') : this.context.t('copy'), - position: 'bottom', - }, h('span', { - style: { - cursor: 'pointer', - }, - onClick: (event) => { - event.preventDefault() - event.stopPropagation() - copyToClipboard(value) - this.debounceRestore() - }, - }, children)) -} - -Copyable.prototype.debounceRestore = function () { - this.setState({ copied: true }) - clearTimeout(this.timeout) - this.timeout = setTimeout(() => { - this.setState({ copied: false }) - }, 850) -} diff --git a/ui/app/components/currency-display/currency-display.component.js b/ui/app/components/currency-display/currency-display.component.js deleted file mode 100644 index 6a743cc4e..000000000 --- a/ui/app/components/currency-display/currency-display.component.js +++ /dev/null @@ -1,46 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { GWEI } from '../../constants/common' - -export default class CurrencyDisplay extends PureComponent { - static propTypes = { - className: PropTypes.string, - displayValue: PropTypes.string, - prefix: PropTypes.string, - prefixComponent: PropTypes.node, - style: PropTypes.object, - suffix: PropTypes.string, - // Used in container - currency: PropTypes.string, - denomination: PropTypes.oneOf([GWEI]), - value: PropTypes.string, - numberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - hideLabel: PropTypes.bool, - hideTitle: PropTypes.bool, - } - - render () { - const { className, displayValue, prefix, prefixComponent, style, suffix, hideTitle } = this.props - const text = `${prefix || ''}${displayValue}` - const title = `${text} ${suffix}` - - return ( - <div - className={classnames('currency-display-component', className)} - style={style} - title={!hideTitle && title || null} - > - { prefixComponent } - <span className="currency-display-component__text">{ text }</span> - { - suffix && ( - <span className="currency-display-component__suffix"> - { suffix } - </span> - ) - } - </div> - ) - } -} diff --git a/ui/app/components/currency-display/currency-display.container.js b/ui/app/components/currency-display/currency-display.container.js deleted file mode 100644 index e581f8a5e..000000000 --- a/ui/app/components/currency-display/currency-display.container.js +++ /dev/null @@ -1,51 +0,0 @@ -import { connect } from 'react-redux' -import CurrencyDisplay from './currency-display.component' -import { getValueFromWeiHex, formatCurrency } from '../../helpers/confirm-transaction/util' - -const mapStateToProps = state => { - const { metamask: { nativeCurrency, currentCurrency, conversionRate } } = state - - return { - currentCurrency, - conversionRate, - nativeCurrency, - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { nativeCurrency, currentCurrency, conversionRate, ...restStateProps } = stateProps - const { - value, - numberOfDecimals = 2, - currency, - denomination, - hideLabel, - displayValue: propsDisplayValue, - suffix: propsSuffix, - ...restOwnProps - } = ownProps - - const toCurrency = currency || currentCurrency - - const displayValue = propsDisplayValue || formatCurrency( - getValueFromWeiHex({ - value, - fromCurrency: nativeCurrency, - toCurrency, conversionRate, - numberOfDecimals, - toDenomination: denomination, - }), - toCurrency - ) - const suffix = propsSuffix || (hideLabel ? undefined : toCurrency.toUpperCase()) - - return { - ...restStateProps, - ...dispatchProps, - ...restOwnProps, - displayValue, - suffix, - } -} - -export default connect(mapStateToProps, null, mergeProps)(CurrencyDisplay) diff --git a/ui/app/components/currency-input/currency-input.component.js b/ui/app/components/currency-input/currency-input.component.js deleted file mode 100644 index 30e0e919b..000000000 --- a/ui/app/components/currency-input/currency-input.component.js +++ /dev/null @@ -1,160 +0,0 @@ -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' - -/** - * Component that allows user to enter currency values as a number, and props receive a converted - * hex value in WEI. props.value, used as a default or forced value, should be a hex value, which - * 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, - useFiat: PropTypes.bool, - hideFiat: PropTypes.bool, - value: PropTypes.string, - fiatSuffix: PropTypes.string, - nativeSuffix: PropTypes.string, - } - - constructor (props) { - super(props) - - const { value: hexValue } = props - const decimalValue = hexValue ? this.getDecimalValue(props) : 0 - - this.state = { - decimalValue, - hexValue, - isSwapped: false, - } - } - - componentDidUpdate (prevProps) { - const { value: prevPropsHexValue } = prevProps - const { value: propsHexValue } = this.props - const { hexValue: stateHexValue } = this.state - - if (prevPropsHexValue !== propsHexValue && propsHexValue !== stateHexValue) { - const decimalValue = this.getDecimalValue(this.props) - this.setState({ hexValue: propsHexValue, decimalValue }) - } - } - - getDecimalValue (props) { - const { value: hexValue, currentCurrency, conversionRate } = props - const decimalValueString = this.shouldUseFiat() - ? getValueFromWeiHex({ - value: hexValue, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, - }) - : getValueFromWeiHex({ - value: hexValue, toCurrency: ETH, numberOfDecimals: 6, - }) - - 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 { currentCurrency: fromCurrency, conversionRate, onChange } = this.props - - const hexValue = this.shouldUseFiat() - ? getWeiHexFromDecimalValue({ - value: decimalValue, fromCurrency, conversionRate, invertConversionRate: true, - }) - : getWeiHexFromDecimalValue({ - value: decimalValue, fromCurrency: ETH, fromDenomination: ETH, conversionRate, - }) - - this.setState({ hexValue, decimalValue }) - onChange(hexValue) - } - - handleBlur = () => { - this.props.onBlur(this.state.hexValue) - } - - renderConversionComponent () { - const { currentCurrency, nativeCurrency, hideFiat } = this.props - const { hexValue } = this.state - let currency, numberOfDecimals - - if (hideFiat) { - return ( - <div className="currency-input__conversion-component"> - { this.context.t('noConversionRateAvailable') } - </div> - ) - } - - if (this.shouldUseFiat()) { - // Display ETH - currency = nativeCurrency || ETH - numberOfDecimals = 6 - } else { - // Display Fiat - currency = currentCurrency - numberOfDecimals = 2 - } - - return ( - <CurrencyDisplay - className="currency-input__conversion-component" - currency={currency} - value={hexValue} - numberOfDecimals={numberOfDecimals} - /> - ) - } - - render () { - const { fiatSuffix, nativeSuffix, ...restProps } = this.props - const { decimalValue } = this.state - - return ( - <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/currency-input/currency-input.container.js deleted file mode 100644 index 428be4557..000000000 --- a/ui/app/components/currency-input/currency-input.container.js +++ /dev/null @@ -1,31 +0,0 @@ -import { connect } from 'react-redux' -import CurrencyInput from './currency-input.component' -import { ETH } from '../../constants/common' -import {getIsMainnet, preferencesSelector} from '../../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 - - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - nativeSuffix: nativeCurrency || ETH, - fiatSuffix: currentCurrency.toUpperCase(), - } -} - -export default connect(mapStateToProps, null, mergeProps)(CurrencyInput) diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js deleted file mode 100644 index fd660ead2..000000000 --- a/ui/app/components/customize-gas-modal/index.js +++ /dev/null @@ -1,396 +0,0 @@ -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 -const BigNumber = require('bignumber.js') -const actions = require('../../actions') -const GasModalCard = require('./gas-modal-card') -import Button from '../button' - -const ethUtil = require('ethereumjs-util') - -import { - updateSendErrors, -} from '../../ducks/send.duck' - -const { - MIN_GAS_PRICE_DEC, - MIN_GAS_LIMIT_DEC, - MIN_GAS_PRICE_GWEI, -} = require('../send/send.constants') - -const { - isBalanceSufficient, -} = require('../send/send.utils') - -const { - conversionUtil, - multiplyCurrencies, - conversionGreaterThan, - conversionMax, - subtractCurrencies, -} = require('../../conversion-util') - -const { - getGasIsLoading, - getForceGasMin, - conversionRateSelector, - getSendAmount, - getSelectedToken, - getSendFrom, - getCurrentAccountWithSendEtherInfo, - getSelectedTokenToFiatRate, - getSendMaxModeState, -} = require('../../selectors') - -const { - getGasPrice, - getGasLimit, -} = require('../send/send.selectors') - -function mapStateToProps (state) { - const selectedToken = getSelectedToken(state) - const currentAccount = getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state) - const conversionRate = conversionRateSelector(state) - - return { - gasPrice: getGasPrice(state), - gasLimit: getGasLimit(state), - gasIsLoading: getGasIsLoading(state), - forceGasMin: getForceGasMin(state), - conversionRate, - amount: getSendAmount(state), - maxModeOn: getSendMaxModeState(state), - balance: currentAccount.balance, - primaryCurrency: selectedToken && selectedToken.symbol, - selectedToken, - amountConversionRate: selectedToken ? getSelectedTokenToFiatRate(state) : conversionRate, - } -} - -function mapDispatchToProps (dispatch) { - return { - hideModal: () => dispatch(actions.hideModal()), - setGasPrice: newGasPrice => dispatch(actions.setGasPrice(newGasPrice)), - setGasLimit: newGasLimit => dispatch(actions.setGasLimit(newGasLimit)), - setGasTotal: newGasTotal => dispatch(actions.setGasTotal(newGasTotal)), - updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)), - updateSendErrors: error => dispatch(updateSendErrors(error)), - } -} - -function getFreshState (props) { - const gasPrice = props.gasPrice || MIN_GAS_PRICE_DEC - const gasLimit = props.gasLimit || MIN_GAS_LIMIT_DEC - - const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, - }) - - return { - gasPrice, - gasLimit, - gasTotal, - error: null, - priceSigZeros: '', - priceSigDec: '', - } -} - -inherits(CustomizeGasModal, Component) -function CustomizeGasModal (props) { - Component.call(this) - - const originalState = getFreshState(props) - this.state = { - ...originalState, - originalState, - } -} - -CustomizeGasModal.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal) - -CustomizeGasModal.prototype.componentWillReceiveProps = function (nextProps) { - const currentState = getFreshState(this.props) - const { - gasPrice: currentGasPrice, - gasLimit: currentGasLimit, - } = currentState - const newState = getFreshState(nextProps) - const { - gasPrice: newGasPrice, - gasLimit: newGasLimit, - gasTotal: newGasTotal, - } = newState - const gasPriceChanged = currentGasPrice !== newGasPrice - const gasLimitChanged = currentGasLimit !== newGasLimit - - if (gasPriceChanged) { - this.setState({ - gasPrice: newGasPrice, - gasTotal: newGasTotal, - priceSigZeros: '', - priceSigDec: '', - }) - } - if (gasLimitChanged) { - this.setState({ gasLimit: newGasLimit, gasTotal: newGasTotal }) - } - if (gasLimitChanged || gasPriceChanged) { - this.validate({ gasLimit: newGasLimit, gasTotal: newGasTotal }) - } -} - -CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { - const { metricsEvent } = this.context - const { - setGasPrice, - setGasLimit, - hideModal, - setGasTotal, - maxModeOn, - selectedToken, - balance, - updateSendAmount, - updateSendErrors, - } = this.props - const { - originalState, - } = this.state - - if (maxModeOn && !selectedToken) { - const maxAmount = subtractCurrencies( - ethUtil.addHexPrefix(balance), - ethUtil.addHexPrefix(gasTotal), - { toNumericBase: 'hex' } - ) - 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)) - updateSendErrors({ insufficientFunds: false }) - hideModal() -} - -CustomizeGasModal.prototype.revert = function () { - this.setState(this.state.originalState) -} - -CustomizeGasModal.prototype.validate = function ({ gasTotal, gasLimit }) { - const { - amount, - balance, - selectedToken, - amountConversionRate, - conversionRate, - maxModeOn, - } = this.props - - let error = null - - const balanceIsSufficient = isBalanceSufficient({ - amount: selectedToken || maxModeOn ? '0' : amount, - gasTotal, - balance, - selectedToken, - amountConversionRate, - conversionRate, - }) - - if (!balanceIsSufficient) { - error = this.context.t('balanceIsInsufficientGas') - } - - const gasLimitTooLow = gasLimit && conversionGreaterThan( - { - value: MIN_GAS_LIMIT_DEC, - fromNumericBase: 'dec', - conversionRate, - }, - { - value: gasLimit, - fromNumericBase: 'hex', - }, - ) - - if (gasLimitTooLow) { - error = this.context.t('gasLimitTooLow') - } - - this.setState({ error }) - return error -} - -CustomizeGasModal.prototype.convertAndSetGasLimit = function (newGasLimit) { - const { gasPrice } = this.state - - const gasLimit = conversionUtil(newGasLimit, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }) - - const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, - }) - - this.validate({ gasTotal, gasLimit }) - - this.setState({ gasTotal, gasLimit }) -} - -CustomizeGasModal.prototype.convertAndSetGasPrice = function (newGasPrice) { - const { gasLimit } = this.state - const sigZeros = String(newGasPrice).match(/^\d+[.]\d*?(0+)$/) - const sigDec = String(newGasPrice).match(/^\d+([.])0*$/) - - this.setState({ - priceSigZeros: sigZeros && sigZeros[1] || '', - priceSigDec: sigDec && sigDec[1] || '', - }) - - const gasPrice = conversionUtil(newGasPrice, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - fromDenomination: 'GWEI', - toDenomination: 'WEI', - }) - - const gasTotal = multiplyCurrencies(gasLimit, gasPrice, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, - }) - - this.validate({ gasTotal }) - - this.setState({ gasTotal, gasPrice }) -} - -CustomizeGasModal.prototype.render = function () { - const { hideModal, forceGasMin, gasIsLoading } = this.props - const { gasPrice, gasLimit, gasTotal, error, priceSigZeros, priceSigDec } = this.state - - let convertedGasPrice = conversionUtil(gasPrice, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromDenomination: 'WEI', - toDenomination: 'GWEI', - }) - - convertedGasPrice += convertedGasPrice.match(/[.]/) ? priceSigZeros : `${priceSigDec}${priceSigZeros}` - - let newGasPrice = gasPrice - if (forceGasMin) { - const convertedMinPrice = conversionUtil(forceGasMin, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - }) - convertedGasPrice = conversionMax( - { value: convertedMinPrice, fromNumericBase: 'dec' }, - { value: convertedGasPrice, fromNumericBase: 'dec' } - ) - newGasPrice = conversionMax( - { value: gasPrice, fromNumericBase: 'hex' }, - { value: forceGasMin, fromNumericBase: 'hex' } - ) - } - - const convertedGasLimit = conversionUtil(gasLimit, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - }) - - return !gasIsLoading && h('div.send-v2__customize-gas', {}, [ - h('div.send-v2__customize-gas__content', { - }, [ - h('div.send-v2__customize-gas__header', {}, [ - - h('div.send-v2__customize-gas__title', this.context.t('customGas')), - - h('div.send-v2__customize-gas__close', { - onClick: hideModal, - }), - - ]), - - h('div.send-v2__customize-gas__body', {}, [ - - h(GasModalCard, { - value: convertedGasPrice, - min: forceGasMin || MIN_GAS_PRICE_GWEI, - step: 1, - onChange: value => this.convertAndSetGasPrice(value), - title: this.context.t('gasPrice'), - copy: this.context.t('gasPriceCalculation'), - gasIsLoading, - }), - - h(GasModalCard, { - value: convertedGasLimit, - min: 1, - step: 1, - onChange: value => this.convertAndSetGasLimit(value), - title: this.context.t('gasLimit'), - copy: this.context.t('gasLimitCalculation'), - gasIsLoading, - }), - - ]), - - h('div.send-v2__customize-gas__footer', {}, [ - - error && h('div.send-v2__customize-gas__error-message', [ - error, - ]), - - h('div.send-v2__customize-gas__revert', { - onClick: () => this.revert(), - }, [this.context.t('revert')]), - - h('div.send-v2__customize-gas__buttons', [ - h(Button, { - type: 'default', - className: 'send-v2__customize-gas__cancel', - onClick: this.props.hideModal, - }, [this.context.t('cancel')]), - h(Button, { - type: 'primary', - className: 'send-v2__customize-gas__save', - onClick: () => !error && this.save(newGasPrice, gasLimit, gasTotal), - disabled: error, - }, [this.context.t('save')]), - ]), - - ]), - - ]), - ]) -} diff --git a/ui/app/components/dropdowns/account-details-dropdown.js b/ui/app/components/dropdowns/account-details-dropdown.js deleted file mode 100644 index bda8b9517..000000000 --- a/ui/app/components/dropdowns/account-details-dropdown.js +++ /dev/null @@ -1,131 +0,0 @@ -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 -const actions = require('../../actions') -const { getSelectedIdentity } = require('../../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) - -function mapStateToProps (state) { - return { - selectedIdentity: getSelectedIdentity(state), - network: state.metamask.network, - keyrings: state.metamask.keyrings, - } -} - -function mapDispatchToProps (dispatch) { - return { - showAccountDetailModal: () => { - dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) - }, - viewOnEtherscan: (address, network) => { - global.platform.openWindow({ url: genAccountLink(address, network) }) - }, - showRemoveAccountConfirmationModal: (identity) => { - return dispatch(actions.showModal({ name: 'CONFIRM_REMOVE_ACCOUNT', identity })) - }, - } -} - -inherits(AccountDetailsDropdown, Component) -function AccountDetailsDropdown () { - Component.call(this) - - this.onClose = this.onClose.bind(this) -} - -AccountDetailsDropdown.prototype.onClose = function (e) { - e.stopPropagation() - this.props.onClose() -} - -AccountDetailsDropdown.prototype.render = function () { - const { - selectedIdentity, - network, - keyrings, - showAccountDetailModal, - viewOnEtherscan, - showRemoveAccountConfirmationModal } = this.props - - const address = selectedIdentity.address - - const keyring = keyrings.find((kr) => { - return kr.accounts.includes(address) - }) - - const isRemovable = keyring.type !== 'HD Key Tree' - - return h(Menu, { className: 'account-details-dropdown', isShowing: true }, [ - h(CloseArea, { - onClick: this.onClose, - }), - 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() - }, - text: this.context.t('expandView'), - icon: h(`img`, { src: 'images/expand.svg', style: { height: '15px' } }), - }), - h(Item, { - 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'), - icon: h(`img`, { src: 'images/info.svg', style: { height: '15px' } }), - }), - 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() - }, - text: this.context.t('viewOnEtherscan'), - icon: h(`img`, { src: 'images/open-etherscan.svg', style: { height: '15px' } }), - }), - isRemovable ? h(Item, { - onClick: (e) => { - e.stopPropagation() - showRemoveAccountConfirmationModal(selectedIdentity) - this.props.onClose() - }, - text: this.context.t('removeAccount'), - icon: h(`img`, { src: 'images/hide.svg', style: { height: '15px' } }), - }) : null, - ]) -} diff --git a/ui/app/components/dropdowns/components/account-dropdowns.js b/ui/app/components/dropdowns/components/account-dropdowns.js deleted file mode 100644 index e6b3e0c0c..000000000 --- a/ui/app/components/dropdowns/components/account-dropdowns.js +++ /dev/null @@ -1,473 +0,0 @@ -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 connect = require('react-redux').connect -const Dropdown = require('./dropdown').Dropdown -const DropdownMenuItem = require('./dropdown').DropdownMenuItem -import Identicon from '../../identicon' -const { checksumAddress } = require('../../../util') -const copyToClipboard = require('copy-to-clipboard') -const { formatBalance } = require('../../../util') - - -class AccountDropdowns extends Component { - constructor (props) { - super(props) - this.state = { - accountSelectorActive: false, - optionsMenuActive: false, - } - // Used for orangeaccount selector icon - // this.accountSelectorToggleClassName = 'accounts-selector' - this.accountSelectorToggleClassName = 'fa-angle-down' - this.optionsMenuToggleClassName = 'fa-ellipsis-h' - } - - renderAccounts () { - const { identities, accounts, selected, menuItemStyles, actions, keyrings, ticker } = this.props - - return Object.keys(identities).map((key, index) => { - const identity = identities[key] - const isSelected = identity.address === selected - - const balanceValue = accounts[key].balance - const formattedBalance = balanceValue ? formatBalance(balanceValue, 6, true, ticker) : '...' - const simpleAddress = identity.address.substring(2).toLowerCase() - - const keyring = keyrings.find((kr) => { - return kr.accounts.includes(simpleAddress) || - kr.accounts.includes(identity.address) - }) - - return h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - this.props.actions.showAccountDetail(identity.address) - }, - style: Object.assign( - { - marginTop: index === 0 ? '5px' : '', - fontSize: '24px', - width: '260px', - }, - menuItemStyles, - ), - }, - [ - h('div.flex-row.flex-center', {}, [ - - h('span', { - style: { - flex: '1 1 0', - minWidth: '20px', - minHeight: '30px', - }, - }, [ - h('span', { - style: { - flex: '1 1 auto', - fontSize: '14px', - }, - }, isSelected ? h('i.fa.fa-check') : null), - ]), - - h( - Identicon, - { - address: identity.address, - diameter: 24, - style: { - flex: '1 1 auto', - marginLeft: '10px', - }, - }, - ), - - h('span.flex-column', { - style: { - flex: '10 10 auto', - width: '175px', - alignItems: 'flex-start', - justifyContent: 'center', - marginLeft: '10px', - position: 'relative', - }, - }, [ - this.indicateIfLoose(keyring), - h('span.account-dropdown-name', { - style: { - fontSize: '18px', - maxWidth: '145px', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - }, identity.name || ''), - - h('span.account-dropdown-balance', { - style: { - fontSize: '14px', - fontFamily: 'Avenir', - fontWeight: 500, - }, - }, formattedBalance), - ]), - - h('span', { - style: { - flex: '3 3 auto', - }, - }, [ - h('span.account-dropdown-edit-button.allcaps', { - style: { - fontSize: '16px', - }, - onClick: () => { - actions.showEditAccountModal(identity) - }, - }, [ - this.context.t('edit'), - ]), - ]), - - ]), - ] - ) - }) - } - - indicateIfLoose (keyring) { - try { // Sometimes keyrings aren't loaded yet: - const type = keyring.type - const isLoose = type !== 'HD Key Tree' - return isLoose ? h('.keyring-label.allcaps', this.context.t('loose')) : null - } catch (e) { return } - } - - renderAccountSelector () { - const { actions, useCssTransition, innerStyle, sidebarOpen } = this.props - const { accountSelectorActive, menuItemStyles } = this.state - - return h( - Dropdown, - { - useCssTransition, - style: { - marginLeft: '-185px', - marginTop: '50px', - minWidth: '180px', - overflowY: 'auto', - maxHeight: '300px', - width: '300px', - }, - innerStyle, - isOpen: accountSelectorActive, - onClickOutside: (event) => { - const { classList } = event.target - const isNotToggleElement = !classList.contains(this.accountSelectorToggleClassName) - if (accountSelectorActive && isNotToggleElement) { - this.setState({ accountSelectorActive: false }) - } - }, - }, - [ - ...this.renderAccounts(), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - style: Object.assign( - {}, - menuItemStyles, - ), - onClick: () => actions.showNewAccountPageCreateForm(), - }, - [ - h( - 'i.fa.fa-plus.fa-lg', - { - style: { - marginLeft: '8px', - }, - } - ), - h('span', { - style: { - marginLeft: '14px', - fontFamily: 'DIN OT', - fontSize: '16px', - lineHeight: '23px', - }, - }, this.context.t('createAccount')), - ], - ), - h( - DropdownMenuItem, - { - closeMenu: () => { - if (sidebarOpen) { - actions.hideSidebar() - } - }, - onClick: () => actions.showNewAccountPageImportForm(), - style: Object.assign( - {}, - menuItemStyles, - ), - }, - [ - h( - 'i.fa.fa-download.fa-lg', - { - style: { - marginLeft: '8px', - }, - } - ), - h('span', { - style: { - marginLeft: '20px', - marginBottom: '5px', - fontFamily: 'DIN OT', - fontSize: '16px', - lineHeight: '23px', - }, - }, this.context.t('importAccount')), - ] - ), - ] - ) - } - - renderAccountOptions () { - const { actions, dropdownWrapperStyle, useCssTransition } = this.props - const { optionsMenuActive, menuItemStyles } = this.state - const dropdownMenuItemStyle = { - fontFamily: 'DIN OT', - fontSize: 16, - lineHeight: '24px', - padding: '8px', - } - - return h( - Dropdown, - { - useCssTransition, - style: Object.assign( - { - marginLeft: '-10px', - position: 'absolute', - width: '29vh', // affects both mobile and laptop views - }, - dropdownWrapperStyle, - ), - isOpen: optionsMenuActive, - onClickOutside: (event) => { - const { classList } = event.target - const isNotToggleElement = !classList.contains(this.optionsMenuToggleClassName) - if (optionsMenuActive && isNotToggleElement) { - this.setState({ optionsMenuActive: false }) - } - }, - }, - [ - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - this.props.actions.showAccountDetailModal() - }, - style: Object.assign( - dropdownMenuItemStyle, - menuItemStyles, - ), - }, - this.context.t('accountDetails'), - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - const { selected, network } = this.props - const url = genAccountLink(selected, network) - global.platform.openWindow({ url }) - }, - style: Object.assign( - dropdownMenuItemStyle, - menuItemStyles, - ), - }, - this.context.t('etherscanView'), - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - const { selected } = this.props - copyToClipboard(checksumAddress(selected)) - }, - style: Object.assign( - dropdownMenuItemStyle, - menuItemStyles, - ), - }, - this.context.t('copyAddress'), - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => this.props.actions.showExportPrivateKeyModal(), - style: Object.assign( - dropdownMenuItemStyle, - menuItemStyles, - ), - }, - this.context.t('exportPrivateKey'), - ), - h( - DropdownMenuItem, - { - closeMenu: () => {}, - onClick: () => { - actions.hideSidebar() - actions.showAddTokenPage() - }, - style: Object.assign( - dropdownMenuItemStyle, - menuItemStyles, - ), - }, - this.context.t('addToken'), - ), - - ] - ) - } - - render () { - const { style, enableAccountsSelector, enableAccountOptions } = this.props - const { optionsMenuActive, accountSelectorActive } = this.state - - return h( - 'span', - { - style: style, - }, - [ - enableAccountsSelector && h( - 'i.fa.fa-angle-down', - { - style: { - cursor: 'pointer', - }, - onClick: (event) => { - event.stopPropagation() - this.setState({ - accountSelectorActive: !accountSelectorActive, - optionsMenuActive: false, - }) - }, - }, - this.renderAccountSelector(), - ), - enableAccountOptions && h( - 'i.fa.fa-ellipsis-h', - { - style: { - fontSize: '135%', - cursor: 'pointer', - }, - onClick: (event) => { - event.stopPropagation() - this.setState({ - accountSelectorActive: false, - optionsMenuActive: !optionsMenuActive, - }) - }, - }, - this.renderAccountOptions() - ), - ] - ) - } -} - -AccountDropdowns.defaultProps = { - enableAccountsSelector: false, - enableAccountOptions: false, -} - -AccountDropdowns.propTypes = { - identities: PropTypes.objectOf(PropTypes.object), - selected: PropTypes.string, - keyrings: PropTypes.array, - accounts: PropTypes.object, - menuItemStyles: PropTypes.object, - actions: PropTypes.object, - // actions.showAccountDetail: , - useCssTransition: PropTypes.bool, - innerStyle: PropTypes.object, - sidebarOpen: PropTypes.bool, - dropdownWrapperStyle: PropTypes.string, - // actions.showAccountDetailModal: , - network: PropTypes.number, - // actions.showExportPrivateKeyModal: , - style: PropTypes.object, - ticker: PropTypes.string, - enableAccountsSelector: PropTypes.bool, - enableAccountOption: PropTypes.bool, - enableAccountOptions: PropTypes.bool, - t: PropTypes.func, -} - -const mapDispatchToProps = (dispatch) => { - return { - actions: { - hideSidebar: () => dispatch(actions.hideSidebar()), - showConfigPage: () => dispatch(actions.showConfigPage()), - showAccountDetail: (address) => dispatch(actions.showAccountDetail(address)), - showAccountDetailModal: () => { - dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) - }, - showEditAccountModal: (identity) => { - dispatch(actions.showModal({ - name: 'EDIT_ACCOUNT_NAME', - identity, - })) - }, - showNewAccountPageCreateForm: () => dispatch(actions.showNewAccountPage({ form: 'CREATE' })), - showExportPrivateKeyModal: () => { - dispatch(actions.showModal({ name: 'EXPORT_PRIVATE_KEY' })) - }, - showAddTokenPage: () => { - dispatch(actions.showAddTokenPage()) - }, - addNewAccount: () => dispatch(actions.addNewAccount()), - showNewAccountPageImportForm: () => dispatch(actions.showNewAccountPage({ form: 'IMPORT' })), - showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), - }, - } -} - -function mapStateToProps (state) { - return { - ticker: state.metamask.ticker, - keyrings: state.metamask.keyrings, - sidebarOpen: state.appState.sidebar.isOpen, - } -} - -AccountDropdowns.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDropdowns) - diff --git a/ui/app/components/dropdowns/network-dropdown.js b/ui/app/components/dropdowns/network-dropdown.js deleted file mode 100644 index 90355a97c..000000000 --- a/ui/app/components/dropdowns/network-dropdown.js +++ /dev/null @@ -1,378 +0,0 @@ -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 -const { withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const actions = require('../../actions') -const Dropdown = require('./components/dropdown').Dropdown -const DropdownMenuItem = require('./components/dropdown').DropdownMenuItem -const NetworkDropdownIcon = require('./components/network-dropdown-icon') -const R = require('ramda') -const { SETTINGS_ROUTE } = require('../../routes') - -// classes from nodes of the toggle element. -const notToggleElementClassnames = [ - 'menu-icon', - 'network-name', - 'network-indicator', - 'network-caret', - 'network-component', -] - -function mapStateToProps (state) { - return { - provider: state.metamask.provider, - frequentRpcListDetail: state.metamask.frequentRpcListDetail || [], - networkDropdownOpen: state.appState.networkDropdownOpen, - network: state.metamask.network, - } -} - -function mapDispatchToProps (dispatch) { - return { - hideModal: () => { - dispatch(actions.hideModal()) - }, - setProviderType: (type) => { - dispatch(actions.setProviderType(type)) - }, - setDefaultRpcTarget: type => { - dispatch(actions.setDefaultRpcTarget(type)) - }, - setRpcTarget: (target, network, ticker, nickname) => { - dispatch(actions.setRpcTarget(target, network, ticker, nickname)) - }, - delRpcTarget: (target) => { - dispatch(actions.delRpcTarget(target)) - }, - showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), - hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), - } -} - - -inherits(NetworkDropdown, Component) -function NetworkDropdown () { - Component.call(this) -} - -NetworkDropdown.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(NetworkDropdown) - - -// TODO: specify default props and proptypes -NetworkDropdown.prototype.render = function () { - const props = this.props - const { provider: { type: providerType, rpcTarget: activeNetwork } } = props - const rpcListDetail = props.frequentRpcListDetail - const isOpen = this.props.networkDropdownOpen - const dropdownMenuItemStyle = { - fontSize: '16px', - lineHeight: '20px', - padding: '12px 0', - } - - return h(Dropdown, { - isOpen, - onClickOutside: (event) => { - const { classList } = event.target - const isInClassList = className => classList.contains(className) - const notToggleElementIndex = R.findIndex(isInClassList)(notToggleElementClassnames) - - if (notToggleElementIndex === -1) { - this.props.hideNetworkDropdown() - } - }, - containerClassName: 'network-droppo', - zIndex: 55, - style: { - position: 'absolute', - top: '58px', - width: '309px', - zIndex: '55px', - }, - innerStyle: { - padding: '18px 8px', - }, - }, [ - - h('div.network-dropdown-header', {}, [ - h('div.network-dropdown-title', {}, this.context.t('networks')), - - h('div.network-dropdown-divider'), - - h('div.network-dropdown-content', - {}, - this.context.t('defaultNetwork') - ), - ]), - - h( - DropdownMenuItem, - { - key: 'main', - closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.handleClick('mainnet'), - style: { ...dropdownMenuItemStyle, borderColor: '#038789' }, - }, - [ - providerType === 'mainnet' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), - h(NetworkDropdownIcon, { - backgroundColor: '#29B6AF', // $java - isSelected: providerType === 'mainnet', - }), - h('span.network-name-item', { - style: { - color: providerType === 'mainnet' ? '#ffffff' : '#9b9b9b', - }, - }, this.context.t('mainnet')), - ] - ), - - h( - DropdownMenuItem, - { - key: 'ropsten', - closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.handleClick('ropsten'), - style: dropdownMenuItemStyle, - }, - [ - providerType === 'ropsten' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), - h(NetworkDropdownIcon, { - backgroundColor: '#ff4a8d', // $wild-strawberry - isSelected: providerType === 'ropsten', - }), - h('span.network-name-item', { - style: { - color: providerType === 'ropsten' ? '#ffffff' : '#9b9b9b', - }, - }, this.context.t('ropsten')), - ] - ), - - h( - DropdownMenuItem, - { - key: 'kovan', - closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.handleClick('kovan'), - style: dropdownMenuItemStyle, - }, - [ - providerType === 'kovan' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), - h(NetworkDropdownIcon, { - backgroundColor: '#7057ff', // $cornflower-blue - isSelected: providerType === 'kovan', - }), - h('span.network-name-item', { - style: { - color: providerType === 'kovan' ? '#ffffff' : '#9b9b9b', - }, - }, this.context.t('kovan')), - ] - ), - - h( - DropdownMenuItem, - { - key: 'rinkeby', - closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.handleClick('rinkeby'), - style: dropdownMenuItemStyle, - }, - [ - providerType === 'rinkeby' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), - h(NetworkDropdownIcon, { - backgroundColor: '#f6c343', // $saffron - isSelected: providerType === 'rinkeby', - }), - h('span.network-name-item', { - style: { - color: providerType === 'rinkeby' ? '#ffffff' : '#9b9b9b', - }, - }, this.context.t('rinkeby')), - ] - ), - - h( - DropdownMenuItem, - { - key: 'default', - closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.handleClick('localhost'), - style: dropdownMenuItemStyle, - }, - [ - providerType === 'localhost' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), - h(NetworkDropdownIcon, { - isSelected: providerType === 'localhost', - innerBorder: '1px solid #9b9b9b', - }), - h('span.network-name-item', { - style: { - color: providerType === 'localhost' ? '#ffffff' : '#9b9b9b', - }, - }, this.context.t('localhost')), - ] - ), - - this.renderCustomOption(props.provider), - this.renderCommonRpc(rpcListDetail, props.provider), - - h( - DropdownMenuItem, - { - closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.props.history.push(SETTINGS_ROUTE), - style: dropdownMenuItemStyle, - }, - [ - activeNetwork === 'custom' ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), - h(NetworkDropdownIcon, { - isSelected: activeNetwork === 'custom', - innerBorder: '1px solid #9b9b9b', - }), - h('span.network-name-item', { - style: { - color: activeNetwork === 'custom' ? '#ffffff' : '#9b9b9b', - }, - }, this.context.t('customRPC')), - ] - ), - - ]) -} - -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 - 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 = provider.nickname || this.context.t('unknownNetwork') - } - - return name -} - -NetworkDropdown.prototype.renderCommonRpc = function (rpcListDetail, provider) { - const props = this.props - const reversedRpcListDetail = rpcListDetail.slice().reverse() - - return reversedRpcListDetail.map((entry) => { - const rpc = entry.rpcUrl - const ticker = entry.ticker || 'ETH' - const nickname = entry.nickname || '' - const currentRpcTarget = provider.type === 'rpc' && rpc === provider.rpcTarget - - if ((rpc === 'http://localhost:8545') || currentRpcTarget) { - return null - } else { - const chainId = entry.chainId - return h( - DropdownMenuItem, - { - key: `common${rpc}`, - closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setRpcTarget(rpc, chainId, ticker, nickname), - style: { - fontSize: '16px', - lineHeight: '20px', - padding: '12px 0', - }, - }, - [ - currentRpcTarget ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), - h('i.fa.fa-question-circle.fa-med.menu-icon-circle'), - h('span.network-name-item', { - style: { - color: currentRpcTarget ? '#ffffff' : '#9b9b9b', - }, - }, nickname || rpc), - h('i.fa.fa-times.delete', - { - onClick: (e) => { - e.stopPropagation() - props.delRpcTarget(rpc) - }, - }), - ] - ) - } - }) -} - -NetworkDropdown.prototype.renderCustomOption = function (provider) { - const { rpcTarget, type, ticker, nickname } = provider - const props = this.props - const network = props.network - - if (type !== 'rpc') return null - - switch (rpcTarget) { - - case 'http://localhost:8545': - return null - - default: - return h( - DropdownMenuItem, - { - key: rpcTarget, - onClick: () => props.setRpcTarget(rpcTarget, network, ticker, nickname), - closeMenu: () => this.props.hideNetworkDropdown(), - style: { - fontSize: '16px', - lineHeight: '20px', - padding: '12px 0', - }, - }, - [ - h('i.fa.fa-check'), - h('i.fa.fa-question-circle.fa-med.menu-icon-circle'), - h('span.network-name-item', { - style: { - color: '#ffffff', - }, - }, nickname || rpcTarget), - ] - ) - } -} diff --git a/ui/app/components/dropdowns/tests/network-dropdown.test.js b/ui/app/components/dropdowns/tests/network-dropdown.test.js deleted file mode 100644 index 88ad56851..000000000 --- a/ui/app/components/dropdowns/tests/network-dropdown.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { createMockStore } from 'redux-test-utils' -import { mountWithRouter } from '../../../../../test/lib/render-helpers' -import NetworkDropdown from '../network-dropdown' -import { DropdownMenuItem } from '../components/dropdown' -import NetworkDropdownIcon from '../components/network-dropdown-icon' - -describe('Network Dropdown', () => { - let wrapper - - describe('NetworkDropdown in appState in false', () => { - const mockState = { - metamask: { - provider: { - type: 'test', - }, - }, - appState: { - networkDropdown: false, - }, - } - - const store = createMockStore(mockState) - - beforeEach(() => { - wrapper = mountWithRouter( - <NetworkDropdown store={store} /> - ) - }) - - it('checks for network droppo class', () => { - assert.equal(wrapper.find('.network-droppo').length, 1) - }) - - it('renders only one child when networkDropdown is false in state', () => { - assert.equal(wrapper.children().length, 1) - }) - - }) - - describe('NetworkDropdown in appState is true', () => { - const mockState = { - metamask: { - provider: { - 'type': 'test', - }, - frequentRpcListDetail: [ - { rpcUrl: 'http://localhost:7545' }, - ], - }, - appState: { - 'networkDropdownOpen': true, - }, - } - const store = createMockStore(mockState) - - beforeEach(() => { - wrapper = mountWithRouter( - <NetworkDropdown store={store}/>, - ) - }) - - it('renders 7 DropDownMenuItems ', () => { - assert.equal(wrapper.find(DropdownMenuItem).length, 7) - }) - - it('checks background color for first NetworkDropdownIcon', () => { - assert.equal(wrapper.find(NetworkDropdownIcon).at(0).prop('backgroundColor'), '#29B6AF') // Main Ethereum Network Teal - }) - - it('checks background color for second NetworkDropdownIcon', () => { - assert.equal(wrapper.find(NetworkDropdownIcon).at(1).prop('backgroundColor'), '#ff4a8d') // Ropsten Red - }) - - it('checks background color for third NetworkDropdownIcon', () => { - assert.equal(wrapper.find(NetworkDropdownIcon).at(2).prop('backgroundColor'), '#7057ff') // Kovan Purple - }) - - it('checks background color for fourth NetworkDropdownIcon', () => { - assert.equal(wrapper.find(NetworkDropdownIcon).at(3).prop('backgroundColor'), '#f6c343') // Rinkeby Yellow - }) - - it('checks background color for fifth NetworkDropdownIcon', () => { - assert.equal(wrapper.find(NetworkDropdownIcon).at(4).prop('innerBorder'), '1px solid #9b9b9b') - }) - - it('checks dropdown for frequestRPCList from state ', () => { - assert.equal(wrapper.find(DropdownMenuItem).at(5).text(), '✓http://localhost:7545') - }) - - it('checks background color for sixth NetworkDropdownIcon', () => { - assert.equal(wrapper.find(NetworkDropdownIcon).at(5).prop('innerBorder'), '1px solid #9b9b9b') - }) - - }) -}) diff --git a/ui/app/components/dropdowns/token-menu-dropdown.js b/ui/app/components/dropdowns/token-menu-dropdown.js deleted file mode 100644 index 8a072b1bc..000000000 --- a/ui/app/components/dropdowns/token-menu-dropdown.js +++ /dev/null @@ -1,68 +0,0 @@ -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 -const actions = require('../../actions') -const genAccountLink = require('etherscan-link').createAccountLink -const { Menu, Item, CloseArea } = require('./components/menu') - -TokenMenuDropdown.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(TokenMenuDropdown) - -function mapStateToProps (state) { - return { - network: state.metamask.network, - } -} - -function mapDispatchToProps (dispatch) { - return { - showHideTokenConfirmationModal: (token) => { - dispatch(actions.showModal({ name: 'HIDE_TOKEN_CONFIRMATION', token })) - }, - } -} - - -inherits(TokenMenuDropdown, Component) -function TokenMenuDropdown () { - Component.call(this) - - this.onClose = this.onClose.bind(this) -} - -TokenMenuDropdown.prototype.onClose = function (e) { - e.stopPropagation() - this.props.onClose() -} - -TokenMenuDropdown.prototype.render = function () { - const { showHideTokenConfirmationModal } = this.props - - return h(Menu, { className: 'token-menu-dropdown', isShowing: true }, [ - h(CloseArea, { - onClick: this.onClose, - }), - h(Item, { - onClick: (e) => { - e.stopPropagation() - showHideTokenConfirmationModal(this.props.token) - this.props.onClose() - }, - text: this.context.t('hideToken'), - }), - h(Item, { - onClick: (e) => { - e.stopPropagation() - const url = genAccountLink(this.props.token.address, this.props.network) - global.platform.openWindow({ url }) - this.props.onClose() - }, - text: this.context.t('viewOnEtherscan'), - }), - ]) -} diff --git a/ui/app/components/ens-input.js b/ui/app/components/ens-input.js deleted file mode 100644 index a9167e3b2..000000000 --- a/ui/app/components/ens-input.js +++ /dev/null @@ -1,181 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const extend = require('xtend') -const debounce = require('debounce') -const copyToClipboard = require('copy-to-clipboard') -const ENS = require('ethjs-ens') -const networkMap = require('ethjs-ens/lib/network-map.json') -const ensRE = /.+\..+$/ -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') - -EnsInput.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(EnsInput) - - -inherits(EnsInput, Component) -function EnsInput () { - Component.call(this) -} - -EnsInput.prototype.onChange = function (recipient) { - - const network = this.props.network - const networkHasEnsSupport = getNetworkEnsSupport(network) - - this.props.onChange({ toAddress: recipient }) - - if (!networkHasEnsSupport) return - - if (recipient.match(ensRE) === null) { - return this.setState({ - loadingEns: false, - ensResolution: null, - ensFailure: null, - toError: null, - }) - } - - this.setState({ - loadingEns: true, - }) - this.checkName(recipient) -} - -EnsInput.prototype.render = function () { - const props = this.props - const opts = extend(props, { - list: 'addresses', - onChange: this.onChange.bind(this), - qrScanner: true, - }) - return h('div', { - style: { width: '100%', position: 'relative' }, - }, [ - h(ToAutoComplete, { ...opts }), - this.ensIcon(), - ]) -} - -EnsInput.prototype.componentDidMount = function () { - const network = this.props.network - const networkHasEnsSupport = getNetworkEnsSupport(network) - this.setState({ ensResolution: ZERO_ADDRESS }) - - if (networkHasEnsSupport) { - const provider = global.ethereumProvider - this.ens = new ENS({ provider, network }) - this.checkName = debounce(this.lookupEnsName.bind(this), 200) - } -} - -EnsInput.prototype.lookupEnsName = function (recipient) { - const { ensResolution } = this.state - - log.info(`ENS attempting to resolve name: ${recipient}`) - this.ens.lookup(recipient.trim()) - .then((address) => { - if (address === ZERO_ADDRESS) throw new Error(this.context.t('noAddressForName')) - if (address !== ensResolution) { - this.setState({ - loadingEns: false, - ensResolution: address, - nickname: recipient.trim(), - hoverText: address + '\n' + this.context.t('clickCopy'), - ensFailure: false, - toError: null, - }) - } - }) - .catch((reason) => { - const setStateObj = { - loadingEns: false, - ensResolution: recipient, - ensFailure: true, - toError: null, - } - if (isValidENSAddress(recipient) && reason.message === 'ENS name not defined.') { - setStateObj.hoverText = this.context.t('ensNameNotFound') - setStateObj.toError = 'ensNameNotFound' - setStateObj.ensFailure = false - } else { - log.error(reason) - setStateObj.hoverText = reason.message - } - - return this.setState(setStateObj) - }) -} - -EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) { - const state = this.state || {} - const ensResolution = state.ensResolution - // If an address is sent without a nickname, meaning not from ENS or from - // the user's own accounts, a default of a one-space string is used. - const nickname = state.nickname || ' ' - if (prevProps.network !== this.props.network) { - const provider = global.ethereumProvider - this.ens = new ENS({ provider, network: this.props.network }) - this.onChange(ensResolution) - } - if (prevState && ensResolution && this.props.onChange && - ensResolution !== prevState.ensResolution) { - this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError, toWarning: state.toWarning }) - } -} - -EnsInput.prototype.ensIcon = function (recipient) { - const { hoverText } = this.state || {} - return h('span.#ensIcon', { - title: hoverText, - style: { - position: 'absolute', - top: '16px', - left: '-25px', - }, - }, this.ensIconContents(recipient)) -} - -EnsInput.prototype.ensIconContents = function (recipient) { - const { loadingEns, ensFailure, ensResolution, toError } = this.state || { ensResolution: ZERO_ADDRESS } - - if (toError) return - - if (loadingEns) { - return h('img', { - src: 'images/loading.svg', - style: { - width: '30px', - height: '30px', - transform: 'translateY(-6px)', - }, - }) - } - - if (ensFailure) { - return h('i.fa.fa-warning.fa-lg.warning') - } - - if (ensResolution && (ensResolution !== ZERO_ADDRESS)) { - return h('i.fa.fa-check-circle.fa-lg.cursor-pointer', { - style: { color: 'green' }, - onClick: (event) => { - event.preventDefault() - event.stopPropagation() - copyToClipboard(ensResolution) - }, - }) - } -} - -function getNetworkEnsSupport (network) { - return Boolean(networkMap[network]) -} diff --git a/ui/app/components/eth-balance.js b/ui/app/components/eth-balance.js deleted file mode 100644 index 2f6395a2d..000000000 --- a/ui/app/components/eth-balance.js +++ /dev/null @@ -1,102 +0,0 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const { inherits } = require('util') -const { - formatBalance, - generateBalanceObject, -} = require('../util') -const Tooltip = require('./tooltip.js') -const FiatValue = require('./fiat-value.js') - -module.exports = connect(mapStateToProps)(EthBalanceComponent) -function mapStateToProps (state) { - return { - ticker: state.metamask.ticker, - } -} - -inherits(EthBalanceComponent, Component) -function EthBalanceComponent () { - Component.call(this) -} - -EthBalanceComponent.prototype.render = function () { - const props = this.props - const { ticker, value, style, width, needsParse = true } = props - - const formattedValue = value ? formatBalance(value, 6, needsParse, ticker) : '...' - - return ( - - h('.ether-balance.ether-balance-amount', { - style, - }, [ - h('div', { - style: { - display: 'inline', - width, - }, - }, this.renderBalance(formattedValue)), - ]) - - ) -} -EthBalanceComponent.prototype.renderBalance = function (value) { - if (value === 'None') return value - if (value === '...') return value - - const { - conversionRate, - shorten, - incoming, - currentCurrency, - hideTooltip, - styleOveride = {}, - showFiat = true, - } = this.props - const { fontSize, color, fontFamily, lineHeight } = styleOveride - - const { shortBalance, balance, label } = generateBalanceObject(value, shorten ? 1 : 3) - const balanceToRender = shorten ? shortBalance : balance - - const [ethNumber, ethSuffix] = value.split(' ') - const containerProps = hideTooltip ? {} : { - position: 'bottom', - title: `${ethNumber} ${ethSuffix}`, - } - - return ( - h(hideTooltip ? 'div' : Tooltip, - containerProps, - h('div.flex-column', [ - h('.flex-row', { - style: { - alignItems: 'flex-end', - lineHeight: lineHeight || '13px', - fontFamily: fontFamily || 'Montserrat Light', - textRendering: 'geometricPrecision', - }, - }, [ - h('div', { - style: { - width: '100%', - textAlign: 'right', - fontSize: fontSize || 'inherit', - color: color || 'inherit', - }, - }, incoming ? `+${balanceToRender}` : balanceToRender), - h('div', { - style: { - color: color || '#AEAEAE', - fontSize: fontSize || '12px', - marginLeft: '5px', - }, - }, label), - ]), - - showFiat ? h(FiatValue, { value: this.props.value, conversionRate, currentCurrency }) : null, - ]) - ) - ) -} diff --git a/ui/app/components/export-text-container/export-text-container.component.js b/ui/app/components/export-text-container/export-text-container.component.js deleted file mode 100644 index c2546fa9b..000000000 --- a/ui/app/components/export-text-container/export-text-container.component.js +++ /dev/null @@ -1,45 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const copyToClipboard = require('copy-to-clipboard') -const { exportAsFile } = require('../../util') - -class ExportTextContainer extends Component { - render () { - const { text = '', filename = '' } = this.props - const { t } = this.context - - return ( - h('.export-text-container', [ - h('.export-text-container__text-container', [ - h('.export-text-container__text', text), - ]), - h('.export-text-container__buttons-container', [ - h('.export-text-container__button.export-text-container__button--copy', { - onClick: () => copyToClipboard(text), - }, [ - h('img', { src: 'images/copy-to-clipboard.svg' }), - h('.export-text-container__button-text', t('copyToClipboard')), - ]), - h('.export-text-container__button', { - onClick: () => exportAsFile(filename, text), - }, [ - h('img', { src: 'images/download.svg' }), - h('.export-text-container__button-text', t('saveAsCsvFile')), - ]), - ]), - ]) - ) - } -} - -ExportTextContainer.propTypes = { - text: PropTypes.string, - filename: PropTypes.string, -} - -ExportTextContainer.contextTypes = { - t: PropTypes.func, -} - -module.exports = ExportTextContainer diff --git a/ui/app/components/fiat-value.js b/ui/app/components/fiat-value.js deleted file mode 100644 index 56465fc9d..000000000 --- a/ui/app/components/fiat-value.js +++ /dev/null @@ -1,66 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const formatBalance = require('../util').formatBalance - -module.exports = FiatValue - -inherits(FiatValue, Component) -function FiatValue () { - Component.call(this) -} - -FiatValue.prototype.render = function () { - const props = this.props - const { conversionRate, currentCurrency, style } = props - const renderedCurrency = currentCurrency || '' - - const value = formatBalance(props.value, 6) - - if (value === 'None') return value - var fiatDisplayNumber, fiatTooltipNumber - var splitBalance = value.split(' ') - - if (conversionRate !== 0) { - fiatTooltipNumber = Number(splitBalance[0]) * conversionRate - fiatDisplayNumber = fiatTooltipNumber.toFixed(2) - } else { - fiatDisplayNumber = 'N/A' - fiatTooltipNumber = 'Unknown' - } - - return fiatDisplay(fiatDisplayNumber, renderedCurrency.toUpperCase(), style) -} - -function fiatDisplay (fiatDisplayNumber, fiatSuffix, styleOveride = {}) { - const { fontSize, color, fontFamily, lineHeight } = styleOveride - - if (fiatDisplayNumber !== 'N/A') { - return h('.flex-row', { - style: { - alignItems: 'flex-end', - lineHeight: lineHeight || '13px', - fontFamily: fontFamily || 'Montserrat Light', - textRendering: 'geometricPrecision', - }, - }, [ - h('div', { - style: { - width: '100%', - textAlign: 'right', - fontSize: fontSize || '12px', - color: color || '#333333', - }, - }, fiatDisplayNumber), - h('div', { - style: { - color: color || '#AEAEAE', - marginLeft: '5px', - fontSize: fontSize || '12px', - }, - }, fiatSuffix), - ]) - } else { - return h('div') - } -} diff --git a/ui/app/components/gas-customization/advanced-gas-inputs/advanced-gas-inputs.container.js b/ui/app/components/gas-customization/advanced-gas-inputs/advanced-gas-inputs.container.js deleted file mode 100644 index a71d37b43..000000000 --- a/ui/app/components/gas-customization/advanced-gas-inputs/advanced-gas-inputs.container.js +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux' -import { showModal } from '../../../actions' -import { - decGWEIToHexWEI, - decimalToHex, - hexWEIToDecGWEI, -} from '../../../helpers/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/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js deleted file mode 100644 index a3a3f96d8..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js +++ /dev/null @@ -1,219 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import Loading from '../../../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, - } - - 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, - } = 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, - }) } - <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> - ) - } -} diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js deleted file mode 100644 index 2500ee267..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js +++ /dev/null @@ -1,364 +0,0 @@ -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 '../../../../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} - />, { 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).hasClass('advanced-tab__fee-chart__title')) - assert(feeChartDiv.childAt(2).is(GasPriceChart)) - assert(feeChartDiv.childAt(3).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).hasClass('advanced-tab__fee-chart__title')) - assert(feeChartDiv.childAt(2).is(Loading)) - assert(feeChartDiv.childAt(3).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/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js deleted file mode 100644 index d8490272f..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js +++ /dev/null @@ -1,30 +0,0 @@ -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/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js deleted file mode 100644 index 05b8f700b..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js +++ /dev/null @@ -1,35 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import Loading from '../../../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/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js deleted file mode 100644 index 47864fcab..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js +++ /dev/null @@ -1,82 +0,0 @@ -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 '../../../../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/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js b/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js deleted file mode 100644 index 174bd8ea8..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js +++ /dev/null @@ -1,186 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import PageContainer from '../../page-container' -import { Tabs, Tab } from '../../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={'Close'} - hideCancel={true} - /> - </div> - ) - } -} diff --git a/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js deleted file mode 100644 index 6692fb363..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js +++ /dev/null @@ -1,291 +0,0 @@ -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 '../../../actions' -import { - setCustomGasPrice, - setCustomGasLimit, - resetCustomData, - setCustomTimeEstimate, - fetchGasEstimates, - fetchBasicGasAndTimeEstimates, -} from '../../../ducks/gas.duck' -import { - hideGasButtonGroup, -} from '../../../ducks/send.duck' -import { - updateGasAndCalculate, -} from '../../../ducks/confirm-transaction.duck' -import { - getCurrentCurrency, - conversionRateSelector as getConversionRate, - getSelectedToken, - getCurrentEthBalance, -} from '../../../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/confirm-transaction/util' -import { - addHexWEIsToDec, - decEthToConvertedCurrency as ethTotalToConvertedCurrency, - decGWEIToHexWEI, - hexWEIToDecGWEI, -} from '../../../helpers/conversions.util' -import { - formatETHFee, -} from '../../../helpers/formatters' -import { - calcGasTotal, - isBalanceSufficient, -} from '../../send/send.utils' -import { addHexPrefix } from 'ethereumjs-util' -import { getAdjacentGasPrices, extrapolateY } from '../gas-price-chart/gas-price-chart.utils' -import {getIsMainnet, preferencesSelector} from '../../../selectors' - -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, - } -} - -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/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js deleted file mode 100644 index 1761ad2b0..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js +++ /dev/null @@ -1,274 +0,0 @@ -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 '../../../page-container' - -import { Tab } from '../../../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/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js b/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js deleted file mode 100644 index fb6a01fff..000000000 --- a/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js +++ /dev/null @@ -1,425 +0,0 @@ -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, - }, - '../../../actions': actionSpies, - '../../../ducks/gas.duck': gasActionSpies, - '../../../ducks/confirm-transaction.duck': confirmTransactionActionSpies, - '../../../ducks/send.duck': sendActionSpies, - '../../../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, - } - 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: '', - }, - }, - }, - { - mockState: Object.assign({}, baseMockState, { - metamask: { - ...baseMockState.metamask, - preferences: { - ...baseMockState.metamask.preferences, - showFiatInTestnets: true, - }, - provider: { - ...baseMockState.metamask.provider, - type: 'rinkeby', - }, - }, - }), - mockOwnProps: baseMockOwnProps, - expectedResult: baseExpectedResult, - }, - { - 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/gas-customization/gas-price-button-group/gas-price-button-group.component.js b/ui/app/components/gas-customization/gas-price-button-group/gas-price-button-group.component.js deleted file mode 100644 index 8ad063b21..000000000 --- a/ui/app/components/gas-customization/gas-price-button-group/gas-price-button-group.component.js +++ /dev/null @@ -1,89 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import ButtonGroup from '../../button-group' -import Button from '../../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/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js b/ui/app/components/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js deleted file mode 100644 index 79f74f8e4..000000000 --- a/ui/app/components/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js +++ /dev/null @@ -1,233 +0,0 @@ -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 '../../../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/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js b/ui/app/components/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js deleted file mode 100644 index 74eddae42..000000000 --- a/ui/app/components/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js +++ /dev/null @@ -1,218 +0,0 @@ -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/hex-to-decimal/hex-to-decimal.component.js b/ui/app/components/hex-to-decimal/hex-to-decimal.component.js deleted file mode 100644 index 6847a6919..000000000 --- a/ui/app/components/hex-to-decimal/hex-to-decimal.component.js +++ /dev/null @@ -1,21 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import { hexToDecimal } from '../../helpers/conversions.util' - -export default class HexToDecimal extends PureComponent { - static propTypes = { - className: PropTypes.string, - value: PropTypes.string, - } - - render () { - const { className, value } = this.props - const decimalValue = hexToDecimal(value) - - return ( - <div className={className}> - { decimalValue } - </div> - ) - } -} diff --git a/ui/app/components/identicon/identicon.component.js b/ui/app/components/identicon/identicon.component.js deleted file mode 100644 index b892e5ae5..000000000 --- a/ui/app/components/identicon/identicon.component.js +++ /dev/null @@ -1,99 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { toDataUrl } from '../../../lib/blockies' -import contractMap from 'eth-contract-metadata' -import { checksumAddress } from '../../../app/util' -import Jazzicon from '../jazzicon' - -const getStyles = diameter => ( - { - height: diameter, - width: diameter, - borderRadius: diameter / 2, - } -) - -export default class Identicon extends PureComponent { - static propTypes = { - address: PropTypes.string, - className: PropTypes.string, - diameter: PropTypes.number, - image: PropTypes.string, - useBlockie: PropTypes.bool, - } - - static defaultProps = { - diameter: 46, - } - - renderImage () { - const { className, diameter, image } = this.props - - return ( - <img - className={classnames('identicon', className)} - src={image} - style={getStyles(diameter)} - /> - ) - } - - renderJazzicon () { - const { address, className, diameter } = this.props - - return ( - <Jazzicon - address={address} - diameter={diameter} - className={classnames('identicon', className)} - style={getStyles(diameter)} - /> - ) - } - - renderBlockie () { - const { address, className, diameter } = this.props - - return ( - <div - className={classnames('identicon', className)} - style={getStyles(diameter)} - > - <img - src={toDataUrl(address)} - height={diameter} - width={diameter} - /> - </div> - ) - } - - render () { - const { className, address, image, diameter, useBlockie } = this.props - - if (image) { - return this.renderImage() - } - - if (address) { - const checksummedAddress = checksumAddress(address) - - if (contractMap[checksummedAddress] && contractMap[checksummedAddress].logo) { - return this.renderJazzicon() - } - - return useBlockie - ? this.renderBlockie() - : this.renderJazzicon() - } - - return ( - <img - className={classnames('balance-icon', className)} - src="./images/eth_logo.svg" - style={getStyles(diameter)} - /> - ) - } -} diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss deleted file mode 100644 index 96cc74c79..000000000 --- a/ui/app/components/index.scss +++ /dev/null @@ -1,81 +0,0 @@ -@import './account-menu/index'; - -@import './add-token-button/index'; - -@import './app-header/index'; - -@import './breadcrumbs/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 './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 './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/input-number.js b/ui/app/components/input-number.js deleted file mode 100644 index eec5e3740..000000000 --- a/ui/app/components/input-number.js +++ /dev/null @@ -1,81 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const { - addCurrencies, - conversionGTE, - conversionLTE, - subtractCurrencies, -} = require('../conversion-util') - -module.exports = InputNumber - -inherits(InputNumber, Component) -function InputNumber () { - Component.call(this) - - this.setValue = this.setValue.bind(this) -} - -function isValidInput (text) { - const re = /^([1-9]\d*|0)(\.|\.\d*)?$/ - return re.test(text) -} - -function removeLeadingZeroes (str) { - return str.replace(/^0*(?=\d)/, '') -} - -InputNumber.prototype.setValue = function (newValue) { - newValue = removeLeadingZeroes(newValue) - if (newValue && !isValidInput(newValue)) return - const { fixed, min = -1, max = Infinity, onChange } = this.props - - newValue = fixed ? newValue.toFixed(4) : newValue - const newValueGreaterThanMin = conversionGTE( - { value: newValue || '0', fromNumericBase: 'dec' }, - { value: min, fromNumericBase: 'hex' }, - ) - - const newValueLessThanMax = conversionLTE( - { value: newValue || '0', fromNumericBase: 'dec' }, - { value: max, fromNumericBase: 'hex' }, - ) - if (newValueGreaterThanMin && newValueLessThanMax) { - onChange(newValue) - } else if (!newValueGreaterThanMin) { - onChange(min) - } else if (!newValueLessThanMax) { - onChange(max) - } -} - -InputNumber.prototype.render = function () { - const { unitLabel, step = 1, placeholder, value } = this.props - - return h('div.customize-gas-input-wrapper', {}, [ - h('input', { - className: 'customize-gas-input', - value, - placeholder, - type: 'number', - onChange: e => { - this.setValue(e.target.value) - }, - min: 0, - }), - h('span.gas-tooltip-input-detail', {}, [unitLabel]), - h('div.gas-tooltip-input-arrows', {}, [ - h('div.gas-tooltip-input-arrow-wrapper', { - onClick: () => this.setValue(addCurrencies(value, step, { toNumericBase: 'dec' })), - }, [ - h('i.fa.fa-angle-up'), - ]), - h('div.gas-tooltip-input-arrow-wrapper', { - onClick: () => this.setValue(subtractCurrencies(value, step, { toNumericBase: 'dec' })), - }, [ - h('i.fa.fa-angle-down'), - ]), - ]), - ]) -} diff --git a/ui/app/components/jazzicon/jazzicon.component.js b/ui/app/components/jazzicon/jazzicon.component.js deleted file mode 100644 index fcb1c59b1..000000000 --- a/ui/app/components/jazzicon/jazzicon.component.js +++ /dev/null @@ -1,69 +0,0 @@ -import React, { PureComponent } from 'react' -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' -const iconFactory = iconFactoryGenerator(jazzicon) - -/** - * Wrapper around the jazzicon library to return a React component, as the library returns an - * HTMLDivElement which needs to be appended. - */ -export default class Jazzicon extends PureComponent { - static propTypes = { - address: PropTypes.string.isRequired, - className: PropTypes.string, - diameter: PropTypes.number, - style: PropTypes.object, - } - - static defaultProps = { - diameter: 46, - } - - componentDidMount () { - if (!isNode) { - this.appendJazzicon() - } - } - - componentDidUpdate (prevProps) { - const { address: prevAddress } = prevProps - const { address } = this.props - - if (!isNode && address !== prevAddress) { - this.removeExistingChildren() - this.appendJazzicon() - } - } - - removeExistingChildren () { - // eslint-disable-next-line react/no-find-dom-node - const container = findDOMNode(this) - const { children } = container - - for (let i = 0; i < children.length; i++) { - container.removeChild(children[i]) - } - } - - appendJazzicon () { - // eslint-disable-next-line react/no-find-dom-node - const container = findDOMNode(this) - const { address, diameter } = this.props - const image = iconFactory.iconForAddress(address, diameter) - container.appendChild(image) - } - - render () { - const { className, style } = this.props - - return ( - <div - className={className} - style={style} - /> - ) - } -} diff --git a/ui/app/components/loading-network-screen/loading-network-screen.component.js b/ui/app/components/loading-network-screen/loading-network-screen.component.js deleted file mode 100644 index bf1c141e0..000000000 --- a/ui/app/components/loading-network-screen/loading-network-screen.component.js +++ /dev/null @@ -1,138 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Spinner from '../spinner' -import Button from '../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/loading-network-screen/loading-network-screen.container.js b/ui/app/components/loading-network-screen/loading-network-screen.container.js deleted file mode 100644 index d0623e574..000000000 --- a/ui/app/components/loading-network-screen/loading-network-screen.container.js +++ /dev/null @@ -1,41 +0,0 @@ -import { connect } from 'react-redux' -import LoadingNetworkScreen from './loading-network-screen.component' -import actions from '../../actions' -import { getNetworkIdentifier } from '../../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/menu-bar.component.js b/ui/app/components/menu-bar/menu-bar.component.js deleted file mode 100644 index 29c56953d..000000000 --- a/ui/app/components/menu-bar/menu-bar.component.js +++ /dev/null @@ -1,79 +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, - 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/menu-bar/menu-bar.container.js deleted file mode 100644 index 0305f17d3..000000000 --- a/ui/app/components/menu-bar/menu-bar.container.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux' -import { WALLET_VIEW_SIDEBAR } from '../sidebars/sidebar.constants' -import MenuBar from './menu-bar.component' -import { showSidebar, hideSidebar } from '../../actions' - -const mapStateToProps = state => { - const { appState: { sidebar: { isOpen } } } = state - - return { - sidebarOpen: isOpen, - } -} - -const mapDispatchToProps = dispatch => { - return { - showSidebar: () => { - dispatch(showSidebar({ - transitionName: 'sidebar-right', - type: WALLET_VIEW_SIDEBAR, - })) - }, - hideSidebar: () => dispatch(hideSidebar()), - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(MenuBar) diff --git a/ui/app/components/modal/index.scss b/ui/app/components/modal/index.scss deleted file mode 100644 index 2beb14633..000000000 --- a/ui/app/components/modal/index.scss +++ /dev/null @@ -1,62 +0,0 @@ -@import './modal-content/index'; - -.modal-container { - width: 100%; - height: 100%; - background-color: #fff; - display: flex; - flex-flow: column; - border-radius: 8px; - - @media screen and (max-width: 575px) { - max-height: 450px; - } - - &__content { - overflow-y: auto; - flex: 1; - padding: 16px 32px; - - @media screen and (max-width: 575px) { - justify-content: center; - padding: 28px 20px; - } - } - - &__header { - position: relative; - display: flex; - padding: 12px; - justify-content: center; - border-bottom: 1px solid #d2d8dd; - flex: 0 0 auto; - } - - &__header-close::after { - content: '\00D7'; - font-size: 40px; - color: $dusty-gray; - position: absolute; - top: -5px; - right: 10px; - cursor: pointer; - } - - &__footer { - display: flex; - flex-flow: row; - justify-content: center; - border-top: 1px solid #d2d8dd; - padding: 16px; - flex: 0 0 auto; - - &-button { - min-width: 0; - margin-right: 16px; - - &:last-of-type { - margin-right: 0; - } - } - } -} diff --git a/ui/app/components/modal/modal.component.js b/ui/app/components/modal/modal.component.js deleted file mode 100644 index c73f8d903..000000000 --- a/ui/app/components/modal/modal.component.js +++ /dev/null @@ -1,83 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Button from '../button' - -export default class Modal extends PureComponent { - static propTypes = { - children: PropTypes.node, - // Header text - headerText: PropTypes.string, - onClose: PropTypes.func, - // Submit button (right button) - onSubmit: PropTypes.func, - submitType: PropTypes.string, - submitText: PropTypes.string, - submitDisabled: PropTypes.bool, - // Cancel button (left button) - onCancel: PropTypes.func, - cancelType: PropTypes.string, - cancelText: PropTypes.string, - } - - static defaultProps = { - submitType: 'primary', - cancelType: 'default', - } - - render () { - const { - children, - headerText, - onClose, - onSubmit, - submitType, - submitText, - submitDisabled, - onCancel, - cancelType, - cancelText, - } = this.props - - return ( - <div className="modal-container"> - { - headerText && ( - <div className="modal-container__header"> - <div className="modal-container__header-text"> - { headerText } - </div> - <div - className="modal-container__header-close" - onClick={onClose} - /> - </div> - ) - } - <div className="modal-container__content"> - { children } - </div> - <div className="modal-container__footer"> - { - onCancel && ( - <Button - type={cancelType} - onClick={onCancel} - className="modal-container__footer-button" - > - { cancelText } - </Button> - ) - } - <Button - type={submitType} - onClick={onSubmit} - disabled={submitDisabled} - className="modal-container__footer-button" - > - { submitText } - </Button> - </div> - </div> - ) - } -} diff --git a/ui/app/components/modal/tests/modal.component.test.js b/ui/app/components/modal/tests/modal.component.test.js deleted file mode 100644 index 2ced3f32d..000000000 --- a/ui/app/components/modal/tests/modal.component.test.js +++ /dev/null @@ -1,133 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { mount, shallow } from 'enzyme' -import sinon from 'sinon' -import Modal from '../modal.component' -import Button from '../../button' - -describe('Modal Component', () => { - it('should render a modal with a submit button', () => { - const wrapper = shallow(<Modal />) - - assert.equal(wrapper.find('.modal-container').length, 1) - const buttons = wrapper.find(Button) - assert.equal(buttons.length, 1) - assert.equal(buttons.at(0).props().type, 'primary') - }) - - it('should render a modal with a cancel and a submit button', () => { - const handleCancel = sinon.spy() - const handleSubmit = sinon.spy() - const wrapper = shallow( - <Modal - onCancel={handleCancel} - cancelText="Cancel" - onSubmit={handleSubmit} - submitText="Submit" - /> - ) - - const buttons = wrapper.find(Button) - assert.equal(buttons.length, 2) - const cancelButton = buttons.at(0) - const submitButton = buttons.at(1) - - assert.equal(cancelButton.props().type, 'default') - assert.equal(cancelButton.props().children, 'Cancel') - assert.equal(handleCancel.callCount, 0) - cancelButton.simulate('click') - assert.equal(handleCancel.callCount, 1) - - assert.equal(submitButton.props().type, 'primary') - assert.equal(submitButton.props().children, 'Submit') - assert.equal(handleSubmit.callCount, 0) - submitButton.simulate('click') - assert.equal(handleSubmit.callCount, 1) - }) - - it('should render a modal with different button types', () => { - const wrapper = shallow( - <Modal - onCancel={() => {}} - cancelText="Cancel" - cancelType="secondary" - onSubmit={() => {}} - submitText="Submit" - submitType="confirm" - /> - ) - - const buttons = wrapper.find(Button) - assert.equal(buttons.length, 2) - assert.equal(buttons.at(0).props().type, 'secondary') - assert.equal(buttons.at(1).props().type, 'confirm') - }) - - it('should render a modal with children', () => { - const wrapper = shallow( - <Modal - onCancel={() => {}} - cancelText="Cancel" - onSubmit={() => {}} - submitText="Submit" - > - <div className="test-child" /> - </Modal> - ) - - assert.ok(wrapper.find('.test-class')) - }) - - it('should render a modal with a header', () => { - const handleCancel = sinon.spy() - const handleSubmit = sinon.spy() - const wrapper = shallow( - <Modal - onCancel={handleCancel} - cancelText="Cancel" - onSubmit={handleSubmit} - submitText="Submit" - headerText="My Header" - onClose={handleCancel} - /> - ) - - assert.ok(wrapper.find('.modal-container__header')) - assert.equal(wrapper.find('.modal-container__header-text').text(), 'My Header') - assert.equal(handleCancel.callCount, 0) - assert.equal(handleSubmit.callCount, 0) - wrapper.find('.modal-container__header-close').simulate('click') - 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/modals/account-details-modal.js deleted file mode 100644 index 67d8eb0fd..000000000 --- a/ui/app/components/modals/account-details-modal.js +++ /dev/null @@ -1,101 +0,0 @@ -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 -const actions = require('../../actions') -const AccountModalContainer = require('./account-modal-container') -const { getSelectedIdentity } = require('../../selectors') -const genAccountLink = require('../../../lib/account-link.js') -const QrView = require('../qr-code') -const EditableLabel = require('../editable-label') - -import Button from '../button' - -function mapStateToProps (state) { - return { - network: state.metamask.network, - selectedIdentity: getSelectedIdentity(state), - keyrings: state.metamask.keyrings, - } -} - -function mapDispatchToProps (dispatch) { - return { - // Is this supposed to be used somewhere? - showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), - showExportPrivateKeyModal: () => { - dispatch(actions.showModal({ name: 'EXPORT_PRIVATE_KEY' })) - }, - hideModal: () => dispatch(actions.hideModal()), - setAccountLabel: (address, label) => dispatch(actions.setAccountLabel(address, label)), - } -} - -inherits(AccountDetailsModal, Component) -function AccountDetailsModal () { - Component.call(this) -} - -AccountDetailsModal.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDetailsModal) - - -// Not yet pixel perfect todos: - // fonts of qr-header - -AccountDetailsModal.prototype.render = function () { - const { - selectedIdentity, - network, - showExportPrivateKeyModal, - setAccountLabel, - keyrings, - } = this.props - const { name, address } = selectedIdentity - - const keyring = keyrings.find((kr) => { - return kr.accounts.includes(address) - }) - - let exportPrivateKeyFeatureEnabled = true - // This feature is disabled for hardware wallets - if (keyring && keyring.type.search('Hardware') !== -1) { - exportPrivateKeyFeatureEnabled = false - } - - return h(AccountModalContainer, {}, [ - h(EditableLabel, { - className: 'account-modal__name', - defaultValue: name, - onSubmit: label => setAccountLabel(address, label), - }), - - h(QrView, { - Qr: { - data: address, - network: network, - }, - }), - - h('div.account-modal-divider'), - - h(Button, { - type: 'primary', - className: 'account-modal__button', - onClick: () => global.platform.openWindow({ url: genAccountLink(address, network) }), - }, this.context.t('etherscanView')), - - // Holding on redesign for Export Private Key functionality - - exportPrivateKeyFeatureEnabled ? h(Button, { - type: 'primary', - className: 'account-modal__button', - onClick: () => showExportPrivateKeyModal(), - }, this.context.t('exportPrivateKey')) : null, - - ]) -} diff --git a/ui/app/components/modals/account-modal-container.js b/ui/app/components/modals/account-modal-container.js deleted file mode 100644 index 2a6c655e1..000000000 --- a/ui/app/components/modals/account-modal-container.js +++ /dev/null @@ -1,80 +0,0 @@ -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 -const actions = require('../../actions') -const { getSelectedIdentity } = require('../../selectors') -import Identicon from '../identicon' - -function mapStateToProps (state, ownProps) { - return { - selectedIdentity: ownProps.selectedIdentity || getSelectedIdentity(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - hideModal: () => { - dispatch(actions.hideModal()) - }, - } -} - -inherits(AccountModalContainer, Component) -function AccountModalContainer () { - Component.call(this) -} - -AccountModalContainer.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountModalContainer) - - -AccountModalContainer.prototype.render = function () { - const { - selectedIdentity, - showBackButton = false, - backButtonAction, - } = this.props - let { children } = this.props - - if (children.constructor !== Array) { - children = [children] - } - - return h('div', { style: { borderRadius: '4px' }}, [ - h('div.account-modal-container', [ - - h('div', [ - - // Needs a border; requires changes to svg - h(Identicon, { - address: selectedIdentity.address, - diameter: 64, - style: {}, - }), - - ]), - - showBackButton && h('div.account-modal-back', { - onClick: backButtonAction, - }, [ - - h('i.fa.fa-angle-left.fa-lg'), - - h('span.account-modal-back__text', ' ' + this.context.t('back')), - - ]), - - h('div.account-modal-close', { - onClick: this.props.hideModal, - }), - - ...children, - - ]), - ]) -} diff --git a/ui/app/components/modals/buy-options-modal.js b/ui/app/components/modals/buy-options-modal.js deleted file mode 100644 index c70510b5f..000000000 --- a/ui/app/components/modals/buy-options-modal.js +++ /dev/null @@ -1,101 +0,0 @@ -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 -const actions = require('../../actions') -const { getNetworkDisplayName } = require('../../../../app/scripts/controllers/network/util') - -function mapStateToProps (state) { - return { - network: state.metamask.network, - address: state.metamask.selectedAddress, - } -} - -function mapDispatchToProps (dispatch) { - return { - toCoinbase: (address) => { - dispatch(actions.buyEth({ network: '1', address, amount: 0 })) - }, - hideModal: () => { - dispatch(actions.hideModal()) - }, - showAccountDetailModal: () => { - dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) - }, - toFaucet: network => dispatch(actions.buyEth({ network })), - } -} - -inherits(BuyOptions, Component) -function BuyOptions () { - Component.call(this) -} - -BuyOptions.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(BuyOptions) - - -BuyOptions.prototype.renderModalContentOption = function (title, header, onClick) { - return h('div.buy-modal-content-option', { - onClick, - }, [ - h('div.buy-modal-content-option-title', {}, title), - h('div.buy-modal-content-option-subtitle', {}, header), - ]) -} - -BuyOptions.prototype.render = function () { - const { network, toCoinbase, address, toFaucet } = this.props - const isTestNetwork = ['3', '4', '42'].find(n => n === network) - const networkName = getNetworkDisplayName(network) - - return h('div', {}, [ - h('div.buy-modal-content.transfers-subview', { - }, [ - h('div.buy-modal-content-title-wrapper.flex-column.flex-center', { - style: {}, - }, [ - h('div.buy-modal-content-title', { - style: {}, - }, this.context.t('transfers')), - h('div', {}, this.context.t('howToDeposit')), - ]), - - h('div.buy-modal-content-options.flex-column.flex-center', {}, [ - - isTestNetwork - ? this.renderModalContentOption(networkName, this.context.t('testFaucet'), () => toFaucet(network)) - : this.renderModalContentOption('Coinbase', this.context.t('depositFiat'), () => toCoinbase(address)), - - // h('div.buy-modal-content-option', {}, [ - // h('div.buy-modal-content-option-title', {}, 'Shapeshift'), - // h('div.buy-modal-content-option-subtitle', {}, 'Trade any digital asset for any other'), - // ]),, - - this.renderModalContentOption( - this.context.t('directDeposit'), - this.context.t('depositFromAccount'), - () => this.goToAccountDetailsModal() - ), - - ]), - - h('button', { - style: { - background: 'white', - }, - onClick: () => { this.props.hideModal() }, - }, h('div.buy-modal-content-footer#buy-modal-content-footer-text', {}, this.context.t('cancel'))), - ]), - ]) -} - -BuyOptions.prototype.goToAccountDetailsModal = function () { - this.props.hideModal() - this.props.showAccountDetailModal() -} diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js deleted file mode 100644 index b973f221c..000000000 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../../constants/common' - -export default class CancelTransaction extends PureComponent { - static propTypes = { - value: PropTypes.string, - } - - render () { - const { value } = this.props - - return ( - <div className="cancel-transaction-gas-fee"> - <UserPreferencedCurrencyDisplay - className="cancel-transaction-gas-fee__eth" - value={value} - type={PRIMARY} - /> - <UserPreferencedCurrencyDisplay - className="cancel-transaction-gas-fee__fiat" - value={value} - type={SECONDARY} - /> - </div> - ) - } -} diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js b/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js deleted file mode 100644 index 8fd7b2679..000000000 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js +++ /dev/null @@ -1,76 +0,0 @@ -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' - -export default class CancelTransaction extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - createCancelTransaction: PropTypes.func, - hideModal: PropTypes.func, - showTransactionConfirmedModal: PropTypes.func, - transactionStatus: PropTypes.string, - newGasFee: PropTypes.string, - } - - state = { - busy: false, - } - - componentDidUpdate () { - const { transactionStatus, showTransactionConfirmedModal } = this.props - - if (transactionStatus !== SUBMITTED_STATUS) { - showTransactionConfirmedModal() - return - } - } - - handleSubmit = async () => { - const { createCancelTransaction, hideModal } = this.props - - this.setState({ busy: true }) - - await createCancelTransaction() - this.setState({ busy: false }, () => hideModal()) - } - - handleCancel = () => { - this.props.hideModal() - } - - render () { - const { t } = this.context - const { newGasFee } = this.props - const { busy } = this.state - - return ( - <Modal - headerText={t('attemptToCancel')} - onClose={this.handleCancel} - onSubmit={this.handleSubmit} - onCancel={this.handleCancel} - submitText={t('yesLetsTry')} - cancelText={t('nevermind')} - submitType="secondary" - submitDisabled={busy} - > - <div> - <div className="cancel-transaction__title"> - { t('cancellationGasFee') } - </div> - <div className="cancel-transaction__cancel-transaction-gas-fee-container"> - <CancelTransactionGasFee value={newGasFee} /> - </div> - <div className="cancel-transaction__description"> - { t('attemptToCancelDescription') } - </div> - </div> - </Modal> - ) - } -} diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js b/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js deleted file mode 100644 index 10931a001..000000000 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js +++ /dev/null @@ -1,60 +0,0 @@ -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 CancelTransaction from './cancel-transaction.component' -import { showModal, createCancelTransaction } from '../../../actions' -import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' - -const mapStateToProps = (state, ownProps) => { - const { metamask } = state - const { transactionId, originalGasPrice } = ownProps - const { selectedAddressTxList } = metamask - const transaction = selectedAddressTxList.find(({ id }) => id === transactionId) - const transactionStatus = transaction ? transaction.status : '' - - const defaultNewGasPrice = ethUtil.addHexPrefix( - multiplyCurrencies(originalGasPrice, 1.1, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - }) - ) - - const newGasFee = getHexGasTotal({ gasPrice: defaultNewGasPrice, gasLimit: '0x5208' }) - - return { - transactionId, - transactionStatus, - originalGasPrice, - defaultNewGasPrice, - newGasFee, - } -} - -const mapDispatchToProps = dispatch => { - return { - createCancelTransaction: (txId, customGasPrice) => { - return dispatch(createCancelTransaction(txId, customGasPrice)) - }, - showTransactionConfirmedModal: () => dispatch(showModal({ name: 'TRANSACTION_CONFIRMED' })), - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { transactionId, defaultNewGasPrice, ...restStateProps } = stateProps - const { createCancelTransaction, ...restDispatchProps } = dispatchProps - - return { - ...restStateProps, - ...restDispatchProps, - ...ownProps, - createCancelTransaction: () => createCancelTransaction(transactionId, defaultNewGasPrice), - } -} - -export default compose( - withModalProps, - connect(mapStateToProps, mapDispatchToProps, mergeProps), -)(CancelTransaction) diff --git a/ui/app/components/modals/cancel-transaction/index.scss b/ui/app/components/modals/cancel-transaction/index.scss deleted file mode 100644 index 62e8e36fd..000000000 --- a/ui/app/components/modals/cancel-transaction/index.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import './cancel-transaction-gas-fee/index'; - -.cancel-transaction { - &__title { - font-weight: 500; - padding-bottom: 16px; - text-align: center; - } - - &__description { - text-align: center; - font-size: .875rem; - } - - &__cancel-transaction-gas-fee-container { - margin-bottom: 16px; - } -}
\ No newline at end of file diff --git a/ui/app/components/modals/clear-approved-origins/clear-approved-origins.container.js b/ui/app/components/modals/clear-approved-origins/clear-approved-origins.container.js deleted file mode 100644 index 3a801a062..000000000 --- a/ui/app/components/modals/clear-approved-origins/clear-approved-origins.container.js +++ /dev/null @@ -1,16 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import withModalProps from '../../../higher-order-components/with-modal-props' -import ClearApprovedOriginsComponent from './clear-approved-origins.component' -import { clearApprovedOrigins } from '../../../actions' - -const mapDispatchToProps = dispatch => { - return { - clearApprovedOrigins: () => dispatch(clearApprovedOrigins()), - } -} - -export default compose( - withModalProps, - connect(null, mapDispatchToProps) -)(ClearApprovedOriginsComponent) diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js deleted file mode 100644 index 195c55421..000000000 --- a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js +++ /dev/null @@ -1,89 +0,0 @@ -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' - -export default class ConfirmRemoveAccount extends Component { - static propTypes = { - hideModal: PropTypes.func.isRequired, - removeAccount: PropTypes.func.isRequired, - identity: PropTypes.object.isRequired, - network: PropTypes.string.isRequired, - } - - static contextTypes = { - t: PropTypes.func, - } - - handleRemove = () => { - this.props.removeAccount(this.props.identity.address) - .then(() => this.props.hideModal()) - } - - handleCancel = () => { - this.props.hideModal() - } - - renderSelectedAccount () { - const { identity } = this.props - return ( - <div className="confirm-remove-account__account"> - <div className="confirm-remove-account__account__identicon"> - <Identicon - address={identity.address} - diameter={32} - /> - </div> - <div className="confirm-remove-account__account__name"> - <span className="confirm-remove-account__account__label">Name</span> - <span className="account_value">{identity.name}</span> - </div> - <div className="confirm-remove-account__account__address"> - <span className="confirm-remove-account__account__label">Public Address</span> - <span className="account_value">{ addressSummary(identity.address, 4, 4) }</span> - </div> - <div className="confirm-remove-account__account__link"> - <a - className="" - href={genAccountLink(identity.address, this.props.network)} - target={'_blank'} - title={this.context.t('etherscanView')} - > - <img src="images/popout.svg" /> - </a> - </div> - </div> - ) - } - - render () { - const { t } = this.context - - return ( - <Modal - headerText={`${t('removeAccount')}?`} - onClose={this.handleCancel} - onSubmit={this.handleRemove} - onCancel={this.handleCancel} - submitText={t('remove')} - cancelText={t('nevermind')} - submitType="secondary" - > - <div> - { this.renderSelectedAccount() } - <div className="confirm-remove-account__description"> - { t('removeAccountDescription') } - <a - className="confirm-remove-account__link" - rel="noopener noreferrer" - target="_blank" href="https://metamask.zendesk.com/hc/en-us/articles/360015289932"> - { t('learnMore') } - </a> - </div> - </div> - </Modal> - ) - } -} diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js deleted file mode 100644 index 45c6654ab..000000000 --- a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js +++ /dev/null @@ -1,22 +0,0 @@ -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' - -const mapStateToProps = state => { - return { - network: state.metamask.network, - } -} - -const mapDispatchToProps = dispatch => { - return { - removeAccount: (address) => dispatch(removeAccount(address)), - } -} - -export default compose( - withModalProps, - connect(mapStateToProps, mapDispatchToProps) -)(ConfirmRemoveAccount) diff --git a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js b/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js deleted file mode 100644 index c8a7b8478..000000000 --- a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js +++ /dev/null @@ -1,16 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import withModalProps from '../../../higher-order-components/with-modal-props' -import ConfirmResetAccount from './confirm-reset-account.component' -import { resetAccount } from '../../../actions' - -const mapDispatchToProps = dispatch => { - return { - resetAccount: () => dispatch(resetAccount()), - } -} - -export default compose( - withModalProps, - connect(null, mapDispatchToProps) -)(ConfirmResetAccount) diff --git a/ui/app/components/modals/customize-gas/customize-gas.component.js b/ui/app/components/modals/customize-gas/customize-gas.component.js deleted file mode 100644 index 4e2e20660..000000000 --- a/ui/app/components/modals/customize-gas/customize-gas.component.js +++ /dev/null @@ -1,162 +0,0 @@ -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 { - getDecimalGasLimit, - getDecimalGasPrice, - getPrefixedHexGasLimit, - getPrefixedHexGasPrice, -} from './customize-gas.util' - -export default class CustomizeGas extends Component { - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - } - - static propTypes = { - txData: PropTypes.object.isRequired, - hideModal: PropTypes.func, - validate: PropTypes.func, - onSubmit: PropTypes.func, - } - - state = { - gasPrice: 0, - gasLimit: 0, - originalGasPrice: 0, - originalGasLimit: 0, - } - - componentDidMount () { - const { txData = {} } = this.props - const { txParams: { gas: hexGasLimit, gasPrice: hexGasPrice } = {} } = txData - - const gasLimit = getDecimalGasLimit(hexGasLimit) - const gasPrice = getDecimalGasPrice(hexGasPrice) - - this.setState({ - gasPrice, - gasLimit, - originalGasPrice: gasPrice, - originalGasLimit: gasLimit, - }) - } - - handleRevert () { - const { originalGasPrice, originalGasLimit } = this.state - - this.setState({ - gasPrice: originalGasPrice, - gasLimit: originalGasLimit, - }) - } - - handleSave () { - const { onSubmit, hideModal } = this.props - const { gasLimit, gasPrice } = this.state - const prefixedHexGasPrice = getPrefixedHexGasPrice(gasPrice) - const prefixedHexGasLimit = getPrefixedHexGasLimit(gasLimit) - - Promise.resolve(onSubmit({ gasPrice: prefixedHexGasPrice, gasLimit: prefixedHexGasLimit })) - .then(() => hideModal()) - } - - validate () { - const { gasLimit, gasPrice } = this.state - return this.props.validate({ - gasPrice: getPrefixedHexGasPrice(gasPrice), - gasLimit: getPrefixedHexGasLimit(gasLimit), - }) - } - - render () { - const { t, metricsEvent } = this.context - const { hideModal } = this.props - const { gasPrice, gasLimit, originalGasPrice, originalGasLimit } = this.state - const { valid, errorKey } = this.validate() - - return ( - <div className="customize-gas"> - <div className="customize-gas__content"> - <div className="customize-gas__header"> - <div className="customize-gas__title"> - { this.context.t('customGas') } - </div> - <div - className="customize-gas__close" - onClick={() => hideModal()} - /> - </div> - <div className="customize-gas__body"> - <GasModalCard - value={gasPrice} - min={MIN_GAS_PRICE_GWEI} - step={1} - onChange={value => this.setState({ gasPrice: value })} - title={t('gasPrice')} - copy={t('gasPriceCalculation')} - /> - <GasModalCard - value={gasLimit} - min={1} - step={1} - onChange={value => this.setState({ gasLimit: value })} - title={t('gasLimit')} - copy={t('gasLimitCalculation')} - /> - </div> - <div className="customize-gas__footer"> - { !valid && <div className="customize-gas__error-message">{ t(errorKey) }</div> } - <div - className="customize-gas__revert" - onClick={() => this.handleRevert()} - > - { t('revert') } - </div> - <div className="customize-gas__buttons"> - <Button - type="default" - className="customize-gas__cancel" - onClick={() => hideModal()} - style={{ marginRight: '10px' }} - > - { t('cancel') } - </Button> - <Button - type="primary" - className="customize-gas__save" - 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} - > - { t('save') } - </Button> - </div> - </div> - </div> - </div> - ) - } -} diff --git a/ui/app/components/modals/customize-gas/customize-gas.container.js b/ui/app/components/modals/customize-gas/customize-gas.container.js deleted file mode 100644 index 46a799795..000000000 --- a/ui/app/components/modals/customize-gas/customize-gas.container.js +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from 'react-redux' -import CustomizeGas from './customize-gas.component' -import { hideModal } from '../../../actions' - -const mapStateToProps = state => { - const { appState: { modal: { modalState: { props } } } } = state - const { txData, onSubmit, validate } = props - - return { - txData, - onSubmit, - validate, - } -} - -const mapDispatchToProps = dispatch => { - return { - hideModal: () => dispatch(hideModal()), - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(CustomizeGas) diff --git a/ui/app/components/modals/customize-gas/customize-gas.util.js b/ui/app/components/modals/customize-gas/customize-gas.util.js deleted file mode 100644 index 6ba4a7705..000000000 --- a/ui/app/components/modals/customize-gas/customize-gas.util.js +++ /dev/null @@ -1,34 +0,0 @@ -import ethUtil from 'ethereumjs-util' -import { conversionUtil } from '../../../conversion-util' - -export function getDecimalGasLimit (hexGasLimit) { - return conversionUtil(hexGasLimit, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - }) -} - -export function getDecimalGasPrice (hexGasPrice) { - return conversionUtil(hexGasPrice, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromDenomination: 'WEI', - toDenomination: 'GWEI', - }) -} - -export function getPrefixedHexGasLimit (gasLimit) { - return ethUtil.addHexPrefix(conversionUtil(gasLimit, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - })) -} - -export function getPrefixedHexGasPrice (gasPrice) { - return ethUtil.addHexPrefix(conversionUtil(gasPrice, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - fromDenomination: 'GWEI', - toDenomination: 'WEI', - })) -} diff --git a/ui/app/components/modals/deposit-ether-modal.js b/ui/app/components/modals/deposit-ether-modal.js deleted file mode 100644 index 09137d39a..000000000 --- a/ui/app/components/modals/deposit-ether-modal.js +++ /dev/null @@ -1,220 +0,0 @@ -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 -const actions = require('../../actions') -const { getNetworkDisplayName } = require('../../../../app/scripts/controllers/network/util') -const ShapeshiftForm = require('../shapeshift-form') - -import Button from '../button' - -let DIRECT_DEPOSIT_ROW_TITLE -let DIRECT_DEPOSIT_ROW_TEXT -let COINBASE_ROW_TITLE -let COINBASE_ROW_TEXT -let SHAPESHIFT_ROW_TITLE -let SHAPESHIFT_ROW_TEXT -let FAUCET_ROW_TITLE - -function mapStateToProps (state) { - return { - network: state.metamask.network, - address: state.metamask.selectedAddress, - } -} - -function mapDispatchToProps (dispatch) { - return { - toCoinbase: (address) => { - dispatch(actions.buyEth({ network: '1', address, amount: 0 })) - }, - hideModal: () => { - dispatch(actions.hideModal()) - }, - hideWarning: () => { - dispatch(actions.hideWarning()) - }, - showAccountDetailModal: () => { - dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) - }, - toFaucet: network => dispatch(actions.buyEth({ network })), - } -} - -inherits(DepositEtherModal, Component) -function DepositEtherModal (props, context) { - Component.call(this) - - // 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') - SHAPESHIFT_ROW_TITLE = context.t('depositShapeShift') - SHAPESHIFT_ROW_TEXT = context.t('depositShapeShiftExplainer') - FAUCET_ROW_TITLE = context.t('testFaucet') - - this.state = { - buyingWithShapeshift: false, - } -} - -DepositEtherModal.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(DepositEtherModal) - - -DepositEtherModal.prototype.facuetRowText = function (networkName) { - return this.context.t('getEtherFromFaucet', [networkName]) -} - -DepositEtherModal.prototype.renderRow = function ({ - logo, - title, - text, - buttonLabel, - onButtonClick, - hide, - className, - hideButton, - hideTitle, - onBackClick, - showBackButton, -}) { - if (hide) { - return null - } - - return h('div', { - className: className || 'deposit-ether-modal__buy-row', - }, [ - - onBackClick && showBackButton && h('div.deposit-ether-modal__buy-row__back', { - onClick: onBackClick, - }, [ - - h('i.fa.fa-arrow-left.cursor-pointer'), - - ]), - - h('div.deposit-ether-modal__buy-row__logo-container', [logo]), - - h('div.deposit-ether-modal__buy-row__description', [ - - !hideTitle && h('div.deposit-ether-modal__buy-row__description__title', [title]), - - h('div.deposit-ether-modal__buy-row__description__text', [text]), - - ]), - - !hideButton && h('div.deposit-ether-modal__buy-row__button', [ - h(Button, { - type: 'primary', - className: 'deposit-ether-modal__deposit-button', - large: true, - onClick: onButtonClick, - }, [buttonLabel]), - ]), - - ]) -} - -DepositEtherModal.prototype.render = function () { - const { network, toCoinbase, address, toFaucet } = this.props - const { buyingWithShapeshift } = this.state - - const isTestNetwork = ['3', '4', '42'].find(n => n === network) - const networkName = getNetworkDisplayName(network) - - return h('div.page-container.page-container--full-width.page-container--full-height', {}, [ - - h('div.page-container__header', [ - - h('div.page-container__title', [this.context.t('depositEther')]), - - h('div.page-container__subtitle', [ - this.context.t('needEtherInWallet'), - ]), - - h('div.page-container__header-close', { - onClick: () => { - this.setState({ buyingWithShapeshift: false }) - this.props.hideWarning() - this.props.hideModal() - }, - }), - - ]), - - h('.page-container__content', {}, [ - - h('div.deposit-ether-modal__buy-rows', [ - - this.renderRow({ - logo: h('img.deposit-ether-modal__logo', { - src: './images/deposit-eth.svg', - }), - title: DIRECT_DEPOSIT_ROW_TITLE, - text: DIRECT_DEPOSIT_ROW_TEXT, - buttonLabel: this.context.t('viewAccount'), - onButtonClick: () => this.goToAccountDetailsModal(), - hide: buyingWithShapeshift, - }), - - this.renderRow({ - logo: h('i.fa.fa-tint.fa-2x'), - title: FAUCET_ROW_TITLE, - text: this.facuetRowText(networkName), - buttonLabel: this.context.t('getEther'), - onButtonClick: () => toFaucet(network), - hide: !isTestNetwork || buyingWithShapeshift, - }), - - this.renderRow({ - logo: h('div.deposit-ether-modal__logo', { - style: { - backgroundImage: 'url(\'./images/coinbase logo.png\')', - height: '40px', - }, - }), - title: COINBASE_ROW_TITLE, - text: COINBASE_ROW_TEXT, - buttonLabel: this.context.t('continueToCoinbase'), - onButtonClick: () => toCoinbase(address), - hide: isTestNetwork || buyingWithShapeshift, - }), - - this.renderRow({ - logo: h('div.deposit-ether-modal__logo', { - style: { - backgroundImage: 'url(\'./images/shapeshift logo.png\')', - }, - }), - title: SHAPESHIFT_ROW_TITLE, - text: SHAPESHIFT_ROW_TEXT, - buttonLabel: this.context.t('shapeshiftBuy'), - onButtonClick: () => this.setState({ buyingWithShapeshift: true }), - hide: isTestNetwork, - hideButton: buyingWithShapeshift, - hideTitle: buyingWithShapeshift, - onBackClick: () => this.setState({ buyingWithShapeshift: false }), - showBackButton: this.state.buyingWithShapeshift, - className: buyingWithShapeshift && 'deposit-ether-modal__buy-row__shapeshift-buy', - }), - - buyingWithShapeshift && h(ShapeshiftForm), - - ]), - - ]), - ]) -} - -DepositEtherModal.prototype.goToAccountDetailsModal = function () { - this.props.hideWarning() - this.props.hideModal() - this.props.showAccountDetailModal() -} diff --git a/ui/app/components/modals/edit-account-name-modal.js b/ui/app/components/modals/edit-account-name-modal.js deleted file mode 100644 index edced8725..000000000 --- a/ui/app/components/modals/edit-account-name-modal.js +++ /dev/null @@ -1,83 +0,0 @@ -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 -const actions = require('../../actions') -const { getSelectedAccount } = require('../../selectors') - -function mapStateToProps (state) { - return { - selectedAccount: getSelectedAccount(state), - identity: state.appState.modal.modalState.props.identity, - } -} - -function mapDispatchToProps (dispatch) { - return { - hideModal: () => { - dispatch(actions.hideModal()) - }, - setAccountLabel: (account, label) => { - dispatch(actions.setAccountLabel(account, label)) - }, - } -} - -inherits(EditAccountNameModal, Component) -function EditAccountNameModal (props) { - Component.call(this) - - this.state = { - inputText: props.identity.name, - } -} - -EditAccountNameModal.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(EditAccountNameModal) - - -EditAccountNameModal.prototype.render = function () { - const { hideModal, setAccountLabel, identity } = this.props - - return h('div', {}, [ - h('div.flex-column.edit-account-name-modal-content', { - }, [ - - h('div.edit-account-name-modal-cancel', { - onClick: () => { - hideModal() - }, - }, [ - h('i.fa.fa-times'), - ]), - - h('div.edit-account-name-modal-title', { - }, [this.context.t('editAccountName')]), - - h('input.edit-account-name-modal-input', { - placeholder: identity.name, - onChange: (event) => { - this.setState({ inputText: event.target.value }) - }, - value: this.state.inputText, - }, []), - - h('button.btn-clear.edit-account-name-modal-save-button.allcaps', { - onClick: () => { - if (this.state.inputText.length !== 0) { - setAccountLabel(identity.address, this.state.inputText) - hideModal() - } - }, - disabled: this.state.inputText.length === 0, - }, [ - this.context.t('save'), - ]), - - ]), - ]) -} diff --git a/ui/app/components/modals/export-private-key-modal.js b/ui/app/components/modals/export-private-key-modal.js deleted file mode 100644 index d3e3c9a56..000000000 --- a/ui/app/components/modals/export-private-key-modal.js +++ /dev/null @@ -1,177 +0,0 @@ -const log = require('loglevel') -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 -const { stripHexPrefix } = require('ethereumjs-util') -const actions = require('../../actions') -const AccountModalContainer = require('./account-modal-container') -const { getSelectedIdentity } = require('../../selectors') -const ReadOnlyInput = require('../readonly-input') -const copyToClipboard = require('copy-to-clipboard') -const { checksumAddress } = require('../../util') -import Button from '../button' - -function mapStateToPropsFactory () { - let selectedIdentity = null - return function mapStateToProps (state) { - // We should **not** change the identity displayed here even if it changes from underneath us. - // If we do, we will be showing the user one private key and a **different** address and name. - // Note that the selected identity **will** change from underneath us when we unlock the keyring - // which is the expected behavior that we are side-stepping. - selectedIdentity = selectedIdentity || getSelectedIdentity(state) - return { - warning: state.appState.warning, - privateKey: state.appState.accountDetail.privateKey, - network: state.metamask.network, - selectedIdentity, - previousModalState: state.appState.modal.previousModalState.name, - } - } -} - -function mapDispatchToProps (dispatch) { - return { - exportAccount: (password, address) => { - return dispatch(actions.exportAccount(password, address)) - .then((res) => { - dispatch(actions.hideWarning()) - return res - }) - }, - showAccountDetailModal: () => dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })), - hideModal: () => dispatch(actions.hideModal()), - } -} - -inherits(ExportPrivateKeyModal, Component) -function ExportPrivateKeyModal () { - Component.call(this) - - this.state = { - password: '', - privateKey: null, - showWarning: true, - } -} - -ExportPrivateKeyModal.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToPropsFactory, mapDispatchToProps)(ExportPrivateKeyModal) - - -ExportPrivateKeyModal.prototype.exportAccountAndGetPrivateKey = function (password, address) { - const { exportAccount } = this.props - - exportAccount(password, address) - .then(privateKey => this.setState({ - privateKey, - showWarning: false, - })) - .catch((e) => log.error(e)) -} - -ExportPrivateKeyModal.prototype.renderPasswordLabel = function (privateKey) { - return h('span.private-key-password-label', privateKey - ? this.context.t('copyPrivateKey') - : this.context.t('typePassword') - ) -} - -ExportPrivateKeyModal.prototype.renderPasswordInput = function (privateKey) { - const plainKey = privateKey && stripHexPrefix(privateKey) - - return privateKey - ? h(ReadOnlyInput, { - wrapperClass: 'private-key-password-display-wrapper', - inputClass: 'private-key-password-display-textarea', - textarea: true, - value: plainKey, - onClick: () => copyToClipboard(plainKey), - }) - : h('input.private-key-password-input', { - type: 'password', - onChange: event => this.setState({ password: event.target.value }), - }) -} - -ExportPrivateKeyModal.prototype.renderButtons = function (privateKey, password, address, hideModal) { - return h('div.export-private-key-buttons', {}, [ - !privateKey && h(Button, { - type: 'default', - large: true, - className: 'export-private-key__button export-private-key__button--cancel', - onClick: () => hideModal(), - }, this.context.t('cancel')), - - (privateKey - ? ( - h(Button, { - type: 'primary', - large: true, - className: 'export-private-key__button', - onClick: () => hideModal(), - }, this.context.t('done')) - ) : ( - h(Button, { - type: 'primary', - large: true, - className: 'export-private-key__button', - onClick: () => this.exportAccountAndGetPrivateKey(this.state.password, address), - }, this.context.t('confirm')) - ) - ), - - ]) -} - -ExportPrivateKeyModal.prototype.render = function () { - const { - selectedIdentity, - warning, - showAccountDetailModal, - hideModal, - previousModalState, - } = this.props - const { name, address } = selectedIdentity - - const { - privateKey, - showWarning, - } = this.state - - return h(AccountModalContainer, { - selectedIdentity, - showBackButton: previousModalState === 'ACCOUNT_DETAILS', - backButtonAction: () => showAccountDetailModal(), - }, [ - - h('span.account-name', name), - - h(ReadOnlyInput, { - wrapperClass: 'ellip-address-wrapper', - inputClass: 'qr-ellip-address ellip-address', - value: checksumAddress(address), - }), - - h('div.account-modal-divider'), - - h('span.modal-body-title', this.context.t('showPrivateKeys')), - - h('div.private-key-password', {}, [ - this.renderPasswordLabel(privateKey), - - this.renderPasswordInput(privateKey), - - showWarning && warning ? h('span.private-key-password-error', warning) : null, - ]), - - h('div.private-key-password-warning', this.context.t('privateKeyWarning')), - - this.renderButtons(privateKey, this.state.password, address, hideModal), - - ]) -} diff --git a/ui/app/components/modals/hide-token-confirmation-modal.js b/ui/app/components/modals/hide-token-confirmation-modal.js deleted file mode 100644 index 43f3009a5..000000000 --- a/ui/app/components/modals/hide-token-confirmation-modal.js +++ /dev/null @@ -1,83 +0,0 @@ -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 -const actions = require('../../actions') -import Identicon from '../identicon' - -function mapStateToProps (state) { - return { - network: state.metamask.network, - token: state.appState.modal.modalState.props.token, - assetImages: state.metamask.assetImages, - } -} - -function mapDispatchToProps (dispatch) { - return { - hideModal: () => dispatch(actions.hideModal()), - hideToken: address => { - dispatch(actions.removeToken(address)) - .then(() => { - dispatch(actions.hideModal()) - }) - }, - } -} - -inherits(HideTokenConfirmationModal, Component) -function HideTokenConfirmationModal () { - Component.call(this) - - this.state = {} -} - -HideTokenConfirmationModal.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(HideTokenConfirmationModal) - - -HideTokenConfirmationModal.prototype.render = function () { - const { token, network, hideToken, hideModal, assetImages } = this.props - const { symbol, address } = token - const image = assetImages[address] - - return h('div.hide-token-confirmation', {}, [ - h('div.hide-token-confirmation__container', { - }, [ - h('div.hide-token-confirmation__title', {}, [ - this.context.t('hideTokenPrompt'), - ]), - - h(Identicon, { - className: 'hide-token-confirmation__identicon', - diameter: 45, - address, - network, - image, - }), - - h('div.hide-token-confirmation__symbol', {}, symbol), - - h('div.hide-token-confirmation__copy', {}, [ - this.context.t('readdToken'), - ]), - - h('div.hide-token-confirmation__buttons', {}, [ - h('button.btn-cancel.hide-token-confirmation__button.allcaps', { - onClick: () => hideModal(), - }, [ - this.context.t('cancel'), - ]), - h('button.btn-clear.hide-token-confirmation__button.allcaps', { - onClick: () => hideToken(address), - }, [ - this.context.t('hide'), - ]), - ]), - ]), - ]) -} diff --git a/ui/app/components/modals/index.scss b/ui/app/components/modals/index.scss deleted file mode 100644 index 555da87ef..000000000 --- a/ui/app/components/modals/index.scss +++ /dev/null @@ -1,11 +0,0 @@ -@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/modals/loading-network-error/loading-network-error.container.js b/ui/app/components/modals/loading-network-error/loading-network-error.container.js deleted file mode 100644 index 3fcba20aa..000000000 --- a/ui/app/components/modals/loading-network-error/loading-network-error.container.js +++ /dev/null @@ -1,4 +0,0 @@ -import LoadingNetworkError from './loading-network-error.component' -import withModalProps from '../../../higher-order-components/with-modal-props' - -export default withModalProps(LoadingNetworkError) diff --git a/ui/app/components/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.component.js b/ui/app/components/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.component.js deleted file mode 100644 index 1a224eb12..000000000 --- a/ui/app/components/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.component.js +++ /dev/null @@ -1,141 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import PageContainerFooter from '../../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/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.container.js b/ui/app/components/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.container.js deleted file mode 100644 index 525806b75..000000000 --- a/ui/app/components/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.container.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import MetaMetricsOptInModal from './metametrics-opt-in-modal.component' -import withModalProps from '../../../higher-order-components/with-modal-props' -import { setParticipateInMetaMetrics } from '../../../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/modals/modal.js deleted file mode 100644 index 8ab599a71..000000000 --- a/ui/app/components/modals/modal.js +++ /dev/null @@ -1,511 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const connect = require('react-redux').connect -const FadeModal = require('boron').FadeModal -const actions = require('../../actions') -const { resetCustomData: resetCustomGasData } = require('../../ducks/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') -const DepositEtherModal = require('./deposit-ether-modal') -const AccountDetailsModal = require('./account-details-modal') -const EditAccountNameModal = require('./edit-account-name-modal') -const ExportPrivateKeyModal = require('./export-private-key-modal') -const NewAccountModal = require('./new-account-modal') -const ShapeshiftDepositTxModal = require('./shapeshift-deposit-tx-modal.js') -const HideTokenConfirmationModal = require('./hide-token-confirmation-modal') -const 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 CancelTransaction from './cancel-transaction' - -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)', - border: '1px solid #CCCFD1', - borderRadius: '8px', - backgroundColor: '#FFFFFF', - boxShadow: '0 2px 22px 0 rgba(0,0,0,0.2)', -} - -const modalContainerLaptopStyle = { - ...modalContainerBaseStyle, - width: '344px', - top: '15%', -} - -const modalContainerMobileStyle = { - ...modalContainerBaseStyle, - width: '309px', - top: '12.5%', -} - -const accountModalStyle = { - mobileModalStyle: { - width: '95%', - // top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', - boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', - borderRadius: '4px', - top: '10%', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - }, - laptopModalStyle: { - width: '360px', - // top: 'calc(33% + 45px)', - boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', - borderRadius: '4px', - top: '10%', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - }, - contentStyle: { - borderRadius: '4px', - }, -} - -const MODALS = { - BUY: { - contents: [ - h(BuyOptions, {}, []), - ], - mobileModalStyle: { - width: '95%', - // top: isPopupOrNotification() === 'popup' ? '48vh' : '36.5vh', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', - top: '10%', - }, - laptopModalStyle: { - width: '66%', - maxWidth: '550px', - top: 'calc(10% + 10px)', - left: '0', - right: '0', - margin: '0 auto', - boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', - transform: 'none', - }, - }, - - DEPOSIT_ETHER: { - contents: [ - h(DepositEtherModal, {}, []), - ], - onHide: (props) => props.hideWarning(), - mobileModalStyle: { - width: '100%', - height: '100%', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - boxShadow: '0 0 7px 0 rgba(0,0,0,0.08)', - top: '0', - display: 'flex', - }, - laptopModalStyle: { - width: 'initial', - maxWidth: '850px', - top: 'calc(10% + 10px)', - left: '0', - right: '0', - margin: '0 auto', - boxShadow: '0 0 6px 0 rgba(0,0,0,0.3)', - borderRadius: '7px', - transform: 'none', - height: 'calc(80% - 20px)', - overflowY: 'hidden', - }, - contentStyle: { - borderRadius: '7px', - height: '100%', - }, - }, - - EDIT_ACCOUNT_NAME: { - contents: [ - h(EditAccountNameModal, {}, []), - ], - mobileModalStyle: { - width: '95%', - // top: isPopupOrNotification() === 'popup' ? '48vh' : '36.5vh', - top: '10%', - boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - }, - laptopModalStyle: { - width: '375px', - // top: 'calc(30% + 10px)', - top: '10%', - boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - }, - }, - - ACCOUNT_DETAILS: { - contents: [ - h(AccountDetailsModal, {}, []), - ], - ...accountModalStyle, - }, - - EXPORT_PRIVATE_KEY: { - contents: [ - h(ExportPrivateKeyModal, {}, []), - ], - ...accountModalStyle, - }, - - SHAPESHIFT_DEPOSIT_TX: { - contents: [ - h(ShapeshiftDepositTxModal), - ], - ...accountModalStyle, - }, - - HIDE_TOKEN_CONFIRMATION: { - contents: [ - h(HideTokenConfirmationModal, {}, []), - ], - mobileModalStyle: { - width: '95%', - top: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP ? '52vh' : '36.5vh', - }, - laptopModalStyle: { - width: '449px', - top: 'calc(33% + 45px)', - }, - }, - - CLEAR_APPROVED_ORIGINS: { - contents: h(ClearApprovedOrigins), - mobileModalStyle: { - ...modalContainerMobileStyle, - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - METAMETRICS_OPT_IN_MODAL: { - contents: h(MetaMetricsOptInModal), - mobileModalStyle: { - ...modalContainerMobileStyle, - width: '100%', - height: '100%', - top: '0px', - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - top: '10%', - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - OLD_UI_NOTIFICATION_MODAL: { - contents: [ - h(NotifcationModal, { - header: 'oldUI', - message: 'oldUIMessage', - }), - ], - mobileModalStyle: { - width: '95%', - top: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP ? '52vh' : '36.5vh', - }, - laptopModalStyle: { - width: '449px', - top: 'calc(33% + 45px)', - }, - }, - - 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: { - ...modalContainerMobileStyle, - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - CONFIRM_REMOVE_ACCOUNT: { - contents: h(ConfirmRemoveAccount), - mobileModalStyle: { - ...modalContainerMobileStyle, - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - NEW_ACCOUNT: { - contents: [ - h(NewAccountModal, {}, []), - ], - mobileModalStyle: { - width: '95%', - // top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', - top: '10%', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - }, - laptopModalStyle: { - width: '449px', - // top: 'calc(33% + 45px)', - top: '10%', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - }, - }, - - CUSTOMIZE_GAS: { - contents: [ - h(ConfirmCustomizeGasModal), - ], - mobileModalStyle: { - width: '100vw', - height: '100vh', - top: '0', - transform: 'none', - left: '0', - right: '0', - margin: '0 auto', - }, - laptopModalStyle: { - width: 'auto', - height: '0px', - top: '80px', - left: '0px', - transform: 'none', - margin: '0 auto', - position: 'relative', - }, - contentStyle: { - borderRadius: '8px', - }, - customOnHideOpts: { - action: resetCustomGasData, - args: [], - }, - }, - - TRANSACTION_CONFIRMED: { - disableBackdropClick: true, - contents: h(TransactionConfirmed), - mobileModalStyle: { - ...modalContainerMobileStyle, - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - QR_SCANNER: { - contents: h(QRScanner), - mobileModalStyle: { - ...modalContainerMobileStyle, - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - CANCEL_TRANSACTION: { - contents: h(CancelTransaction), - mobileModalStyle: { - ...modalContainerMobileStyle, - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - REJECT_TRANSACTIONS: { - contents: h(RejectTransactions), - mobileModalStyle: { - ...modalContainerMobileStyle, - }, - laptopModalStyle: { - ...modalContainerLaptopStyle, - }, - contentStyle: { - borderRadius: '8px', - }, - }, - - DEFAULT: { - contents: [], - mobileModalStyle: {}, - laptopModalStyle: {}, - }, -} - -const BACKDROPSTYLE = { - backgroundColor: 'rgba(0, 0, 0, 0.5)', -} - -function mapStateToProps (state) { - return { - active: state.appState.modal.open, - modalState: state.appState.modal.modalState, - } -} - -function mapDispatchToProps (dispatch) { - return { - hideModal: (customOnHideOpts) => { - dispatch(actions.hideModal()) - if (customOnHideOpts && customOnHideOpts.action) { - dispatch(customOnHideOpts.action(...customOnHideOpts.args)) - } - }, - hideWarning: () => { - dispatch(actions.hideWarning()) - }, - - } -} - -// Global Modal Component -inherits(Modal, Component) -function Modal () { - Component.call(this) -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(Modal) - -Modal.prototype.render = function () { - const modal = MODALS[this.props.modalState.name || 'DEFAULT'] - - const { contents: children, disableBackdropClick = false } = modal - const modalStyle = modal[isMobileView() ? 'mobileModalStyle' : 'laptopModalStyle'] - const contentStyle = modal.contentStyle || {} - - return h(FadeModal, - { - className: 'modal', - keyboard: false, - onHide: () => { - if (modal.onHide) { - modal.onHide(this.props) - } - this.onHide(modal.customOnHideOpts) - }, - ref: (ref) => { - this.modalRef = ref - }, - modalStyle, - contentStyle, - backdropStyle: BACKDROPSTYLE, - closeOnClick: !disableBackdropClick, - }, - children, - ) -} - -Modal.prototype.componentWillReceiveProps = function (nextProps) { - if (nextProps.active) { - this.show() - } else if (this.props.active) { - this.hide() - } -} - -Modal.prototype.onHide = function (customOnHideOpts) { - if (this.props.onHideCallback) { - this.props.onHideCallback() - } - this.props.hideModal(customOnHideOpts) -} - -Modal.prototype.hide = function () { - this.modalRef.hide() -} - -Modal.prototype.show = function () { - this.modalRef.show() -} diff --git a/ui/app/components/modals/new-account-modal.js b/ui/app/components/modals/new-account-modal.js deleted file mode 100644 index a66a3ed4a..000000000 --- a/ui/app/components/modals/new-account-modal.js +++ /dev/null @@ -1,112 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const actions = require('../../actions') - -class NewAccountModal extends Component { - constructor (props) { - super(props) - const { numberOfExistingAccounts = 0 } = props - const newAccountNumber = numberOfExistingAccounts + 1 - - this.state = { - newAccountName: `${this.context.t('account')} ${newAccountNumber}`, - } - } - - render () { - const { newAccountName } = this.state - - return h('div', [ - h('div.new-account-modal-wrapper', { - }, [ - h('div.new-account-modal-header', {}, [ - this.context.t('newAccount'), - ]), - - h('div.modal-close-x', { - onClick: this.props.hideModal, - }), - - h('div.new-account-modal-content', {}, [ - this.context.t('accountName'), - ]), - - h('div.new-account-input-wrapper', {}, [ - h('input.new-account-input', { - value: this.state.newAccountName, - placeholder: this.context.t('sampleAccountName'), - onChange: event => this.setState({ newAccountName: event.target.value }), - }, []), - ]), - - h('div.new-account-modal-content.after-input', {}, [ - this.context.t('or'), - ]), - - h('div.new-account-modal-content.after-input.pointer', { - onClick: () => { - this.props.hideModal() - this.props.showImportPage() - }, - }, this.context.t('importAnAccount')), - - h('div.new-account-modal-content.button.allcaps', {}, [ - h('button.btn-clear', { - onClick: () => this.props.createAccount(newAccountName), - }, [ - this.context.t('save'), - ]), - ]), - ]), - ]) - } -} - -NewAccountModal.propTypes = { - hideModal: PropTypes.func, - showImportPage: PropTypes.func, - createAccount: PropTypes.func, - numberOfExistingAccounts: PropTypes.number, - t: PropTypes.func, -} - -const mapStateToProps = state => { - const { metamask: { network, selectedAddress, identities = {} } } = state - const numberOfExistingAccounts = Object.keys(identities).length - - return { - network, - address: selectedAddress, - numberOfExistingAccounts, - } -} - -const mapDispatchToProps = dispatch => { - return { - toCoinbase: (address) => { - dispatch(actions.buyEth({ network: '1', address, amount: 0 })) - }, - hideModal: () => { - dispatch(actions.hideModal()) - }, - createAccount: (newAccountName) => { - dispatch(actions.addNewAccount()) - .then((newAccountAddress) => { - if (newAccountName) { - dispatch(actions.setAccountLabel(newAccountAddress, newAccountName)) - } - dispatch(actions.hideModal()) - }) - }, - showImportPage: () => dispatch(actions.showImportPage()), - } -} - -NewAccountModal.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(NewAccountModal) - diff --git a/ui/app/components/modals/notification-modal.js b/ui/app/components/modals/notification-modal.js deleted file mode 100644 index 46a4c8a21..000000000 --- a/ui/app/components/modals/notification-modal.js +++ /dev/null @@ -1,81 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const actions = require('../../actions') - -class NotificationModal extends Component { - render () { - const { - header, - message, - showCancelButton = false, - showConfirmButton = false, - hideModal, - onConfirm, - } = this.props - - const showButtons = showCancelButton || showConfirmButton - - return h('div', [ - h('div.notification-modal__wrapper', { - }, [ - - h('div.notification-modal__header', {}, [ - this.context.t(header), - ]), - - h('div.notification-modal__message-wrapper', {}, [ - h('div.notification-modal__message', {}, [ - this.context.t(message), - ]), - ]), - - h('div.modal-close-x', { - onClick: hideModal, - }), - - showButtons && h('div.notification-modal__buttons', [ - - showCancelButton && h('div.btn-cancel.notification-modal__buttons__btn', { - onClick: hideModal, - }, 'Cancel'), - - showConfirmButton && h('div.btn-clear.notification-modal__buttons__btn', { - onClick: () => { - onConfirm() - hideModal() - }, - }, 'Confirm'), - - ]), - - ]), - ]) - } -} - -NotificationModal.propTypes = { - hideModal: PropTypes.func, - header: PropTypes.string, - message: PropTypes.node, - showCancelButton: PropTypes.bool, - showConfirmButton: PropTypes.bool, - onConfirm: PropTypes.func, - t: PropTypes.func, -} - -const mapDispatchToProps = dispatch => { - return { - hideModal: () => { - dispatch(actions.hideModal()) - }, - } -} - -NotificationModal.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(null, mapDispatchToProps)(NotificationModal) - diff --git a/ui/app/components/modals/qr-scanner/qr-scanner.component.js b/ui/app/components/modals/qr-scanner/qr-scanner.component.js deleted file mode 100644 index cb8d07d83..000000000 --- a/ui/app/components/modals/qr-scanner/qr-scanner.component.js +++ /dev/null @@ -1,216 +0,0 @@ -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' - -export default class QrScanner extends Component { - static propTypes = { - hideModal: PropTypes.func.isRequired, - qrCodeDetected: PropTypes.func, - scanQrCode: PropTypes.func, - error: PropTypes.bool, - errorType: PropTypes.string, - } - - static contextTypes = { - t: PropTypes.func, - } - - constructor (props, context) { - super(props) - - this.state = { - ready: false, - msg: context.t('accessingYourCamera'), - } - this.codeReader = null - this.permissionChecker = null - this.needsToReinit = false - - // Clear pre-existing qr code data before scanning - this.props.qrCodeDetected(null) - } - - componentDidMount () { - this.initCamera() - } - - async checkPermisisions () { - const { permissions } = await WebcamUtils.checkStatus() - if (permissions) { - clearTimeout(this.permissionChecker) - // Let the video stream load first... - setTimeout(_ => { - this.setState({ - ready: true, - msg: this.context.t('scanInstructions'), - }) - if (this.needsToReinit) { - this.initCamera() - this.needsToReinit = false - } - }, 2000) - } else { - // Keep checking for permissions - this.permissionChecker = setTimeout(_ => { - this.checkPermisisions() - }, 1000) - } - } - - componentWillUnmount () { - clearTimeout(this.permissionChecker) - if (this.codeReader) { - this.codeReader.reset() - } - } - - initCamera () { - this.codeReader = new BrowserQRCodeReader() - this.codeReader.getVideoInputDevices() - .then(videoInputDevices => { - clearTimeout(this.permissionChecker) - this.checkPermisisions() - this.codeReader.decodeFromInputVideoDevice(undefined, 'video') - .then(content => { - const result = this.parseContent(content.text) - if (result.type !== 'unknown') { - this.props.qrCodeDetected(result) - this.stopAndClose() - } else { - this.setState({msg: this.context.t('unknownQrCode')}) - } - }) - .catch(err => { - if (err && err.name === 'NotAllowedError') { - this.setState({msg: this.context.t('youNeedToAllowCameraAccess')}) - clearTimeout(this.permissionChecker) - this.needsToReinit = true - this.checkPermisisions() - } - }) - }).catch(err => { - console.error('[QR-SCANNER]: getVideoInputDevices threw an exception: ', err) - }) - } - - parseContent (content) { - let type = 'unknown' - let values = {} - - // Here we could add more cases - // To parse other type of links - // For ex. EIP-681 (https://eips.ethereum.org/EIPS/eip-681) - - - // Ethereum address links - fox ex. ethereum:0x.....1111 - if (content.split('ethereum:').length > 1) { - - type = 'address' - values = {'address': content.split('ethereum:')[1] } - - // Regular ethereum addresses - fox ex. 0x.....1111 - } else if (content.substring(0, 2).toLowerCase() === '0x') { - - type = 'address' - values = {'address': content } - - } - return {type, values} - } - - - stopAndClose = () => { - if (this.codeReader) { - this.codeReader.reset() - } - this.setState({ ready: false }) - this.props.hideModal() - } - - tryAgain = () => { - // close the modal - this.stopAndClose() - // wait for the animation and try again - setTimeout(_ => { - this.props.scanQrCode() - }, 1000) - } - - renderVideo () { - return ( - <div className={'qr-scanner__content__video-wrapper'}> - <video - id="video" - style={{ - display: this.state.ready ? 'block' : 'none', - }} - /> - { !this.state.ready ? <Spinner color={'#F7C06C'} /> : null} - </div> - ) - } - - renderErrorModal () { - let title, msg - - if (this.props.error) { - if (this.props.errorType === 'NO_WEBCAM_FOUND') { - title = this.context.t('noWebcamFoundTitle') - msg = this.context.t('noWebcamFound') - } else { - title = this.context.t('unknownCameraErrorTitle') - msg = this.context.t('unknownCameraError') - } - } - - return ( - <div className="qr-scanner"> - <div className="qr-scanner__close" onClick={this.stopAndClose}></div> - - <div className="qr-scanner__image"> - <img src={'images/webcam.svg'} width={70} height={70} /> - </div> - <div className="qr-scanner__title"> - { title } - </div> - <div className={'qr-scanner__error'}> - {msg} - </div> - <PageContainerFooter - onCancel={this.stopAndClose} - onSubmit={this.tryAgain} - cancelText={this.context.t('cancel')} - submitText={this.context.t('tryAgain')} - submitButtonType="confirm" - /> - </div> - ) - } - - render () { - const { t } = this.context - - if (this.props.error) { - return this.renderErrorModal() - } - - return ( - <div className="qr-scanner"> - <div className="qr-scanner__close" onClick={this.stopAndClose}></div> - <div className="qr-scanner__title"> - { `${t('scanQrCode')}` } - </div> - <div className="qr-scanner__content"> - { this.renderVideo() } - </div> - <div className={'qr-scanner__status'}> - {this.state.msg} - </div> - </div> - ) - } -} diff --git a/ui/app/components/modals/qr-scanner/qr-scanner.container.js b/ui/app/components/modals/qr-scanner/qr-scanner.container.js deleted file mode 100644 index d0a35e03b..000000000 --- a/ui/app/components/modals/qr-scanner/qr-scanner.container.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux' -import QrScanner from './qr-scanner.component' - -const { hideModal, qrCodeDetected, showQrScanner } = require('../../../actions') -import { - SEND_ROUTE, -} from '../../../routes' - -const mapStateToProps = state => { - return { - error: state.appState.modal.modalState.props.error, - errorType: state.appState.modal.modalState.props.errorType, - } -} - -const mapDispatchToProps = dispatch => { - return { - hideModal: () => dispatch(hideModal()), - qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), - scanQrCode: () => dispatch(showQrScanner(SEND_ROUTE)), - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(QrScanner) diff --git a/ui/app/components/modals/reject-transactions/reject-transactions.container.js b/ui/app/components/modals/reject-transactions/reject-transactions.container.js deleted file mode 100644 index 81e98d3ff..000000000 --- a/ui/app/components/modals/reject-transactions/reject-transactions.container.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import RejectTransactionsModal from './reject-transactions.component' -import withModalProps from '../../../higher-order-components/with-modal-props' - -const mapStateToProps = (state, ownProps) => { - const { unapprovedTxCount } = ownProps - - return { - unapprovedTxCount, - } -} - -export default compose( - withModalProps, - connect(mapStateToProps), -)(RejectTransactionsModal) diff --git a/ui/app/components/modals/shapeshift-deposit-tx-modal.js b/ui/app/components/modals/shapeshift-deposit-tx-modal.js deleted file mode 100644 index 242c7b89d..000000000 --- a/ui/app/components/modals/shapeshift-deposit-tx-modal.js +++ /dev/null @@ -1,40 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const connect = require('react-redux').connect -const actions = require('../../actions') -const QrView = require('../qr-code') -const AccountModalContainer = require('./account-modal-container') - -function mapStateToProps (state) { - return { - Qr: state.appState.modal.modalState.props.Qr, - } -} - -function mapDispatchToProps (dispatch) { - return { - hideModal: () => { - dispatch(actions.hideModal()) - }, - } -} - -inherits(ShapeshiftDepositTxModal, Component) -function ShapeshiftDepositTxModal () { - Component.call(this) - -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(ShapeshiftDepositTxModal) - -ShapeshiftDepositTxModal.prototype.render = function () { - const { Qr } = this.props - - return h(AccountModalContainer, { - }, [ - h('div', {}, [ - h(QrView, {key: 'qr', Qr}), - ]), - ]) -} diff --git a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js b/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js deleted file mode 100644 index d4e39681a..000000000 --- a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js +++ /dev/null @@ -1,4 +0,0 @@ -import TransactionConfirmed from './transaction-confirmed.component' -import withModalProps from '../../../higher-order-components/with-modal-props' - -export default withModalProps(TransactionConfirmed) diff --git a/ui/app/components/network-display/network-display.component.js b/ui/app/components/network-display/network-display.component.js deleted file mode 100644 index 22d617099..000000000 --- a/ui/app/components/network-display/network-display.component.js +++ /dev/null @@ -1,76 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { - MAINNET_CODE, - ROPSTEN_CODE, - RINKEYBY_CODE, - KOVAN_CODE, -} from '../../../../app/scripts/controllers/network/enums' - -const networkToClassHash = { - [MAINNET_CODE]: 'mainnet', - [ROPSTEN_CODE]: 'ropsten', - [RINKEYBY_CODE]: 'rinkeby', - [KOVAN_CODE]: 'kovan', -} - -export default class NetworkDisplay extends Component { - static defaultProps = { - colored: true, - } - - static propTypes = { - colored: PropTypes.bool, - network: PropTypes.string, - provider: PropTypes.object, - } - - static contextTypes = { - t: PropTypes.func, - } - - renderNetworkIcon () { - const { network } = this.props - const networkClass = networkToClassHash[network] - - return networkClass - ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> - : <div - className="i fa fa-question-circle fa-med" - style={{ - margin: '0 4px', - color: 'rgb(125, 128, 130)', - }} - /> - } - - render () { - const { colored, network, provider: { type, nickname } } = this.props - const networkClass = networkToClassHash[network] - - return ( - <div - className={classnames('network-display__container', { - 'network-display__container--colored': colored, - ['network-display__container--' + networkClass]: colored && networkClass, - })} - > - { - networkClass - ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> - : <div - className="i fa fa-question-circle fa-med" - style={{ - margin: '0 4px', - color: 'rgb(125, 128, 130)', - }} - /> - } - <div className="network-display__name"> - { type === 'rpc' && nickname ? nickname : this.context.t(type) } - </div> - </div> - ) - } -} diff --git a/ui/app/components/pages/add-token/add-token.component.js b/ui/app/components/pages/add-token/add-token.component.js deleted file mode 100644 index 198889cf2..000000000 --- a/ui/app/components/pages/add-token/add-token.component.js +++ /dev/null @@ -1,335 +0,0 @@ -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 TokenList from './token-list' -import TokenSearch from './token-search' -import PageContainer from '../../page-container' -import { Tabs, Tab } from '../../tabs' - -const emptyAddr = '0x0000000000000000000000000000000000000000' -const SEARCH_TAB = 'SEARCH' -const CUSTOM_TOKEN_TAB = 'CUSTOM_TOKEN' - -class AddToken extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - history: PropTypes.object, - setPendingTokens: PropTypes.func, - pendingTokens: PropTypes.object, - clearPendingTokens: PropTypes.func, - tokens: PropTypes.array, - identities: PropTypes.object, - } - - constructor (props) { - super(props) - - this.state = { - customAddress: '', - customSymbol: '', - customDecimals: 0, - searchResults: [], - selectedTokens: {}, - tokenSelectorError: null, - customAddressError: null, - customSymbolError: null, - customDecimalsError: null, - autoFilled: false, - displayedTab: SEARCH_TAB, - forceEditSymbol: false, - } - } - - componentDidMount () { - this.tokenInfoGetter = tokenInfoGetter() - const { pendingTokens = {} } = this.props - const pendingTokenKeys = Object.keys(pendingTokens) - - if (pendingTokenKeys.length > 0) { - let selectedTokens = {} - let customToken = {} - - pendingTokenKeys.forEach(tokenAddress => { - const token = pendingTokens[tokenAddress] - const { isCustom } = token - - if (isCustom) { - customToken = { ...token } - } else { - selectedTokens = { ...selectedTokens, [tokenAddress]: { ...token } } - } - }) - - const { - address: customAddress = '', - symbol: customSymbol = '', - decimals: customDecimals = 0, - } = customToken - - const displayedTab = Object.keys(selectedTokens).length > 0 ? SEARCH_TAB : CUSTOM_TOKEN_TAB - this.setState({ selectedTokens, customAddress, customSymbol, customDecimals, displayedTab }) - } - } - - handleToggleToken (token) { - const { address } = token - const { selectedTokens = {} } = this.state - const selectedTokensCopy = { ...selectedTokens } - - if (address in selectedTokensCopy) { - delete selectedTokensCopy[address] - } else { - selectedTokensCopy[address] = token - } - - this.setState({ - selectedTokens: selectedTokensCopy, - tokenSelectorError: null, - }) - } - - hasError () { - const { - tokenSelectorError, - customAddressError, - customSymbolError, - customDecimalsError, - } = this.state - - return tokenSelectorError || customAddressError || customSymbolError || customDecimalsError - } - - hasSelected () { - const { customAddress = '', selectedTokens = {} } = this.state - return customAddress || Object.keys(selectedTokens).length > 0 - } - - handleNext () { - if (this.hasError()) { - return - } - - if (!this.hasSelected()) { - this.setState({ tokenSelectorError: this.context.t('mustSelectOne') }) - return - } - - const { setPendingTokens, history } = this.props - const { - customAddress: address, - customSymbol: symbol, - customDecimals: decimals, - selectedTokens, - } = this.state - - const customToken = { - address, - symbol, - decimals, - } - - setPendingTokens({ customToken, selectedTokens }) - history.push(CONFIRM_ADD_TOKEN_ROUTE) - } - - async attemptToAutoFillTokenParams (address) { - const { symbol = '', decimals = 0 } = await this.tokenInfoGetter(address) - - const autoFilled = Boolean(symbol && decimals) - this.setState({ autoFilled }) - this.handleCustomSymbolChange(symbol || '') - this.handleCustomDecimalsChange(decimals) - } - - handleCustomAddressChange (value) { - const customAddress = value.trim() - this.setState({ - customAddress, - customAddressError: null, - tokenSelectorError: null, - autoFilled: false, - }) - - const isValidAddress = ethUtil.isValidAddress(customAddress) - const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() - - switch (true) { - case !isValidAddress: - this.setState({ - customAddressError: this.context.t('invalidAddress'), - customSymbol: '', - customDecimals: 0, - customSymbolError: null, - customDecimalsError: null, - }) - - break - case Boolean(this.props.identities[standardAddress]): - this.setState({ - customAddressError: this.context.t('personalAddressDetected'), - }) - - break - case checkExistingAddresses(customAddress, this.props.tokens): - this.setState({ - customAddressError: this.context.t('tokenAlreadyAdded'), - }) - - break - default: - if (customAddress !== emptyAddr) { - this.attemptToAutoFillTokenParams(customAddress) - } - } - } - - handleCustomSymbolChange (value) { - const customSymbol = value.trim() - const symbolLength = customSymbol.length - let customSymbolError = null - - if (symbolLength <= 0 || symbolLength >= 12) { - customSymbolError = this.context.t('symbolBetweenZeroTwelve') - } - - this.setState({ customSymbol, customSymbolError }) - } - - handleCustomDecimalsChange (value) { - const customDecimals = value.trim() - const validDecimals = customDecimals !== null && - customDecimals !== '' && - customDecimals >= 0 && - customDecimals <= 36 - let customDecimalsError = null - - if (!validDecimals) { - customDecimalsError = this.context.t('decimalsMustZerotoTen') - } - - this.setState({ customDecimals, customDecimalsError }) - } - - renderCustomTokenForm () { - const { - customAddress, - customSymbol, - customDecimals, - customAddressError, - customSymbolError, - customDecimalsError, - autoFilled, - forceEditSymbol, - } = this.state - - return ( - <div className="add-token__custom-token-form"> - <TextField - id="custom-address" - label={this.context.t('tokenContractAddress')} - type="text" - value={customAddress} - onChange={e => this.handleCustomAddressChange(e.target.value)} - error={customAddressError} - fullWidth - margin="normal" - /> - <TextField - id="custom-symbol" - 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 && !forceEditSymbol} - /> - <TextField - id="custom-decimals" - label={this.context.t('decimal')} - type="number" - value={customDecimals} - onChange={e => this.handleCustomDecimalsChange(e.target.value)} - error={customDecimalsError} - fullWidth - margin="normal" - disabled={autoFilled} - /> - </div> - ) - } - - renderSearchToken () { - const { tokenSelectorError, selectedTokens, searchResults } = this.state - - return ( - <div className="add-token__search-token"> - <TokenSearch - onSearch={({ results = [] }) => this.setState({ searchResults: results })} - error={tokenSelectorError} - /> - <div className="add-token__token-list"> - <TokenList - results={searchResults} - selectedTokens={selectedTokens} - onToggleToken={token => this.handleToggleToken(token)} - /> - </div> - </div> - ) - } - - renderTabs () { - return ( - <Tabs> - <Tab name={this.context.t('search')}> - { this.renderSearchToken() } - </Tab> - <Tab name={this.context.t('customToken')}> - { this.renderCustomTokenForm() } - </Tab> - </Tabs> - ) - } - - render () { - const { history, clearPendingTokens } = this.props - - return ( - <PageContainer - title={this.context.t('addTokens')} - tabsComponent={this.renderTabs()} - onSubmit={() => this.handleNext()} - disabled={this.hasError() || !this.hasSelected()} - onCancel={() => { - clearPendingTokens() - history.push(DEFAULT_ROUTE) - }} - /> - ) - } -} - -export default AddToken diff --git a/ui/app/components/pages/add-token/add-token.container.js b/ui/app/components/pages/add-token/add-token.container.js deleted file mode 100644 index 87671b156..000000000 --- a/ui/app/components/pages/add-token/add-token.container.js +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from 'react-redux' -import AddToken from './add-token.component' - -const { setPendingTokens, clearPendingTokens } = require('../../../actions') - -const mapStateToProps = ({ metamask }) => { - const { identities, tokens, pendingTokens } = metamask - return { - identities, - tokens, - pendingTokens, - } -} - -const mapDispatchToProps = dispatch => { - return { - setPendingTokens: tokens => dispatch(setPendingTokens(tokens)), - clearPendingTokens: () => dispatch(clearPendingTokens()), - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(AddToken) diff --git a/ui/app/components/pages/add-token/index.scss b/ui/app/components/pages/add-token/index.scss deleted file mode 100644 index 1690c7654..000000000 --- a/ui/app/components/pages/add-token/index.scss +++ /dev/null @@ -1,45 +0,0 @@ -@import './token-list/index'; - -.add-token { - &__custom-token-form { - padding: 8px 16px 16px; - - input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - display: none; - } - - input[type="number"]:hover::-webkit-inner-spin-button { - -webkit-appearance: none; - display: none; - } - } - - &__search-token { - padding: 16px; - } - - &__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.scss b/ui/app/components/pages/add-token/token-list/index.scss deleted file mode 100644 index e32739d59..000000000 --- a/ui/app/components/pages/add-token/token-list/index.scss +++ /dev/null @@ -1,65 +0,0 @@ -@import './token-list-placeholder/index'; - -.token-list { - &__title { - font-size: .75rem; - } - - &__tokens-container { - display: flex; - flex-direction: column; - } - - &__token { - transition: 200ms ease-in-out; - display: flex; - flex-flow: row nowrap; - align-items: center; - padding: 8px; - margin-top: 8px; - box-sizing: border-box; - border-radius: 10px; - cursor: pointer; - border: 2px solid transparent; - position: relative; - - &:hover { - border: 2px solid rgba($malibu-blue, .5); - } - - &--selected { - border: 2px solid $malibu-blue !important; - } - - &--disabled { - opacity: .4; - pointer-events: none; - } - } - - &__token-icon { - width: 48px; - height: 48px; - background-repeat: no-repeat; - background-size: contain; - background-position: center; - border-radius: 50%; - background-color: $white; - box-shadow: 0 2px 4px 0 rgba($black, .24); - margin-right: 12px; - flex: 0 0 auto; - } - - &__token-data { - display: flex; - flex-direction: row; - align-items: center; - min-width: 0; - } - - &__token-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} diff --git a/ui/app/components/pages/add-token/token-search/token-search.component.js b/ui/app/components/pages/add-token/token-search/token-search.component.js deleted file mode 100644 index 036b2db1e..000000000 --- a/ui/app/components/pages/add-token/token-search/token-search.component.js +++ /dev/null @@ -1,85 +0,0 @@ -import React, { Component } from 'react' -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' - -const contractList = Object.entries(contractMap) - .map(([ _, tokenData]) => tokenData) - .filter(tokenData => Boolean(tokenData.erc20)) - -const fuse = new Fuse(contractList, { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'symbol', weight: 0.5 }, - ], -}) - -export default class TokenSearch extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static defaultProps = { - error: null, - } - - static propTypes = { - onSearch: PropTypes.func, - error: PropTypes.string, - } - - constructor (props) { - super(props) - - this.state = { - searchQuery: '', - } - } - - handleSearch (searchQuery) { - this.setState({ searchQuery }) - const fuseSearchResult = fuse.search(searchQuery) - const addressSearchResult = contractList.filter(token => { - return token.address.toLowerCase() === searchQuery.toLowerCase() - }) - const results = [...addressSearchResult, ...fuseSearchResult] - this.props.onSearch({ searchQuery, results }) - } - - renderAdornment () { - return ( - <InputAdornment - position="start" - style={{ marginRight: '12px' }} - > - <img src="images/search.svg" /> - </InputAdornment> - ) - } - - render () { - const { error } = this.props - const { searchQuery } = this.state - - return ( - <TextField - id="search-tokens" - placeholder={this.context.t('searchTokens')} - type="text" - value={searchQuery} - onChange={e => this.handleSearch(e.target.value)} - error={error} - fullWidth - startAdornment={this.renderAdornment()} - /> - ) - } -} diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js deleted file mode 100644 index ee5d6fa64..000000000 --- a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js +++ /dev/null @@ -1,122 +0,0 @@ -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' - -export default class ConfirmAddSuggestedToken extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - history: PropTypes.object, - clearPendingTokens: PropTypes.func, - addToken: PropTypes.func, - pendingTokens: PropTypes.object, - removeSuggestedTokens: PropTypes.func, - } - - componentDidMount () { - const { pendingTokens = {}, history } = this.props - - if (Object.keys(pendingTokens).length === 0) { - history.push(DEFAULT_ROUTE) - } - } - - getTokenName (name, symbol) { - return typeof name === 'undefined' - ? symbol - : `${name} (${symbol})` - } - - render () { - const { addToken, pendingTokens, removeSuggestedTokens, history } = this.props - const pendingTokenKey = Object.keys(pendingTokens)[0] - const pendingToken = pendingTokens[pendingTokenKey] - - return ( - <div className="page-container"> - <div className="page-container__header"> - <div className="page-container__title"> - { this.context.t('addSuggestedTokens') } - </div> - <div className="page-container__subtitle"> - { this.context.t('likeToAddTokens') } - </div> - </div> - <div className="page-container__content"> - <div className="confirm-add-token"> - <div className="confirm-add-token__header"> - <div className="confirm-add-token__token"> - { this.context.t('token') } - </div> - <div className="confirm-add-token__balance"> - { this.context.t('balance') } - </div> - </div> - <div className="confirm-add-token__token-list"> - { - Object.entries(pendingTokens) - .map(([ address, token ]) => { - const { name, symbol, image } = token - - return ( - <div - className="confirm-add-token__token-list-item" - key={address} - > - <div className="confirm-add-token__token confirm-add-token__data"> - <Identicon - className="confirm-add-token__token-icon" - diameter={48} - address={address} - image={image} - /> - <div className="confirm-add-token__name"> - { this.getTokenName(name, symbol) } - </div> - </div> - <div className="confirm-add-token__balance"> - <TokenBalance token={token} /> - </div> - </div> - ) - }) - } - </div> - </div> - </div> - <div className="page-container__footer"> - <header> - <Button - type="default" - large - className="page-container__footer-button" - onClick={() => { - removeSuggestedTokens() - .then(() => history.push(DEFAULT_ROUTE)) - }} - > - { this.context.t('cancel') } - </Button> - <Button - type="primary" - large - className="page-container__footer-button" - onClick={() => { - addToken(pendingToken) - .then(() => removeSuggestedTokens()) - .then(() => history.push(DEFAULT_ROUTE)) - }} - > - { this.context.t('addToken') } - </Button> - </header> - </div> - </div> - ) - } -} diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js deleted file mode 100644 index 1f2737e52..000000000 --- a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import ConfirmAddSuggestedToken from './confirm-add-suggested-token.component' -import { withRouter } from 'react-router-dom' - -const extend = require('xtend') - -const { addToken, removeSuggestedTokens } = require('../../../actions') - -const mapStateToProps = ({ metamask }) => { - const { pendingTokens, suggestedTokens } = metamask - const params = extend(pendingTokens, suggestedTokens) - - return { - pendingTokens: params, - } -} - -const mapDispatchToProps = dispatch => { - return { - addToken: ({address, symbol, decimals, image}) => dispatch(addToken(address, symbol, decimals, image)), - removeSuggestedTokens: () => dispatch(removeSuggestedTokens()), - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(ConfirmAddSuggestedToken) diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js b/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js deleted file mode 100644 index d3fec79d7..000000000 --- a/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js +++ /dev/null @@ -1,117 +0,0 @@ -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' - -export default class ConfirmAddToken extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - history: PropTypes.object, - clearPendingTokens: PropTypes.func, - addTokens: PropTypes.func, - pendingTokens: PropTypes.object, - } - - componentDidMount () { - const { pendingTokens = {}, history } = this.props - - if (Object.keys(pendingTokens).length === 0) { - history.push(DEFAULT_ROUTE) - } - } - - getTokenName (name, symbol) { - return typeof name === 'undefined' - ? symbol - : `${name} (${symbol})` - } - - render () { - const { history, addTokens, clearPendingTokens, pendingTokens } = this.props - - return ( - <div className="page-container"> - <div className="page-container__header"> - <div className="page-container__title"> - { this.context.t('addTokens') } - </div> - <div className="page-container__subtitle"> - { this.context.t('likeToAddTokens') } - </div> - </div> - <div className="page-container__content"> - <div className="confirm-add-token"> - <div className="confirm-add-token__header"> - <div className="confirm-add-token__token"> - { this.context.t('token') } - </div> - <div className="confirm-add-token__balance"> - { this.context.t('balance') } - </div> - </div> - <div className="confirm-add-token__token-list"> - { - Object.entries(pendingTokens) - .map(([ address, token ]) => { - const { name, symbol } = token - - return ( - <div - className="confirm-add-token__token-list-item" - key={address} - > - <div className="confirm-add-token__token confirm-add-token__data"> - <Identicon - className="confirm-add-token__token-icon" - diameter={48} - address={address} - /> - <div className="confirm-add-token__name"> - { this.getTokenName(name, symbol) } - </div> - </div> - <div className="confirm-add-token__balance"> - <TokenBalance token={token} /> - </div> - </div> - ) - }) - } - </div> - </div> - </div> - <div className="page-container__footer"> - <header> - <Button - type="default" - large - className="page-container__footer-button" - onClick={() => history.push(ADD_TOKEN_ROUTE)} - > - { this.context.t('back') } - </Button> - <Button - type="primary" - large - className="page-container__footer-button" - onClick={() => { - addTokens(pendingTokens) - .then(() => { - clearPendingTokens() - history.push(DEFAULT_ROUTE) - }) - }} - > - { this.context.t('addTokens') } - </Button> - </header> - </div> - </div> - ) - } -} diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js b/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js deleted file mode 100644 index 0190024d9..000000000 --- a/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js +++ /dev/null @@ -1,20 +0,0 @@ -import { connect } from 'react-redux' -import ConfirmAddToken from './confirm-add-token.component' - -const { addTokens, clearPendingTokens } = require('../../../actions') - -const mapStateToProps = ({ metamask }) => { - const { pendingTokens } = metamask - return { - pendingTokens, - } -} - -const mapDispatchToProps = dispatch => { - return { - addTokens: tokens => dispatch(addTokens(tokens)), - clearPendingTokens: () => dispatch(clearPendingTokens()), - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(ConfirmAddToken) diff --git a/ui/app/components/pages/confirm-approve/confirm-approve.container.js b/ui/app/components/pages/confirm-approve/confirm-approve.container.js deleted file mode 100644 index 4ef9f4ced..000000000 --- a/ui/app/components/pages/confirm-approve/confirm-approve.container.js +++ /dev/null @@ -1,15 +0,0 @@ -import { connect } from 'react-redux' -import ConfirmApprove from './confirm-approve.component' -import { approveTokenAmountAndToAddressSelector } from '../../../selectors/confirm-transaction' - -const mapStateToProps = state => { - const { confirmTransaction: { tokenProps: { tokenSymbol } = {} } } = state - const { tokenAmount } = approveTokenAmountAndToAddressSelector(state) - - return { - tokenAmount, - tokenSymbol, - } -} - -export default connect(mapStateToProps)(ConfirmApprove) diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js deleted file mode 100644 index 442a478b8..000000000 --- a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import ConfirmTransactionBase from '../confirm-transaction-base' -import { SEND_ROUTE } from '../../../routes' - -export default class ConfirmSendEther extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - editTransaction: PropTypes.func, - history: PropTypes.object, - txParams: PropTypes.object, - } - - handleEdit ({ txData }) { - const { editTransaction, history } = this.props - editTransaction(txData) - history.push(SEND_ROUTE) - } - - shouldHideData () { - const { txParams = {} } = this.props - return !txParams.data - } - - render () { - const hideData = this.shouldHideData() - - return ( - <ConfirmTransactionBase - action={this.context.t('confirm')} - hideData={hideData} - onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} - /> - ) - } -} diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js deleted file mode 100644 index e48ef54a8..000000000 --- a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js +++ /dev/null @@ -1,45 +0,0 @@ -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 ConfirmSendEther from './confirm-send-ether.component' - -const mapStateToProps = state => { - const { confirmTransaction: { txData: { txParams } = {} } } = state - - return { - txParams, - } -} - -const mapDispatchToProps = dispatch => { - return { - editTransaction: txData => { - const { id, txParams } = txData - const { - gas: gasLimit, - gasPrice, - to, - value: amount, - } = txParams - - dispatch(updateSend({ - gasLimit, - gasPrice, - gasTotal: null, - to, - amount, - errors: { to: null, amount: null }, - editingTransactionId: id && id.toString(), - })) - - dispatch(clearConfirmTransaction()) - }, - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(ConfirmSendEther) diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js b/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js deleted file mode 100644 index cb39e3d7b..000000000 --- a/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import ConfirmTokenTransactionBase from '../confirm-token-transaction-base' -import { SEND_ROUTE } from '../../../routes' - -export default class ConfirmSendToken extends Component { - static propTypes = { - history: PropTypes.object, - editTransaction: PropTypes.func, - tokenAmount: PropTypes.number, - } - - handleEdit (confirmTransactionData) { - const { editTransaction, history } = this.props - editTransaction(confirmTransactionData) - history.push(SEND_ROUTE) - } - - render () { - const { tokenAmount } = this.props - - return ( - <ConfirmTokenTransactionBase - onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} - tokenAmount={tokenAmount} - /> - ) - } -} diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js b/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js deleted file mode 100644 index d60911e59..000000000 --- a/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js +++ /dev/null @@ -1,52 +0,0 @@ -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' - -const mapStateToProps = state => { - const { tokenAmount } = sendTokenTokenAmountAndToAddressSelector(state) - - return { - tokenAmount, - } -} - -const mapDispatchToProps = dispatch => { - return { - editTransaction: ({ txData, tokenData, tokenProps }) => { - const { txParams: { to: tokenAddress, gas: gasLimit, gasPrice } = {}, id } = txData - const { params = [] } = tokenData - const { value: to } = params[0] || {} - const { value: tokenAmountInDec } = params[1] || {} - const tokenAmountInHex = conversionUtil(tokenAmountInDec, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }) - dispatch(setSelectedToken(tokenAddress)) - dispatch(updateSend({ - gasLimit, - gasPrice, - gasTotal: null, - to, - amount: tokenAmountInHex, - errors: { to: null, amount: null }, - editingTransactionId: id && id.toString(), - token: { - ...tokenProps, - address: tokenAddress, - }, - })) - dispatch(clearConfirmTransaction()) - dispatch(showSendTokenPage()) - }, - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(ConfirmSendToken) diff --git a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js b/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js deleted file mode 100644 index 7f1fb4e49..000000000 --- a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js +++ /dev/null @@ -1,119 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import ConfirmTransactionBase from '../confirm-transaction-base' -import UserPreferencedCurrencyDisplay from '../../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' - -export default class ConfirmTokenTransactionBase extends Component { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - tokenAddress: PropTypes.string, - toAddress: PropTypes.string, - tokenAmount: PropTypes.number, - tokenSymbol: PropTypes.string, - fiatTransactionTotal: PropTypes.string, - ethTransactionTotal: PropTypes.string, - contractExchangeRate: PropTypes.number, - conversionRate: PropTypes.number, - currentCurrency: PropTypes.string, - } - - getFiatTransactionAmount () { - const { tokenAmount, currentCurrency, conversionRate, contractExchangeRate } = this.props - - return convertTokenToFiat({ - value: tokenAmount, - toCurrency: currentCurrency, - conversionRate, - contractExchangeRate, - }) - } - - renderSubtitleComponent () { - const { contractExchangeRate, tokenAmount } = this.props - - const decimalEthValue = (tokenAmount * contractExchangeRate) || 0 - const hexWeiValue = getWeiHexFromDecimalValue({ - value: decimalEthValue, - fromCurrency: ETH, - fromDenomination: ETH, - }) - - return typeof contractExchangeRate === 'undefined' - ? ( - <span> - { this.context.t('noConversionRateAvailable') } - </span> - ) : ( - <UserPreferencedCurrencyDisplay - value={hexWeiValue} - type={PRIMARY} - showEthLogo - hideLabel - /> - ) - } - - renderPrimaryTotalTextOverride () { - const { tokenAmount, tokenSymbol, ethTransactionTotal } = this.props - const tokensText = `${tokenAmount} ${tokenSymbol}` - - return ( - <div> - <span>{ `${tokensText} + ` }</span> - <img - src="/images/eth.svg" - height="18" - /> - <span>{ ethTransactionTotal }</span> - </div> - ) - } - - getSecondaryTotalTextOverride () { - const { fiatTransactionTotal, currentCurrency, contractExchangeRate } = this.props - - if (typeof contractExchangeRate === 'undefined') { - return formatCurrency(fiatTransactionTotal, currentCurrency) - } else { - const fiatTransactionAmount = this.getFiatTransactionAmount() - const fiatTotal = addFiat(fiatTransactionAmount, fiatTransactionTotal) - const roundedFiatTotal = roundExponential(fiatTotal) - return formatCurrency(roundedFiatTotal, currentCurrency) - } - } - - render () { - const { - toAddress, - tokenAddress, - tokenSymbol, - tokenAmount, - ...restProps - } = this.props - - const tokensText = `${tokenAmount} ${tokenSymbol}` - - return ( - <ConfirmTransactionBase - toAddress={toAddress} - identiconAddress={tokenAddress} - title={tokensText} - subtitleComponent={this.renderSubtitleComponent()} - primaryTotalTextOverride={this.renderPrimaryTotalTextOverride()} - secondaryTotalTextOverride={this.getSecondaryTotalTextOverride()} - {...restProps} - /> - ) - } -} diff --git a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js b/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js deleted file mode 100644 index be38acdb0..000000000 --- a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js +++ /dev/null @@ -1,34 +0,0 @@ -import { connect } from 'react-redux' -import ConfirmTokenTransactionBase from './confirm-token-transaction-base.component' -import { - tokenAmountAndToAddressSelector, - contractExchangeRateSelector, -} from '../../../selectors/confirm-transaction' - -const mapStateToProps = (state, ownProps) => { - const { tokenAmount: ownTokenAmount } = ownProps - const { confirmTransaction, metamask: { currentCurrency, conversionRate } } = state - const { - txData: { txParams: { to: tokenAddress } = {} } = {}, - tokenProps: { tokenSymbol } = {}, - fiatTransactionTotal, - ethTransactionTotal, - } = confirmTransaction - - const { tokenAmount, toAddress } = tokenAmountAndToAddressSelector(state) - const contractExchangeRate = contractExchangeRateSelector(state) - - return { - toAddress, - tokenAddress, - tokenAmount: typeof ownTokenAmount !== 'undefined' ? ownTokenAmount : tokenAmount, - tokenSymbol, - currentCurrency, - conversionRate, - contractExchangeRate, - fiatTransactionTotal, - ethTransactionTotal, - } -} - -export default connect(mapStateToProps)(ConfirmTokenTransactionBase) diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js deleted file mode 100644 index 8b101b1ba..000000000 --- a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ /dev/null @@ -1,574 +0,0 @@ -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, CONFIRM_TRANSACTION_ROUTE } from '../../../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' -import AdvancedGasInputs from '../../gas-customization/advanced-gas-inputs' - -export default class ConfirmTransactionBase extends Component { - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - } - - static propTypes = { - // react-router props - match: PropTypes.object, - history: PropTypes.object, - // Redux props - balance: PropTypes.string, - cancelTransaction: PropTypes.func, - cancelAllTransactions: PropTypes.func, - clearConfirmTransaction: PropTypes.func, - clearSend: PropTypes.func, - conversionRate: PropTypes.number, - currentCurrency: PropTypes.string, - editTransaction: PropTypes.func, - ethTransactionAmount: PropTypes.string, - ethTransactionFee: PropTypes.string, - ethTransactionTotal: PropTypes.string, - fiatTransactionAmount: PropTypes.string, - fiatTransactionFee: PropTypes.string, - fiatTransactionTotal: PropTypes.string, - fromAddress: PropTypes.string, - fromName: PropTypes.string, - hexTransactionAmount: PropTypes.string, - hexTransactionFee: PropTypes.string, - hexTransactionTotal: PropTypes.string, - isTxReprice: PropTypes.bool, - methodData: PropTypes.object, - nonce: PropTypes.string, - assetImage: PropTypes.string, - sendTransaction: PropTypes.func, - showCustomizeGasModal: PropTypes.func, - showTransactionConfirmedModal: PropTypes.func, - showRejectTransactionsConfirmationModal: PropTypes.func, - toAddress: PropTypes.string, - tokenData: PropTypes.object, - tokenProps: PropTypes.object, - toName: PropTypes.string, - 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, - dataComponent: PropTypes.node, - detailsComponent: PropTypes.node, - errorKey: PropTypes.string, - errorMessage: PropTypes.string, - primaryTotalTextOverride: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - secondaryTotalTextOverride: PropTypes.string, - hideData: PropTypes.bool, - hideDetails: PropTypes.bool, - hideSubtitle: PropTypes.bool, - identiconAddress: PropTypes.string, - onCancel: PropTypes.func, - onEdit: PropTypes.func, - onEditGas: PropTypes.func, - onSubmit: PropTypes.func, - setMetaMetricsSendCount: PropTypes.func, - metaMetricsSendCount: PropTypes.number, - subtitle: PropTypes.string, - subtitleComponent: PropTypes.node, - summaryComponent: PropTypes.node, - title: PropTypes.string, - titleComponent: PropTypes.node, - valid: PropTypes.bool, - warning: PropTypes.string, - advancedInlineGasShown: PropTypes.bool, - insufficientBalance: PropTypes.bool, - hideFiatConversion: PropTypes.bool, - } - - state = { - submitting: false, - submitError: null, - } - - componentDidUpdate () { - const { - transactionStatus, - showTransactionConfirmedModal, - history, - clearConfirmTransaction, - } = this.props - - if (transactionStatus === DROPPED_STATUS || transactionStatus === CONFIRMED_STATUS) { - showTransactionConfirmedModal({ - onSubmit: () => { - clearConfirmTransaction() - history.push(DEFAULT_ROUTE) - }, - }) - - return - } - } - - getErrorKey () { - const { - balance, - conversionRate, - hexTransactionFee, - txData: { - simulationFails, - txParams: { - value: amount, - } = {}, - } = {}, - } = this.props - - const insufficientBalance = balance && !isBalanceSufficient({ - amount, - gasTotal: hexTransactionFee || '0x0', - balance, - conversionRate, - }) - - if (insufficientBalance) { - return { - valid: false, - errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, - } - } - - if (simulationFails) { - return { - valid: true, - errorKey: simulationFails.errorKey ? simulationFails.errorKey : TRANSACTION_ERROR_KEY, - } - } - - return { - valid: true, - } - } - - handleEditGas () { - 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() - } else { - showCustomizeGasModal() - } - } - - renderDetails () { - const { - detailsComponent, - primaryTotalTextOverride, - secondaryTotalTextOverride, - hexTransactionFee, - hexTransactionTotal, - hideDetails, - advancedInlineGasShown, - customGas, - insufficientBalance, - updateGasAndCalculate, - hideFiatConversion, - } = this.props - - if (hideDetails) { - return null - } - - return ( - detailsComponent || ( - <div className="confirm-page-container-content__details"> - <div className="confirm-page-container-content__gas-fee"> - <ConfirmDetailRow - label="Gas Fee" - value={hexTransactionFee} - 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={hideFiatConversion ? this.context.t('noConversionRateAvailable') : secondaryTotalTextOverride} - headerText="Amount + Gas Fee" - headerTextClassName="confirm-detail-row__header-text--total" - primaryValueTextColor="#2f9ae0" - /> - </div> - </div> - ) - ) - } - - renderData () { - const { t } = this.context - const { - txData: { - txParams: { - data, - } = {}, - } = {}, - methodData: { - name, - params, - } = {}, - hideData, - dataComponent, - } = this.props - - if (hideData) { - return null - } - - return dataComponent || ( - <div className="confirm-page-container-content__data"> - <div className="confirm-page-container-content__data-box-label"> - {`${t('functionType')}:`} - <span className="confirm-page-container-content__function-type"> - { name || t('notFound') } - </span> - </div> - { - params && ( - <div className="confirm-page-container-content__data-box"> - <div className="confirm-page-container-content__data-field-label"> - { `${t('parameters')}:` } - </div> - <div> - <pre>{ JSON.stringify(params, null, 2) }</pre> - </div> - </div> - ) - } - <div className="confirm-page-container-content__data-box-label"> - {`${t('hexData')}: ${ethUtil.toBuffer(data).length} bytes`} - </div> - <div className="confirm-page-container-content__data-box"> - { data } - </div> - </div> - ) - } - - handleEdit () { - 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 }) - } - - handleCancelAll () { - const { - cancelAllTransactions, - clearConfirmTransaction, - history, - showRejectTransactionsConfirmationModal, - unapprovedTxCount, - } = this.props - - showRejectTransactionsConfirmationModal({ - unapprovedTxCount, - async onSubmit () { - await cancelAllTransactions() - clearConfirmTransaction() - history.push(DEFAULT_ROUTE) - }, - }) - } - - handleCancel () { - 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) - .then(() => { - clearConfirmTransaction() - history.push(DEFAULT_ROUTE) - }) - } - } - - handleSubmit () { - 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, - }, () => { - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Confirm Screen', - name: 'Transaction Completed', - }, - customVariables: { - recipientKnown: null, - functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), - origin, - }, - }) - - setMetaMetricsSendCount(metaMetricsSendCount + 1) - .then(() => { - 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 () { - const { title, titleComponent, hexTransactionAmount } = this.props - - // Title string passed in by props takes priority - if (title) { - return null - } - - return titleComponent || ( - <UserPreferencedCurrencyDisplay - value={hexTransactionAmount} - type={PRIMARY} - showEthLogo - ethLogoHeight="26" - hideLabel - /> - ) - } - - renderSubtitleComponent () { - const { subtitle, subtitleComponent, hexTransactionAmount } = this.props - - // Subtitle string passed in by props takes priority - if (subtitle) { - return null - } - - return subtitleComponent || ( - <UserPreferencedCurrencyDisplay - value={hexTransactionAmount} - type={SECONDARY} - showEthLogo - hideLabel - /> - ) - } - - 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 } = {} } = this.props - const { metricsEvent } = this.context - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Confirm Screen', - name: 'Confirm: Started', - }, - customVariables: { - origin, - }, - }) - } - - render () { - const { - isTxReprice, - fromName, - fromAddress, - toName, - toAddress, - methodData, - valid: propsValid = true, - errorMessage, - errorKey: propsErrorKey, - action, - title, - subtitle, - hideSubtitle, - identiconAddress, - summaryComponent, - contentComponent, - onEdit, - nonce, - assetImage, - warning, - unapprovedTxCount, - } = this.props - const { submitting, submitError } = this.state - - const { name } = methodData - const { valid, errorKey } = this.getErrorKey() - const { totalTx, positionOfCurrentTx, nextTxId, prevTxId, showNavigation, firstTx, lastTx, ofText, requestsWaitingText } = this.getNavigateTxData() - - return ( - <ConfirmPageContainer - fromName={fromName} - fromAddress={fromAddress} - toName={toName} - toAddress={toAddress} - showEdit={onEdit && !isTxReprice} - action={action || getMethodName(name) || this.context.t('contractInteraction')} - title={title} - titleComponent={this.renderTitleComponent()} - subtitle={subtitle} - subtitleComponent={this.renderSubtitleComponent()} - hideSubtitle={hideSubtitle} - summaryComponent={summaryComponent} - detailsComponent={this.renderDetails()} - dataComponent={this.renderData()} - contentComponent={contentComponent} - nonce={nonce} - unapprovedTxCount={unapprovedTxCount} - assetImage={assetImage} - identiconAddress={identiconAddress} - 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()} - onCancel={() => this.handleCancel()} - onSubmit={() => this.handleSubmit()} - /> - ) - } -} - -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/components/pages/confirm-transaction-base/confirm-transaction-base.container.js deleted file mode 100644 index 22f509905..000000000 --- a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ /dev/null @@ -1,242 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import { withRouter } from 'react-router-dom' -import R from 'ramda' -import contractMap from 'eth-contract-metadata' -import ConfirmTransactionBase from './confirm-transaction-base.component' -import { - clearConfirmTransaction, - updateGasAndCalculate, -} from '../../../ducks/confirm-transaction.duck' -import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal, setMetaMetricsSendCount } from '../../../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, calcGasTotal } from '../../send/send.utils' -import { conversionGreaterThan } from '../../../conversion-util' -import { MIN_GAS_LIMIT_DEC } from '../../send/send.constants' -import { checksumAddress, addressSlicer, valuesFor } from '../../../util' -import {getMetaMaskAccounts, getAdvancedInlineGasShown, preferencesSelector, getIsMainnet} from '../../../selectors' - -const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { - return { - ...acc, - [base.toLowerCase()]: contractMap[base], - } -}, {}) - -const mapStateToProps = (state, props) => { - const { toAddress: propsToAddress } = props - const { showFiatInTestnets } = preferencesSelector(state) - const isMainnet = getIsMainnet(state) - const { confirmTransaction, metamask, gas } = state - const { - ethTransactionAmount, - ethTransactionFee, - ethTransactionTotal, - fiatTransactionAmount, - fiatTransactionFee, - fiatTransactionTotal, - hexTransactionAmount, - hexTransactionFee, - hexTransactionTotal, - tokenData, - methodData, - txData, - tokenProps, - nonce, - } = confirmTransaction - const { txParams = {}, lastGasPrice, id: transactionId } = txData - const { - from: fromAddress, - to: txParamsToAddress, - gasPrice, - gas: gasLimit, - value: amount, - } = txParams - const accounts = getMetaMaskAccounts(state) - const { - conversionRate, - identities, - currentCurrency, - 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(checksumAddress(toAddress)) - ) - - const isTxReprice = Boolean(lastGasPrice) - - const transaction = R.find(({ id }) => id === transactionId)(selectedAddressTxList) - const transactionStatus = transaction ? transaction.status : '' - - const currentNetworkUnapprovedTxs = R.filter( - ({ metamaskNetworkId }) => metamaskNetworkId === network, - unapprovedTxs, - ) - const unapprovedTxCount = valuesFor(currentNetworkUnapprovedTxs).length - - const insufficientBalance = !isBalanceSufficient({ - amount, - gasTotal: calcGasTotal(gasLimit, gasPrice), - balance, - conversionRate, - }) - - return { - balance, - fromAddress, - fromName, - toAddress, - toName, - ethTransactionAmount, - ethTransactionFee, - ethTransactionTotal, - fiatTransactionAmount, - fiatTransactionFee, - fiatTransactionTotal, - hexTransactionAmount, - hexTransactionFee, - hexTransactionTotal, - txData, - tokenData, - methodData, - tokenProps, - isTxReprice, - currentCurrency, - conversionRate, - transactionStatus, - nonce, - assetImage, - unapprovedTxs, - unapprovedTxCount, - currentNetworkUnapprovedTxs, - customGas: { - gasLimit: customGasLimit || gasLimit, - gasPrice: customGasPrice || gasPrice, - }, - advancedInlineGasShown: getAdvancedInlineGasShown(state), - insufficientBalance, - hideSubtitle: (!isMainnet && !showFiatInTestnets), - hideFiatConversion: (!isMainnet && !showFiatInTestnets), - metaMetricsSendCount, - } -} - -const mapDispatchToProps = dispatch => { - return { - clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), - clearSend: () => dispatch(clearSend()), - showTransactionConfirmedModal: ({ onSubmit }) => { - return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onSubmit })) - }, - showCustomizeGasModal: ({ txData, onSubmit, validate }) => { - return dispatch(showModal({ name: 'CUSTOMIZE_GAS', txData, onSubmit, validate })) - }, - updateGasAndCalculate: ({ gasLimit, gasPrice }) => { - return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) - }, - showRejectTransactionsConfirmationModal: ({ onSubmit, unapprovedTxCount }) => { - return dispatch(showModal({ name: 'REJECT_TRANSACTIONS', onSubmit, unapprovedTxCount })) - }, - cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), - cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), - sendTransaction: txData => dispatch(updateAndApproveTx(txData)), - setMetaMetricsSendCount: val => dispatch(setMetaMetricsSendCount(val)), - } -} - -const getValidateEditGas = ({ balance, conversionRate, txData }) => { - const { txParams: { value: amount } = {} } = txData - - return ({ gasLimit, gasPrice }) => { - const gasTotal = getHexGasTotal({ gasLimit, gasPrice }) - const hasSufficientBalance = isBalanceSufficient({ - amount, - gasTotal, - balance, - conversionRate, - }) - - if (!hasSufficientBalance) { - return { - valid: false, - errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, - } - } - - const gasLimitTooLow = gasLimit && conversionGreaterThan( - { - value: MIN_GAS_LIMIT_DEC, - fromNumericBase: 'dec', - conversionRate, - }, - { - value: gasLimit, - fromNumericBase: 'hex', - }, - ) - - if (gasLimitTooLow) { - return { - valid: false, - errorKey: GAS_LIMIT_TOO_LOW_ERROR_KEY, - } - } - - return { - valid: true, - } - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { balance, conversionRate, txData, unapprovedTxs } = stateProps - const { - cancelAllTransactions: dispatchCancelAllTransactions, - showCustomizeGasModal: dispatchShowCustomizeGasModal, - updateGasAndCalculate: dispatchUpdateGasAndCalculate, - ...otherDispatchProps - } = dispatchProps - - const validateEditGas = getValidateEditGas({ balance, conversionRate, txData }) - - return { - ...stateProps, - ...otherDispatchProps, - ...ownProps, - showCustomizeGasModal: () => dispatchShowCustomizeGasModal({ - txData, - onSubmit: customGas => dispatchUpdateGasAndCalculate(customGas), - validate: validateEditGas, - }), - cancelAllTransactions: () => dispatchCancelAllTransactions(valuesFor(unapprovedTxs)), - updateGasAndCalculate: dispatchUpdateGasAndCalculate, - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps, mergeProps) -)(ConfirmTransactionBase) diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js deleted file mode 100644 index cf79b94bc..000000000 --- a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js +++ /dev/null @@ -1,92 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import { Redirect } from 'react-router-dom' -import Loading from '../../loading-screen' -import { - CONFIRM_TRANSACTION_ROUTE, - CONFIRM_DEPLOY_CONTRACT_PATH, - CONFIRM_SEND_ETHER_PATH, - CONFIRM_SEND_TOKEN_PATH, - CONFIRM_APPROVE_PATH, - CONFIRM_TRANSFER_FROM_PATH, - CONFIRM_TOKEN_METHOD_PATH, - SIGNATURE_REQUEST_PATH, -} from '../../../routes' -import { isConfirmDeployContract } from '../../../helpers/transactions.util' -import { - TOKEN_METHOD_TRANSFER, - TOKEN_METHOD_APPROVE, - TOKEN_METHOD_TRANSFER_FROM, -} from '../../../constants/transactions' - -export default class ConfirmTransactionSwitch extends Component { - static propTypes = { - txData: PropTypes.object, - methodData: PropTypes.object, - fetchingData: PropTypes.bool, - isEtherTransaction: PropTypes.bool, - } - - redirectToTransaction () { - const { - txData, - methodData: { name }, - fetchingData, - isEtherTransaction, - } = 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 (isEtherTransaction) { - const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` - return <Redirect to={{ pathname }} /> - } - - if (data) { - const methodName = name && name.toLowerCase() - - switch (methodName) { - case TOKEN_METHOD_TRANSFER: { - const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_TOKEN_PATH}` - return <Redirect to={{ pathname }} /> - } - case TOKEN_METHOD_APPROVE: { - const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_APPROVE_PATH}` - return <Redirect to={{ pathname }} /> - } - case TOKEN_METHOD_TRANSFER_FROM: { - const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TRANSFER_FROM_PATH}` - return <Redirect to={{ pathname }} /> - } - default: { - const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TOKEN_METHOD_PATH}` - return <Redirect to={{ pathname }} /> - } - } - } - - const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` - return <Redirect to={{ pathname }} /> - } - - render () { - const { txData } = this.props - - if (txData.txParams) { - return this.redirectToTransaction() - } else if (txData.msgParams) { - const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${txData.id}${SIGNATURE_REQUEST_PATH}` - return <Redirect to={{ pathname }} /> - } - - return <Loading /> - } -} diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js deleted file mode 100644 index 2e460f377..000000000 --- a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js +++ /dev/null @@ -1,160 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import { Switch, Route } from 'react-router-dom' -import Loading from '../../loading-screen' -import ConfirmTransactionSwitch from '../confirm-transaction-switch' -import ConfirmTransactionBase from '../confirm-transaction-base' -import ConfirmSendEther from '../confirm-send-ether' -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 { - DEFAULT_ROUTE, - CONFIRM_TRANSACTION_ROUTE, - CONFIRM_DEPLOY_CONTRACT_PATH, - CONFIRM_SEND_ETHER_PATH, - CONFIRM_SEND_TOKEN_PATH, - CONFIRM_APPROVE_PATH, - CONFIRM_TRANSFER_FROM_PATH, - CONFIRM_TOKEN_METHOD_PATH, - SIGNATURE_REQUEST_PATH, -} from '../../../routes' - -export default class ConfirmTransaction extends Component { - static propTypes = { - history: PropTypes.object.isRequired, - totalUnapprovedCount: PropTypes.number.isRequired, - match: PropTypes.object, - send: PropTypes.object, - unconfirmedTransactions: PropTypes.array, - setTransactionToConfirm: PropTypes.func, - confirmTransaction: PropTypes.object, - clearConfirmTransaction: PropTypes.func, - fetchBasicGasAndTimeEstimates: PropTypes.func, - } - - getParamsTransactionId () { - const { match: { params: { id } = {} } } = this.props - return id || null - } - - componentDidMount () { - const { - totalUnapprovedCount = 0, - send = {}, - history, - confirmTransaction: { txData: { id: transactionId } = {} }, - fetchBasicGasAndTimeEstimates, - } = this.props - - if (!totalUnapprovedCount && !send.to) { - history.replace(DEFAULT_ROUTE) - return - } - - if (!transactionId) { - fetchBasicGasAndTimeEstimates() - this.setTransactionToConfirm() - } - } - - componentDidUpdate () { - const { - setTransactionToConfirm, - confirmTransaction: { txData: { id: transactionId } = {} }, - clearConfirmTransaction, - } = this.props - const paramsTransactionId = this.getParamsTransactionId() - - if (paramsTransactionId && transactionId && paramsTransactionId !== transactionId + '') { - clearConfirmTransaction() - setTransactionToConfirm(paramsTransactionId) - return - } - - if (!transactionId) { - this.setTransactionToConfirm() - } - } - - setTransactionToConfirm () { - const { - history, - unconfirmedTransactions, - setTransactionToConfirm, - } = this.props - const paramsTransactionId = this.getParamsTransactionId() - - if (paramsTransactionId) { - // Check to make sure params ID is valid - const tx = unconfirmedTransactions.find(({ id }) => id + '' === paramsTransactionId) - - if (!tx) { - history.replace(DEFAULT_ROUTE) - } else { - setTransactionToConfirm(paramsTransactionId) - } - } else if (unconfirmedTransactions.length) { - const totalUnconfirmed = unconfirmedTransactions.length - const transaction = unconfirmedTransactions[totalUnconfirmed - 1] - const { id: transactionId, loadingDefaults } = transaction - - if (!loadingDefaults) { - setTransactionToConfirm(transactionId) - } - } - } - - render () { - const { confirmTransaction: { txData: { id } } = {} } = this.props - const paramsTransactionId = this.getParamsTransactionId() - - // Show routes when state.confirmTransaction has been set and when either the ID in the params - // isn't specified or is specified and matches the ID in state.confirmTransaction in order to - // support URLs of /confirm-transaction or /confirm-transaction/<transactionId> - return id && (!paramsTransactionId || paramsTransactionId === id + '') - ? ( - <Switch> - <Route - exact - path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_DEPLOY_CONTRACT_PATH}`} - component={ConfirmDeployContract} - /> - <Route - exact - path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TOKEN_METHOD_PATH}`} - component={ConfirmTransactionBase} - /> - <Route - exact - path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_ETHER_PATH}`} - component={ConfirmSendEther} - /> - <Route - exact - path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_TOKEN_PATH}`} - component={ConfirmSendToken} - /> - <Route - exact - path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_APPROVE_PATH}`} - component={ConfirmApprove} - /> - <Route - exact - path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TRANSFER_FROM_PATH}`} - component={ConfirmTokenTransactionBase} - /> - <Route - exact - path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${SIGNATURE_REQUEST_PATH}`} - component={ConfTx} - /> - <Route path="*" component={ConfirmTransactionSwitch} /> - </Switch> - ) - : <Loading /> - } -} diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js deleted file mode 100644 index 46342dc76..000000000 --- a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js +++ /dev/null @@ -1,37 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import { withRouter } from 'react-router-dom' -import { - setTransactionToConfirm, - clearConfirmTransaction, -} from '../../../ducks/confirm-transaction.duck' -import { - fetchBasicGasAndTimeEstimates, -} from '../../../ducks/gas.duck' -import ConfirmTransaction from './confirm-transaction.component' -import { getTotalUnapprovedCount } from '../../../selectors' -import { unconfirmedTransactionsListSelector } from '../../../selectors/confirm-transaction' - -const mapStateToProps = state => { - const { metamask: { send }, confirmTransaction } = state - - return { - totalUnapprovedCount: getTotalUnapprovedCount(state), - send, - confirmTransaction, - unconfirmedTransactions: unconfirmedTransactionsListSelector(state), - } -} - -const mapDispatchToProps = dispatch => { - return { - setTransactionToConfirm: transactionId => dispatch(setTransactionToConfirm(transactionId)), - clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), - fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()), - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps), -)(ConfirmTransaction) diff --git a/ui/app/components/pages/create-account/connect-hardware/account-list.js b/ui/app/components/pages/create-account/connect-hardware/account-list.js deleted file mode 100644 index c63de234a..000000000 --- a/ui/app/components/pages/create-account/connect-hardware/account-list.js +++ /dev/null @@ -1,205 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const genAccountLink = require('../../../../../lib/account-link.js') -const Select = require('react-select').default -import Button from '../../../button' - -class AccountList extends Component { - constructor (props, context) { - super(props) - } - - getHdPaths () { - return [ - { - label: `Ledger Live`, - value: `m/44'/60'/0'/0/0`, - }, - { - label: `Legacy (MEW / MyCrypto)`, - value: `m/44'/60'/0'`, - }, - ] - } - - goToNextPage = () => { - // If we have < 5 accounts, it's restricted by BIP-44 - if (this.props.accounts.length === 5) { - this.props.getPage(this.props.device, 1, this.props.selectedPath) - } else { - this.props.onAccountRestriction() - } - } - - goToPreviousPage = () => { - this.props.getPage(this.props.device, -1, this.props.selectedPath) - } - - renderHdPathSelector () { - const { onPathChange, selectedPath } = this.props - - const options = this.getHdPaths() - return h('div', [ - h('h3.hw-connect__hdPath__title', {}, this.context.t('selectHdPath')), - h('p.hw-connect__msg', {}, this.context.t('selectPathHelp')), - h('div.hw-connect__hdPath', [ - h(Select, { - className: 'hw-connect__hdPath__select', - name: 'hd-path-select', - clearable: false, - value: selectedPath, - options, - onChange: (opt) => { - onPathChange(opt.value) - }, - }), - ]), - ]) - } - - capitalizeDevice (device) { - return device.slice(0, 1).toUpperCase() + device.slice(1) - } - - renderHeader () { - const { device } = this.props - return ( - h('div.hw-connect', [ - - h('h3.hw-connect__unlock-title', {}, `${this.context.t('unlock')} ${this.capitalizeDevice(device)}`), - - device.toLowerCase() === 'ledger' ? this.renderHdPathSelector() : null, - - h('h3.hw-connect__hdPath__title', {}, this.context.t('selectAnAccount')), - h('p.hw-connect__msg', {}, this.context.t('selectAnAccountHelp')), - ]) - ) - } - - renderAccounts () { - return h('div.hw-account-list', [ - this.props.accounts.map((a, i) => { - - return h('div.hw-account-list__item', { key: a.address }, [ - h('div.hw-account-list__item__radio', [ - h('input', { - type: 'radio', - name: 'selectedAccount', - id: `address-${i}`, - value: a.index, - onChange: (e) => this.props.onAccountChange(e.target.value), - checked: this.props.selectedAccount === a.index.toString(), - }), - h( - 'label.hw-account-list__item__label', - { - htmlFor: `address-${i}`, - }, - [ - h('span.hw-account-list__item__index', a.index + 1), - `${a.address.slice(0, 4)}...${a.address.slice(-4)}`, - h('span.hw-account-list__item__balance', `${a.balance}`), - ]), - ]), - h( - 'a.hw-account-list__item__link', - { - href: genAccountLink(a.address, this.props.network), - target: '_blank', - title: this.context.t('etherscanView'), - }, - h('img', { src: 'images/popout.svg' }) - ), - ]) - }), - ]) - } - - renderPagination () { - return h('div.hw-list-pagination', [ - h( - 'button.hw-list-pagination__button', - { - onClick: this.goToPreviousPage, - }, - `< ${this.context.t('prev')}` - ), - - h( - 'button.hw-list-pagination__button', - { - onClick: this.goToNextPage, - }, - `${this.context.t('next')} >` - ), - ]) - } - - renderButtons () { - const disabled = this.props.selectedAccount === null - const buttonProps = {} - if (disabled) { - buttonProps.disabled = true - } - - return h('div.new-account-connect-form__buttons', {}, [ - h(Button, { - type: 'default', - large: true, - className: 'new-account-connect-form__button', - onClick: this.props.onCancel.bind(this), - }, [this.context.t('cancel')]), - - h(Button, { - type: 'confirm', - large: true, - className: 'new-account-connect-form__button unlock', - disabled, - onClick: this.props.onUnlockAccount.bind(this, this.props.device), - }, [this.context.t('unlock')]), - ]) - } - - renderForgetDevice () { - return h('div.hw-forget-device-container', {}, [ - h('a', { - onClick: this.props.onForgetDevice.bind(this, this.props.device), - }, this.context.t('forgetDevice')), - ]) - } - - render () { - return h('div.new-account-connect-form.account-list', {}, [ - this.renderHeader(), - this.renderAccounts(), - this.renderPagination(), - this.renderButtons(), - this.renderForgetDevice(), - ]) - } - -} - - -AccountList.propTypes = { - onPathChange: PropTypes.func.isRequired, - selectedPath: PropTypes.string.isRequired, - device: PropTypes.string.isRequired, - accounts: PropTypes.array.isRequired, - onAccountChange: PropTypes.func.isRequired, - onForgetDevice: PropTypes.func.isRequired, - getPage: PropTypes.func.isRequired, - network: PropTypes.string, - selectedAccount: PropTypes.string, - history: PropTypes.object, - onUnlockAccount: PropTypes.func, - onCancel: PropTypes.func, - onAccountRestriction: PropTypes.func, -} - -AccountList.contextTypes = { - t: PropTypes.func, -} - -module.exports = AccountList diff --git a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js b/ui/app/components/pages/create-account/connect-hardware/connect-screen.js deleted file mode 100644 index 49a5610c1..000000000 --- a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js +++ /dev/null @@ -1,197 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -import Button from '../../../button' - -class ConnectScreen extends Component { - constructor (props, context) { - super(props) - this.state = { - selectedDevice: null, - } - } - - connect = () => { - if (this.state.selectedDevice) { - this.props.connectToHardwareWallet(this.state.selectedDevice) - } - return null - } - - renderConnectToTrezorButton () { - return h( - `button.hw-connect__btn${this.state.selectedDevice === 'trezor' ? '.selected' : ''}`, - { onClick: _ => this.setState({selectedDevice: 'trezor'}) }, - h('img.hw-connect__btn__img', { - src: 'images/trezor-logo.svg', - }) - ) - } - - renderConnectToLedgerButton () { - return h( - `button.hw-connect__btn${this.state.selectedDevice === 'ledger' ? '.selected' : ''}`, - { onClick: _ => this.setState({selectedDevice: 'ledger'}) }, - h('img.hw-connect__btn__img', { - src: 'images/ledger-logo.svg', - }) - ) - } - - renderButtons () { - return ( - h('div', {}, [ - h('div.hw-connect__btn-wrapper', {}, [ - this.renderConnectToLedgerButton(), - this.renderConnectToTrezorButton(), - ]), - h(Button, { - type: 'confirm', - large: true, - className: 'hw-connect__connect-btn', - onClick: this.connect, - disabled: !this.state.selectedDevice, - }, this.context.t('connect')), - ]) - ) - } - - renderUnsupportedBrowser () { - return ( - h('div.new-account-connect-form.unsupported-browser', {}, [ - h('div.hw-connect', [ - h('h3.hw-connect__title', {}, this.context.t('browserNotSupported')), - h('p.hw-connect__msg', {}, this.context.t('chromeRequiredForHardwareWallets')), - ]), - h(Button, { - type: 'primary', - large: true, - onClick: () => global.platform.openWindow({ - url: 'https://google.com/chrome', - }), - }, this.context.t('downloadGoogleChrome')), - ]) - ) - } - - renderHeader () { - return ( - h('div.hw-connect__header', {}, [ - h('h3.hw-connect__header__title', {}, this.context.t(`hardwareWallets`)), - h('p.hw-connect__header__msg', {}, this.context.t(`hardwareWalletsMsg`)), - ]) - ) - } - - getAffiliateLinks () { - const links = { - trezor: `<a class='hw-connect__get-hw__link' href='https://shop.trezor.io/?a=metamask' target='_blank'>Trezor</a>`, - ledger: `<a class='hw-connect__get-hw__link' href='https://www.ledger.com/products/ledger-nano-s?r=17c4991a03fa&tracker=MY_TRACKER' target='_blank'>Ledger</a>`, - } - - const text = this.context.t('orderOneHere') - const response = text.replace('Trezor', links.trezor).replace('Ledger', links.ledger) - - return h('div.hw-connect__get-hw__msg', { dangerouslySetInnerHTML: {__html: response }}) - } - - renderTrezorAffiliateLink () { - return h('div.hw-connect__get-hw', {}, [ - h('p.hw-connect__get-hw__msg', {}, this.context.t(`dontHaveAHardwareWallet`)), - this.getAffiliateLinks(), - ]) - } - - - scrollToTutorial = (e) => { - if (this.referenceNode) this.referenceNode.scrollIntoView({behavior: 'smooth'}) - } - - renderLearnMore () { - return ( - h('p.hw-connect__learn-more', { - onClick: this.scrollToTutorial, - }, [ - this.context.t('learnMore'), - h('img.hw-connect__learn-more__arrow', { src: 'images/caret-right.svg'}), - ]) - ) - } - - renderTutorialSteps () { - const steps = [ - { - asset: 'hardware-wallet-step-1', - dimensions: {width: '225px', height: '75px'}, - }, - { - asset: 'hardware-wallet-step-2', - dimensions: {width: '300px', height: '100px'}, - }, - { - asset: 'hardware-wallet-step-3', - dimensions: {width: '120px', height: '90px'}, - }, - ] - - return h('.hw-tutorial', { - ref: node => { this.referenceNode = node }, - }, - steps.map((step, i) => ( - h('div.hw-connect', {}, [ - h('h3.hw-connect__title', {}, this.context.t(`step${i + 1}HardwareWallet`)), - h('p.hw-connect__msg', {}, this.context.t(`step${i + 1}HardwareWalletMsg`)), - h('img.hw-connect__step-asset', { src: `images/${step.asset}.svg`, ...step.dimensions }), - ]) - )) - ) - } - - renderFooter () { - return ( - h('div.hw-connect__footer', {}, [ - h('h3.hw-connect__footer__title', {}, this.context.t(`readyToConnect`)), - this.renderButtons(), - h('p.hw-connect__footer__msg', {}, [ - this.context.t(`havingTroubleConnecting`), - h('a.hw-connect__footer__link', { - href: 'https://support.metamask.io/', - target: '_blank', - }, this.context.t('getHelp')), - ]), - ]) - ) - } - - renderConnectScreen () { - return ( - h('div.new-account-connect-form', {}, [ - this.renderHeader(), - this.renderButtons(), - this.renderTrezorAffiliateLink(), - this.renderLearnMore(), - this.renderTutorialSteps(), - this.renderFooter(), - ]) - ) - } - - render () { - if (this.props.browserSupported) { - return this.renderConnectScreen() - } - return this.renderUnsupportedBrowser() - } -} - -ConnectScreen.propTypes = { - connectToHardwareWallet: PropTypes.func.isRequired, - browserSupported: PropTypes.bool.isRequired, -} - -ConnectScreen.contextTypes = { - t: PropTypes.func, -} - -module.exports = ConnectScreen - diff --git a/ui/app/components/pages/create-account/connect-hardware/index.js b/ui/app/components/pages/create-account/connect-hardware/index.js deleted file mode 100644 index 712cc5cbb..000000000 --- a/ui/app/components/pages/create-account/connect-hardware/index.js +++ /dev/null @@ -1,293 +0,0 @@ -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 { getMetaMaskAccounts } = require('../../../../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') - -class ConnectHardwareForm extends Component { - constructor (props, context) { - super(props) - this.state = { - error: null, - selectedAccount: null, - accounts: [], - browserSupported: true, - unlocked: false, - device: null, - } - } - - componentWillReceiveProps (nextProps) { - const { accounts } = nextProps - const newAccounts = this.state.accounts.map(a => { - const normalizedAddress = a.address.toLowerCase() - const balanceValue = accounts[normalizedAddress] && accounts[normalizedAddress].balance || null - a.balance = balanceValue ? formatBalance(balanceValue, 6) : '...' - return a - }) - this.setState({accounts: newAccounts}) - } - - - componentDidMount () { - this.checkIfUnlocked() - } - - async checkIfUnlocked () { - ['trezor', 'ledger'].forEach(async device => { - const unlocked = await this.props.checkHardwareStatus(device, this.props.defaultHdPaths[device]) - if (unlocked) { - this.setState({unlocked: true}) - this.getPage(device, 0, this.props.defaultHdPaths[device]) - } - }) - } - - connectToHardwareWallet = (device) => { - // Ledger hardware wallets are not supported on firefox - if (getPlatform() === PLATFORM_FIREFOX && device === 'ledger') { - this.setState({ browserSupported: false, error: null}) - return null - } - - if (this.state.accounts.length) { - return null - } - - // Default values - this.getPage(device, 0, this.props.defaultHdPaths[device]) - } - - onPathChange = (path) => { - this.props.setHardwareWalletDefaultHdPath({device: this.state.device, path}) - this.getPage(this.state.device, 0, path) - } - - onAccountChange = (account) => { - this.setState({selectedAccount: account.toString(), error: null}) - } - - onAccountRestriction = () => { - this.setState({error: this.context.t('ledgerAccountRestriction') }) - } - - showTemporaryAlert () { - this.props.showAlert(this.context.t('hardwareWalletConnected')) - // Autohide the alert after 5 seconds - setTimeout(_ => { - this.props.hideAlert() - }, 5000) - } - - getPage = (device, page, hdPath) => { - this.props - .connectHardware(device, page, hdPath) - .then(accounts => { - if (accounts.length) { - - // If we just loaded the accounts for the first time - // (device previously locked) show the global alert - if (this.state.accounts.length === 0 && !this.state.unlocked) { - this.showTemporaryAlert() - } - - const newState = { unlocked: true, device, error: null } - // Default to the first account - if (this.state.selectedAccount === null) { - accounts.forEach((a, i) => { - if (a.address.toLowerCase() === this.props.address) { - newState.selectedAccount = a.index.toString() - } - }) - // If the page doesn't contain the selected account, let's deselect it - } else if (!accounts.filter(a => a.index.toString() === this.state.selectedAccount).length) { - newState.selectedAccount = null - } - - - // Map accounts with balances - newState.accounts = accounts.map(account => { - const normalizedAddress = account.address.toLowerCase() - const balanceValue = this.props.accounts[normalizedAddress] && this.props.accounts[normalizedAddress].balance || null - account.balance = balanceValue ? formatBalance(balanceValue, 6) : '...' - return account - }) - - this.setState(newState) - } - }) - .catch(e => { - if (e === 'Window blocked') { - this.setState({ browserSupported: false, error: null}) - } else if (e !== 'Window closed' && e !== 'Popup closed') { - this.setState({ error: e.toString() }) - } - }) - } - - onForgetDevice = (device) => { - this.props.forgetDevice(device) - .then(_ => { - this.setState({ - error: null, - selectedAccount: null, - accounts: [], - unlocked: false, - }) - }).catch(e => { - this.setState({ error: e.toString() }) - }) - } - - onUnlockAccount = (device) => { - - if (this.state.selectedAccount === null) { - this.setState({ error: this.context.t('accountSelectionRequired') }) - } - - 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() }) - }) - } - - onCancel = () => { - this.props.history.push(DEFAULT_ROUTE) - } - - renderError () { - return this.state.error - ? h('span.error', { style: { margin: '20px 20px 10px', display: 'block', textAlign: 'center' } }, this.state.error) - : null - } - - renderContent () { - if (!this.state.accounts.length) { - return h(ConnectScreen, { - connectToHardwareWallet: this.connectToHardwareWallet, - browserSupported: this.state.browserSupported, - }) - } - - return h(AccountList, { - onPathChange: this.onPathChange, - selectedPath: this.props.defaultHdPaths[this.state.device], - device: this.state.device, - accounts: this.state.accounts, - selectedAccount: this.state.selectedAccount, - onAccountChange: this.onAccountChange, - network: this.props.network, - getPage: this.getPage, - history: this.props.history, - onUnlockAccount: this.onUnlockAccount, - onForgetDevice: this.onForgetDevice, - onCancel: this.onCancel, - onAccountRestriction: this.onAccountRestriction, - }) - } - - render () { - return h('div', [ - this.renderError(), - this.renderContent(), - ]) - } -} - -ConnectHardwareForm.propTypes = { - hideModal: PropTypes.func, - showImportPage: PropTypes.func, - showConnectPage: PropTypes.func, - connectHardware: PropTypes.func, - checkHardwareStatus: PropTypes.func, - forgetDevice: PropTypes.func, - showAlert: PropTypes.func, - hideAlert: PropTypes.func, - unlockHardwareWalletAccount: PropTypes.func, - setHardwareWalletDefaultHdPath: PropTypes.func, - numberOfExistingAccounts: PropTypes.number, - history: PropTypes.object, - t: PropTypes.func, - network: PropTypes.string, - accounts: PropTypes.object, - address: PropTypes.string, - defaultHdPaths: PropTypes.object, -} - -const mapStateToProps = state => { - const { - metamask: { network, selectedAddress, identities = {} }, - } = state - const accounts = getMetaMaskAccounts(state) - const numberOfExistingAccounts = Object.keys(identities).length - const { - appState: { defaultHdPaths }, - } = state - - return { - network, - accounts, - address: selectedAddress, - numberOfExistingAccounts, - defaultHdPaths, - } -} - -const mapDispatchToProps = dispatch => { - return { - setHardwareWalletDefaultHdPath: ({device, path}) => { - return dispatch(actions.setHardwareWalletDefaultHdPath({device, path})) - }, - connectHardware: (deviceName, page, hdPath) => { - return dispatch(actions.connectHardware(deviceName, page, hdPath)) - }, - checkHardwareStatus: (deviceName, hdPath) => { - return dispatch(actions.checkHardwareStatus(deviceName, hdPath)) - }, - forgetDevice: (deviceName) => { - return dispatch(actions.forgetDevice(deviceName)) - }, - unlockHardwareWalletAccount: (index, deviceName, hdPath) => { - return dispatch(actions.unlockHardwareWalletAccount(index, deviceName, hdPath)) - }, - showImportPage: () => dispatch(actions.showImportPage()), - showConnectPage: () => dispatch(actions.showConnectPage()), - showAlert: (msg) => dispatch(actions.showAlert(msg)), - hideAlert: () => dispatch(actions.hideAlert()), - } -} - -ConnectHardwareForm.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)( - ConnectHardwareForm -) diff --git a/ui/app/components/pages/create-account/import-account/json.js b/ui/app/components/pages/create-account/import-account/json.js deleted file mode 100644 index 9aeea5579..000000000 --- a/ui/app/components/pages/create-account/import-account/json.js +++ /dev/null @@ -1,170 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -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 FileInput = require('react-simple-file-input').default -const { DEFAULT_ROUTE } = require('../../../../routes') -const { getMetaMaskAccounts } = require('../../../../selectors') -const HELP_LINK = 'https://support.metamask.io/kb/article/7-importing-accounts' -import Button from '../../../button' - -class JsonImportSubview extends Component { - constructor (props) { - super(props) - - this.state = { - file: null, - fileContents: '', - } - } - - render () { - const { error } = this.props - - return ( - h('div.new-account-import-form__json', [ - - h('p', this.context.t('usedByClients')), - h('a.warning', { - href: HELP_LINK, - target: '_blank', - }, this.context.t('fileImportFail')), - - h(FileInput, { - readAs: 'text', - onLoad: this.onLoad.bind(this), - style: { - margin: '20px 0px 12px 34%', - fontSize: '15px', - display: 'flex', - justifyContent: 'center', - }, - }), - - h('input.new-account-import-form__input-password', { - type: 'password', - placeholder: this.context.t('enterPassword'), - id: 'json-password-box', - onKeyPress: this.createKeyringOnEnter.bind(this), - }), - - h('div.new-account-create-form__buttons', {}, [ - - 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: () => this.createNewKeychain(), - }, [this.context.t('import')]), - - ]), - - error ? h('span.error', error) : null, - ]) - ) - } - - onLoad (event, file) { - this.setState({file: file, fileContents: event.target.result}) - } - - createKeyringOnEnter (event) { - if (event.key === 'Enter') { - event.preventDefault() - this.createNewKeychain() - } - } - - createNewKeychain () { - const { firstAddress, displayWarning, importNewJsonAccount, setSelectedAddress, history } = this.props - const state = this.state - - if (!state) { - const message = this.context.t('validFileImport') - return displayWarning(message) - } - - const { fileContents } = state - - if (!fileContents) { - const message = this.context.t('needImportFile') - return displayWarning(message) - } - - const passwordInput = document.getElementById('json-password-box') - const password = passwordInput.value - - 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) - } - }) - .catch(err => err && displayWarning(err.message || err)) - } -} - -JsonImportSubview.propTypes = { - error: PropTypes.string, - goHome: PropTypes.func, - displayWarning: PropTypes.func, - firstAddress: PropTypes.string, - importNewJsonAccount: PropTypes.func, - history: PropTypes.object, - setSelectedAddress: PropTypes.func, - t: PropTypes.func, -} - -const mapStateToProps = state => { - return { - error: state.appState.warning, - firstAddress: Object.keys(getMetaMaskAccounts(state))[0], - } -} - -const mapDispatchToProps = dispatch => { - return { - goHome: () => dispatch(actions.goHome()), - displayWarning: warning => dispatch(actions.displayWarning(warning)), - importNewJsonAccount: options => dispatch(actions.importNewAccount('JSON File', options)), - setSelectedAddress: (address) => dispatch(actions.setSelectedAddress(address)), - } -} - -JsonImportSubview.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(JsonImportSubview) diff --git a/ui/app/components/pages/create-account/import-account/private-key.js b/ui/app/components/pages/create-account/import-account/private-key.js deleted file mode 100644 index 4ba31806f..000000000 --- a/ui/app/components/pages/create-account/import-account/private-key.js +++ /dev/null @@ -1,128 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const h = require('react-hyperscript') -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') -const { getMetaMaskAccounts } = require('../../../../selectors') -import Button from '../../../button' - -PrivateKeyImportView.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(PrivateKeyImportView) - - -function mapStateToProps (state) { - return { - error: state.appState.warning, - firstAddress: Object.keys(getMetaMaskAccounts(state))[0], - } -} - -function mapDispatchToProps (dispatch) { - return { - importNewAccount: (strategy, [ privateKey ]) => { - return dispatch(actions.importNewAccount(strategy, [ privateKey ])) - }, - displayWarning: (message) => dispatch(actions.displayWarning(message || null)), - setSelectedAddress: (address) => dispatch(actions.setSelectedAddress(address)), - } -} - -inherits(PrivateKeyImportView, Component) -function PrivateKeyImportView () { - this.createKeyringOnEnter = this.createKeyringOnEnter.bind(this) - Component.call(this) -} - -PrivateKeyImportView.prototype.render = function () { - const { error, displayWarning } = this.props - - return ( - h('div.new-account-import-form__private-key', [ - - h('span.new-account-create-form__instruction', this.context.t('pastePrivateKey')), - - h('div.new-account-import-form__private-key-password-container', [ - - h('input.new-account-import-form__input-password', { - type: 'password', - id: 'private-key-box', - onKeyPress: e => this.createKeyringOnEnter(e), - }), - - ]), - - h('div.new-account-import-form__buttons', {}, [ - - h(Button, { - type: 'default', - large: true, - className: 'new-account-create-form__button', - onClick: () => { - displayWarning(null) - this.props.history.push(DEFAULT_ROUTE) - }, - }, [this.context.t('cancel')]), - - h(Button, { - type: 'primary', - large: true, - className: 'new-account-create-form__button', - onClick: () => this.createNewKeychain(), - }, [this.context.t('import')]), - - ]), - - error ? h('span.error', error) : null, - ]) - ) -} - -PrivateKeyImportView.prototype.createKeyringOnEnter = function (event) { - if (event.key === 'Enter') { - event.preventDefault() - this.createNewKeychain() - } -} - -PrivateKeyImportView.prototype.createNewKeychain = function () { - const input = document.getElementById('private-key-box') - const privateKey = input.value - const { importNewAccount, history, displayWarning, setSelectedAddress, firstAddress } = this.props - - 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) - } - }) - .catch(err => err && displayWarning(err.message || err)) -} diff --git a/ui/app/components/pages/create-account/index.js b/ui/app/components/pages/create-account/index.js deleted file mode 100644 index d3de1ea01..000000000 --- a/ui/app/components/pages/create-account/index.js +++ /dev/null @@ -1,113 +0,0 @@ -const Component = require('react').Component -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 classnames = require('classnames') -const NewAccountCreateForm = require('./new-account') -const NewAccountImportForm = require('./import-account') -const ConnectHardwareForm = require('./connect-hardware') -const { - NEW_ACCOUNT_ROUTE, - IMPORT_ACCOUNT_ROUTE, - CONNECT_HARDWARE_ROUTE, -} = require('../../../routes') - -class CreateAccountPage extends Component { - renderTabs () { - const { history, location } = this.props - - return h('div.new-account__tabs', [ - h('div.new-account__tabs__tab', { - className: classnames('new-account__tabs__tab', { - 'new-account__tabs__selected': matchPath(location.pathname, { - path: NEW_ACCOUNT_ROUTE, exact: true, - }), - }), - onClick: () => history.push(NEW_ACCOUNT_ROUTE), - }, [ - this.context.t('create'), - ]), - - h('div.new-account__tabs__tab', { - className: classnames('new-account__tabs__tab', { - 'new-account__tabs__selected': matchPath(location.pathname, { - path: IMPORT_ACCOUNT_ROUTE, exact: true, - }), - }), - onClick: () => history.push(IMPORT_ACCOUNT_ROUTE), - }, [ - this.context.t('import'), - ]), - h( - 'div.new-account__tabs__tab', - { - className: classnames('new-account__tabs__tab', { - 'new-account__tabs__selected': matchPath(location.pathname, { - path: CONNECT_HARDWARE_ROUTE, - exact: true, - }), - }), - onClick: () => history.push(CONNECT_HARDWARE_ROUTE), - }, - this.context.t('connect') - ), - ]) - } - - render () { - return h('div.new-account', {}, [ - h('div.new-account__header', [ - h('div.new-account__title', this.context.t('newAccount')), - this.renderTabs(), - ]), - h('div.new-account__form', [ - h(Switch, [ - h(Route, { - exact: true, - path: NEW_ACCOUNT_ROUTE, - component: NewAccountCreateForm, - }), - h(Route, { - exact: true, - path: IMPORT_ACCOUNT_ROUTE, - component: NewAccountImportForm, - }), - h(Route, { - exact: true, - path: CONNECT_HARDWARE_ROUTE, - component: ConnectHardwareForm, - }), - ]), - ]), - ]) - } -} - -CreateAccountPage.propTypes = { - location: PropTypes.object, - history: PropTypes.object, - t: PropTypes.func, -} - -CreateAccountPage.contextTypes = { - t: PropTypes.func, -} - -const mapStateToProps = state => ({ - displayedForm: getCurrentViewContext(state), -}) - -const mapDispatchToProps = dispatch => ({ - 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)), -}) - -module.exports = connect(mapStateToProps, mapDispatchToProps)(CreateAccountPage) diff --git a/ui/app/components/pages/create-account/new-account.js b/ui/app/components/pages/create-account/new-account.js deleted file mode 100644 index a7595e346..000000000 --- a/ui/app/components/pages/create-account/new-account.js +++ /dev/null @@ -1,130 +0,0 @@ -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' - -class NewAccountCreateForm extends Component { - constructor (props, context) { - super(props) - - const { numberOfExistingAccounts = 0 } = props - const newAccountNumber = numberOfExistingAccounts + 1 - - this.state = { - newAccountName: '', - defaultAccountName: context.t('newAccountNumberName', [newAccountNumber]), - } - } - - render () { - const { newAccountName, defaultAccountName } = this.state - const { history, createAccount } = this.props - - return h('div.new-account-create-form', [ - - h('div.new-account-create-form__input-label', {}, [ - this.context.t('accountName'), - ]), - - h('div.new-account-create-form__input-wrapper', {}, [ - h('input.new-account-create-form__input', { - value: newAccountName, - placeholder: defaultAccountName, - onChange: event => this.setState({ newAccountName: event.target.value }), - }, []), - ]), - - h('div.new-account-create-form__buttons', {}, [ - - h(Button, { - type: 'default', - large: true, - className: 'new-account-create-form__button', - onClick: () => history.push(DEFAULT_ROUTE), - }, [this.context.t('cancel')]), - - h(Button, { - type: 'primary', - large: true, - className: 'new-account-create-form__button', - onClick: () => { - createAccount(newAccountName || defaultAccountName) - .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')]), - - ]), - - ]) - } -} - -NewAccountCreateForm.propTypes = { - hideModal: PropTypes.func, - showImportPage: PropTypes.func, - showConnectPage: PropTypes.func, - createAccount: PropTypes.func, - numberOfExistingAccounts: PropTypes.number, - history: PropTypes.object, - t: PropTypes.func, -} - -const mapStateToProps = state => { - const { metamask: { network, selectedAddress, identities = {} } } = state - const numberOfExistingAccounts = Object.keys(identities).length - - return { - network, - address: selectedAddress, - numberOfExistingAccounts, - } -} - -const mapDispatchToProps = dispatch => { - return { - toCoinbase: address => dispatch(actions.buyEth({ network: '1', address, amount: 0 })), - hideModal: () => dispatch(actions.hideModal()), - createAccount: newAccountName => { - return dispatch(actions.addNewAccount()) - .then(newAccountAddress => { - if (newAccountName) { - dispatch(actions.setAccountLabel(newAccountAddress, newAccountName)) - } - }) - }, - showImportPage: () => dispatch(actions.showImportPage()), - showConnectPage: () => dispatch(actions.showConnectPage()), - } -} - -NewAccountCreateForm.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(NewAccountCreateForm) - diff --git a/ui/app/components/pages/first-time-flow/create-password/create-password.component.js b/ui/app/components/pages/first-time-flow/create-password/create-password.component.js deleted file mode 100644 index 070361652..000000000 --- a/ui/app/components/pages/first-time-flow/create-password/create-password.component.js +++ /dev/null @@ -1,71 +0,0 @@ -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 '../../../../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/components/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js b/ui/app/components/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js deleted file mode 100644 index 4ecaa5895..000000000 --- a/ui/app/components/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js +++ /dev/null @@ -1,256 +0,0 @@ -import {validateMnemonic} from 'bip39' -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import TextField from '../../../../text-field' -import Button from '../../../../button' -import { - INITIALIZE_SELECT_ACTION_ROUTE, - INITIALIZE_END_OF_FLOW_ROUTE, -} from '../../../../../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/components/pages/first-time-flow/create-password/new-account/new-account.component.js b/ui/app/components/pages/first-time-flow/create-password/new-account/new-account.component.js deleted file mode 100644 index 11d10e2d9..000000000 --- a/ui/app/components/pages/first-time-flow/create-password/new-account/new-account.component.js +++ /dev/null @@ -1,225 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Button from '../../../../button' -import { - INITIALIZE_SEED_PHRASE_ROUTE, - INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, - INITIALIZE_SELECT_ACTION_ROUTE, -} from '../../../../../routes' -import TextField from '../../../../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/components/pages/first-time-flow/create-password/unique-image/unique-image.component.js b/ui/app/components/pages/first-time-flow/create-password/unique-image/unique-image.component.js deleted file mode 100644 index cbc85c0e4..000000000 --- a/ui/app/components/pages/first-time-flow/create-password/unique-image/unique-image.component.js +++ /dev/null @@ -1,55 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Button from '../../../../button' -import { INITIALIZE_END_OF_FLOW_ROUTE } from '../../../../../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/components/pages/first-time-flow/end-of-flow/end-of-flow.component.js b/ui/app/components/pages/first-time-flow/end-of-flow/end-of-flow.component.js deleted file mode 100644 index c0e2f59d9..000000000 --- a/ui/app/components/pages/first-time-flow/end-of-flow/end-of-flow.component.js +++ /dev/null @@ -1,93 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Button from '../../../button' -import { DEFAULT_ROUTE } from '../../../../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/components/pages/first-time-flow/end-of-flow/end-of-flow.container.js b/ui/app/components/pages/first-time-flow/end-of-flow/end-of-flow.container.js deleted file mode 100644 index 91ae5a941..000000000 --- a/ui/app/components/pages/first-time-flow/end-of-flow/end-of-flow.container.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux' -import EndOfFlow from './end-of-flow.component' -import { setCompletedOnboarding } from '../../../../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/components/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.component.js b/ui/app/components/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.component.js deleted file mode 100644 index 5c2294393..000000000 --- a/ui/app/components/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.component.js +++ /dev/null @@ -1,57 +0,0 @@ -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 '../../../../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/components/pages/first-time-flow/first-time-flow.component.js b/ui/app/components/pages/first-time-flow/first-time-flow.component.js deleted file mode 100644 index a1f629431..000000000 --- a/ui/app/components/pages/first-time-flow/first-time-flow.component.js +++ /dev/null @@ -1,152 +0,0 @@ -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 '../../../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/components/pages/first-time-flow/first-time-flow.container.js b/ui/app/components/pages/first-time-flow/first-time-flow.container.js deleted file mode 100644 index 293f94c47..000000000 --- a/ui/app/components/pages/first-time-flow/first-time-flow.container.js +++ /dev/null @@ -1,31 +0,0 @@ -import { connect } from 'react-redux' -import FirstTimeFlow from './first-time-flow.component' -import { getFirstTimeFlowTypeRoute } from './first-time-flow.selectors' -import { - createNewVaultAndGetSeedPhrase, - createNewVaultAndRestore, - unlockAndGetSeedPhrase, -} from '../../../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/components/pages/first-time-flow/first-time-flow.selectors.js b/ui/app/components/pages/first-time-flow/first-time-flow.selectors.js deleted file mode 100644 index 1286afed9..000000000 --- a/ui/app/components/pages/first-time-flow/first-time-flow.selectors.js +++ /dev/null @@ -1,26 +0,0 @@ -import { - INITIALIZE_CREATE_PASSWORD_ROUTE, - INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, - DEFAULT_ROUTE, -} from '../../../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/components/pages/first-time-flow/index.scss b/ui/app/components/pages/first-time-flow/index.scss deleted file mode 100644 index d41748575..000000000 --- a/ui/app/components/pages/first-time-flow/index.scss +++ /dev/null @@ -1,159 +0,0 @@ -@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/components/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js b/ui/app/components/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js deleted file mode 100644 index 2b4af27ad..000000000 --- a/ui/app/components/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js +++ /dev/null @@ -1,169 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import PageContainerFooter from '../../../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/components/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.container.js b/ui/app/components/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.container.js deleted file mode 100644 index b13af8bf6..000000000 --- a/ui/app/components/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.container.js +++ /dev/null @@ -1,27 +0,0 @@ -import { connect } from 'react-redux' -import MetaMetricsOptIn from './metametrics-opt-in.component' -import { setParticipateInMetaMetrics } from '../../../../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/components/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js b/ui/app/components/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js deleted file mode 100644 index bd5ab8a84..000000000 --- a/ui/app/components/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js +++ /dev/null @@ -1,155 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import shuffle from 'lodash.shuffle' -import Button from '../../../../button' -import { - INITIALIZE_END_OF_FLOW_ROUTE, - INITIALIZE_SEED_PHRASE_ROUTE, -} from '../../../../../routes' -import { exportAsFile } from '../../../../../../app/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/components/pages/first-time-flow/seed-phrase/index.scss b/ui/app/components/pages/first-time-flow/seed-phrase/index.scss deleted file mode 100644 index e4fd7be4f..000000000 --- a/ui/app/components/pages/first-time-flow/seed-phrase/index.scss +++ /dev/null @@ -1,40 +0,0 @@ -@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/components/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js b/ui/app/components/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js deleted file mode 100644 index cb8a01322..000000000 --- a/ui/app/components/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js +++ /dev/null @@ -1,143 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import LockIcon from '../../../../lock-icon' -import Button from '../../../../button' -import { INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE } from '../../../../../routes' -import { exportAsFile } from '../../../../../../app/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/components/pages/first-time-flow/seed-phrase/seed-phrase.component.js b/ui/app/components/pages/first-time-flow/seed-phrase/seed-phrase.component.js deleted file mode 100644 index 9eec89cdd..000000000 --- a/ui/app/components/pages/first-time-flow/seed-phrase/seed-phrase.component.js +++ /dev/null @@ -1,70 +0,0 @@ -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 '../../../../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/components/pages/first-time-flow/select-action/select-action.component.js b/ui/app/components/pages/first-time-flow/select-action/select-action.component.js deleted file mode 100644 index b6a6942c3..000000000 --- a/ui/app/components/pages/first-time-flow/select-action/select-action.component.js +++ /dev/null @@ -1,112 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Button from '../../../button' -import { - INITIALIZE_METAMETRICS_OPT_IN_ROUTE, -} from '../../../../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/components/pages/first-time-flow/select-action/select-action.container.js b/ui/app/components/pages/first-time-flow/select-action/select-action.container.js deleted file mode 100644 index 42fac7af2..000000000 --- a/ui/app/components/pages/first-time-flow/select-action/select-action.container.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' -import { setFirstTimeFlowType } from '../../../../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/components/pages/first-time-flow/welcome/welcome.component.js b/ui/app/components/pages/first-time-flow/welcome/welcome.component.js deleted file mode 100644 index 88cdb936c..000000000 --- a/ui/app/components/pages/first-time-flow/welcome/welcome.component.js +++ /dev/null @@ -1,69 +0,0 @@ -import EventEmitter from 'events' -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Mascot from '../../../mascot' -import Button from '../../../button' -import { INITIALIZE_CREATE_PASSWORD_ROUTE, INITIALIZE_SELECT_ACTION_ROUTE } from '../../../../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/components/pages/first-time-flow/welcome/welcome.container.js b/ui/app/components/pages/first-time-flow/welcome/welcome.container.js deleted file mode 100644 index 47753e16f..000000000 --- a/ui/app/components/pages/first-time-flow/welcome/welcome.container.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' -import { closeWelcomeScreen } from '../../../../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/components/pages/home/home.component.js deleted file mode 100644 index 953d43aba..000000000 --- a/ui/app/components/pages/home/home.component.js +++ /dev/null @@ -1,77 +0,0 @@ -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 ProviderApproval from '../provider-approval' - -import { - INITIALIZE_SEED_PHRASE_ROUTE, - RESTORE_VAULT_ROUTE, - CONFIRM_TRANSACTION_ROUTE, - CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, -} from '../../../routes' - -export default class Home extends PureComponent { - static propTypes = { - history: PropTypes.object, - forgottenPassword: PropTypes.bool, - seedWords: PropTypes.string, - suggestedTokens: PropTypes.object, - unconfirmedTransactionsCount: PropTypes.number, - providerRequests: PropTypes.array, - } - - componentDidMount () { - const { - history, - suggestedTokens = {}, - unconfirmedTransactionsCount = 0, - } = this.props - - // suggested new tokens - if (Object.keys(suggestedTokens).length > 0) { - history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE) - } - - if (unconfirmedTransactionsCount > 0) { - history.push(CONFIRM_TRANSACTION_ROUTE) - } - } - - render () { - const { - forgottenPassword, - seedWords, - providerRequests, - } = this.props - - // seed words - if (seedWords) { - return <Redirect to={{ pathname: INITIALIZE_SEED_PHRASE_ROUTE }}/> - } - - if (forgottenPassword) { - return <Redirect to={{ pathname: RESTORE_VAULT_ROUTE }} /> - } - - if (providerRequests && providerRequests.length > 0) { - return ( - <ProviderApproval providerRequest={providerRequests[0]} /> - ) - } - - return ( - <div className="main-container"> - <div className="account-and-transaction-details"> - <Media - query="(min-width: 576px)" - render={() => <WalletView />} - /> - <TransactionView /> - </div> - </div> - ) - } -} diff --git a/ui/app/components/pages/home/home.container.js b/ui/app/components/pages/home/home.container.js deleted file mode 100644 index bb8cf5e81..000000000 --- a/ui/app/components/pages/home/home.container.js +++ /dev/null @@ -1,32 +0,0 @@ -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' - -const mapStateToProps = state => { - const { metamask, appState } = state - const { - noActiveNotices, - lostAccounts, - seedWords, - suggestedTokens, - providerRequests, - } = metamask - const { forgottenPassword } = appState - - return { - noActiveNotices, - lostAccounts, - forgottenPassword, - seedWords, - suggestedTokens, - unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state), - providerRequests, - } -} - -export default compose( - withRouter, - connect(mapStateToProps) -)(Home) diff --git a/ui/app/components/pages/index.scss b/ui/app/components/pages/index.scss deleted file mode 100644 index 6a0680f32..000000000 --- a/ui/app/components/pages/index.scss +++ /dev/null @@ -1,11 +0,0 @@ -@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/components/pages/keychains/restore-vault.js b/ui/app/components/pages/keychains/restore-vault.js deleted file mode 100644 index 73ff5191a..000000000 --- a/ui/app/components/pages/keychains/restore-vault.js +++ /dev/null @@ -1,197 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import {connect} from 'react-redux' -import { - createNewVaultAndRestore, - unMarkPasswordForgotten, -} from '../../../actions' -import { DEFAULT_ROUTE } from '../../../routes' -import TextField from '../../text-field' -import Button from '../../button' - -class RestoreVaultPage extends Component { - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - } - - static propTypes = { - warning: PropTypes.string, - createNewVaultAndRestore: PropTypes.func.isRequired, - leaveImportSeedScreenState: PropTypes.func, - history: PropTypes.object, - isLoading: PropTypes.bool, - }; - - state = { - seedPhrase: '', - password: '', - confirmPassword: '', - seedPhraseError: null, - passwordError: null, - confirmPasswordError: null, - } - - parseSeedPhrase = (seedPhrase) => { - return seedPhrase - .match(/\w+/g) - .join(' ') - } - - handleSeedPhraseChange (seedPhrase) { - let seedPhraseError = null - - if (seedPhrase && this.parseSeedPhrase(seedPhrase).split(' ').length !== 12) { - seedPhraseError = this.context.t('seedPhraseReq') - } - - this.setState({ seedPhrase, seedPhraseError }) - } - - handlePasswordChange (password) { - const { confirmPassword } = this.state - let confirmPasswordError = null - let passwordError = null - - if (password && password.length < 8) { - passwordError = this.context.t('passwordNotLongEnough') - } - - if (confirmPassword && password !== confirmPassword) { - confirmPasswordError = this.context.t('passwordsDontMatch') - } - - this.setState({ password, passwordError, confirmPasswordError }) - } - - handleConfirmPasswordChange (confirmPassword) { - const { password } = this.state - let confirmPasswordError = null - - if (password !== confirmPassword) { - confirmPasswordError = this.context.t('passwordsDontMatch') - } - - this.setState({ confirmPassword, confirmPasswordError }) - } - - onClick = () => { - const { password, seedPhrase } = this.state - const { - createNewVaultAndRestore, - leaveImportSeedScreenState, - history, - } = this.props - - leaveImportSeedScreenState() - createNewVaultAndRestore(password, this.parseSeedPhrase(seedPhrase)) - .then(() => { - this.context.metricsEvent({ - eventOpts: { - category: 'Retention', - action: 'userEntersSeedPhrase', - name: 'onboardingRestoredVault', - }, - }) - history.push(DEFAULT_ROUTE) - }) - } - - hasError () { - const { passwordError, confirmPasswordError, seedPhraseError } = this.state - return passwordError || confirmPasswordError || seedPhraseError - } - - render () { - const { - seedPhrase, - password, - confirmPassword, - seedPhraseError, - passwordError, - confirmPasswordError, - } = this.state - const { t } = this.context - const { isLoading } = this.props - const disabled = !seedPhrase || !password || !confirmPassword || isLoading || this.hasError() - - return ( - <div className="first-view-main-wrapper"> - <div className="first-view-main"> - <div className="import-account"> - <a - className="import-account__back-button" - onClick={e => { - e.preventDefault() - this.props.history.goBack() - }} - href="#" - > - {`< Back`} - </a> - <div className="import-account__title"> - { this.context.t('restoreAccountWithSeed') } - </div> - <div className="import-account__selector-label"> - { this.context.t('secretPhrase') } - </div> - <div className="import-account__input-wrapper"> - <label className="import-account__input-label">Wallet Seed</label> - <textarea - className="import-account__secret-phrase" - onChange={e => this.handleSeedPhraseChange(e.target.value)} - value={this.state.seedPhrase} - placeholder={this.context.t('separateEachWord')} - /> - </div> - <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 - /> - <Button - type="first-time" - className="first-time-flow__button" - onClick={() => !disabled && this.onClick()} - disabled={disabled} - > - {this.context.t('restore')} - </Button> - </div> - </div> - </div> - ) - } -} - -export default connect( - ({ appState: { warning, isLoading } }) => ({ warning, isLoading }), - dispatch => ({ - leaveImportSeedScreenState: () => { - dispatch(unMarkPasswordForgotten()) - }, - createNewVaultAndRestore: (pw, seed) => dispatch(createNewVaultAndRestore(pw, seed)), - }) -)(RestoreVaultPage) diff --git a/ui/app/components/pages/keychains/reveal-seed.js b/ui/app/components/pages/keychains/reveal-seed.js deleted file mode 100644 index 32557066f..000000000 --- a/ui/app/components/pages/keychains/reveal-seed.js +++ /dev/null @@ -1,177 +0,0 @@ -const { Component } = require('react') -const { connect } = require('react-redux') -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') - -import Button from '../../button' - -const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' -const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' - -class RevealSeedPage extends Component { - constructor (props) { - super(props) - - this.state = { - screen: PASSWORD_PROMPT_SCREEN, - password: '', - seedWords: null, - error: null, - } - } - - 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.setState({ seedWords, screen: REVEAL_SEED_SCREEN })) - .catch(error => this.setState({ error: error.message })) - } - - renderWarning () { - return ( - h('.page-container__warning-container', [ - h('img.page-container__warning-icon', { - src: 'images/warning.svg', - }), - h('.page-container__warning-message', [ - h('.page-container__warning-title', [this.context.t('revealSeedWordsWarningTitle')]), - h('div', [this.context.t('revealSeedWordsWarning')]), - ]), - ]) - ) - } - - renderContent () { - return this.state.screen === PASSWORD_PROMPT_SCREEN - ? this.renderPasswordPromptContent() - : 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 { t } = this.context - - return ( - h('div', [ - h('label.reveal-seed__label', t('yourPrivateSeedPhrase')), - h(ExportTextContainer, { - text: this.state.seedWords, - filename: t('metamaskSeedWords'), - }), - ]) - ) - } - - renderFooter () { - return this.state.screen === PASSWORD_PROMPT_SCREEN - ? this.renderPasswordPromptFooter() - : this.renderRevealSeedFooter() - } - - renderPasswordPromptFooter () { - return ( - h('.page-container__footer', [ - h('header', [ - h(Button, { - type: 'default', - large: true, - className: 'page-container__footer-button', - onClick: () => this.props.history.push(DEFAULT_ROUTE), - }, this.context.t('cancel')), - h(Button, { - type: 'primary', - large: true, - className: 'page-container__footer-button', - onClick: event => this.handleSubmit(event), - disabled: this.state.password === '', - }, this.context.t('next')), - ]), - ]) - ) - } - - renderRevealSeedFooter () { - return ( - h('.page-container__footer', [ - 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('revealSeedWordsTitle')), - h('.page-container__subtitle', this.context.t('revealSeedWordsDescription')), - ]), - h('.page-container__content', [ - this.renderWarning(), - h('.reveal-seed__content', [ - this.renderContent(), - ]), - ]), - this.renderFooter(), - ]) - ) - } -} - -RevealSeedPage.propTypes = { - requestRevealSeedWords: PropTypes.func, - history: PropTypes.object, -} - -RevealSeedPage.contextTypes = { - t: PropTypes.func, -} - -const mapDispatchToProps = dispatch => { - return { - requestRevealSeedWords: password => dispatch(requestRevealSeedWords(password)), - } -} - -module.exports = connect(null, mapDispatchToProps)(RevealSeedPage) diff --git a/ui/app/components/pages/lock/lock.component.js b/ui/app/components/pages/lock/lock.component.js deleted file mode 100644 index 51f8742ed..000000000 --- a/ui/app/components/pages/lock/lock.component.js +++ /dev/null @@ -1,26 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import Loading from '../../loading-screen' -import { DEFAULT_ROUTE } from '../../../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/lock/lock.container.js b/ui/app/components/pages/lock/lock.container.js deleted file mode 100644 index 81d89ba21..000000000 --- a/ui/app/components/pages/lock/lock.container.js +++ /dev/null @@ -1,24 +0,0 @@ -import Lock from './lock.component' -import { compose } from 'recompose' -import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom' -import { lockMetamask } from '../../../actions' - -const mapStateToProps = state => { - const { metamask: { isUnlocked } } = state - - return { - isUnlocked, - } -} - -const mapDispatchToProps = dispatch => { - return { - lockMetamask: () => dispatch(lockMetamask()), - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(Lock) diff --git a/ui/app/components/pages/mobile-sync/index.js b/ui/app/components/pages/mobile-sync/index.js deleted file mode 100644 index 22a69d092..000000000 --- a/ui/app/components/pages/mobile-sync/index.js +++ /dev/null @@ -1,387 +0,0 @@ -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('../../../actions') -const { DEFAULT_ROUTE } = require('../../../routes') -const actions = require('../../../actions') - -const qrCode = require('qrcode-generator') - -import Button from '../../button' -import LoadingScreen from '../../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/components/pages/notice.js deleted file mode 100644 index a9077b98b..000000000 --- a/ui/app/components/pages/notice.js +++ /dev/null @@ -1,203 +0,0 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const { connect } = require('react-redux') -const PropTypes = require('prop-types') -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') - -class Notice extends Component { - constructor (props) { - super(props) - - this.state = { - disclaimerDisabled: true, - } - } - - componentWillMount () { - if (!this.props.notice) { - this.props.history.push(DEFAULT_ROUTE) - } - } - - componentDidMount () { - // eslint-disable-next-line react/no-find-dom-node - var node = findDOMNode(this) - linker.setupListener(node) - if (document.getElementsByClassName('notice-box')[0].clientHeight < 310) { - this.setState({ disclaimerDisabled: false }) - } - } - - componentWillReceiveProps (nextProps) { - if (!nextProps.notice) { - this.props.history.push(DEFAULT_ROUTE) - } - } - - componentWillUnmount () { - // eslint-disable-next-line react/no-find-dom-node - var node = findDOMNode(this) - linker.teardownListener(node) - } - - handleAccept () { - this.setState({ disclaimerDisabled: true }) - this.props.onConfirm() - } - - render () { - const { notice = {} } = this.props - const { title, date, body } = notice - const { disclaimerDisabled } = this.state - - return ( - h('.flex-column.flex-center.flex-grow', { - style: { - width: '100%', - }, - }, [ - h('h3.flex-center.text-transform-uppercase.terms-header', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - width: '100%', - fontSize: '20px', - textAlign: 'center', - padding: 6, - }, - }, [ - title, - ]), - - h('h5.flex-center.text-transform-uppercase.terms-header', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginBottom: 24, - width: '100%', - fontSize: '20px', - textAlign: 'center', - padding: 6, - }, - }, [ - date, - ]), - - h('style', ` - - .markdown { - overflow-x: hidden; - } - - .markdown h1, .markdown h2, .markdown h3 { - margin: 10px 0; - font-weight: bold; - } - - .markdown strong { - font-weight: bold; - } - .markdown em { - font-style: italic; - } - - .markdown p { - margin: 10px 0; - } - - .markdown a { - color: #df6b0e; - } - - `), - - h('div.markdown', { - onScroll: (e) => { - var object = e.currentTarget - if (object.offsetHeight + object.scrollTop + 100 >= object.scrollHeight) { - this.setState({ disclaimerDisabled: false }) - } - }, - style: { - background: 'rgb(235, 235, 235)', - height: '310px', - padding: '6px', - width: '90%', - overflowY: 'scroll', - scroll: 'auto', - }, - }, [ - h(ReactMarkdown, { - className: 'notice-box', - source: body, - skipHtml: true, - }), - ]), - - h('button.primary', { - disabled: disclaimerDisabled, - onClick: () => this.handleAccept(), - style: { - marginTop: '18px', - }, - }, 'Accept'), - ]) - ) - } - -} - -const mapStateToProps = state => { - const { metamask } = state - const { noActiveNotices, nextUnreadNotice, lostAccounts } = metamask - - return { - noActiveNotices, - nextUnreadNotice, - lostAccounts, - } -} - -Notice.propTypes = { - notice: PropTypes.object, - onConfirm: PropTypes.func, - history: PropTypes.object, -} - -const mapDispatchToProps = dispatch => { - return { - markNoticeRead: nextUnreadNotice => dispatch(actions.markNoticeRead(nextUnreadNotice)), - markAccountsFound: () => dispatch(actions.markAccountsFound()), - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { noActiveNotices, nextUnreadNotice, lostAccounts } = stateProps - const { markNoticeRead, markAccountsFound } = dispatchProps - - let notice - let onConfirm - - if (!noActiveNotices) { - notice = nextUnreadNotice - onConfirm = () => markNoticeRead(nextUnreadNotice) - } else if (lostAccounts && lostAccounts.length > 0) { - notice = generateLostAccountsNotice(lostAccounts) - onConfirm = () => markAccountsFound() - } - - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - notice, - onConfirm, - } -} - -module.exports = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Notice) diff --git a/ui/app/components/pages/provider-approval/provider-approval.component.js b/ui/app/components/pages/provider-approval/provider-approval.component.js deleted file mode 100644 index 11895327a..000000000 --- a/ui/app/components/pages/provider-approval/provider-approval.component.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types' -import React, { Component } from 'react' -import ProviderPageContainer from '../../provider-page-container' - -export default class ProviderApproval extends Component { - static propTypes = { - approveProviderRequest: PropTypes.func.isRequired, - providerRequest: PropTypes.object.isRequired, - rejectProviderRequest: PropTypes.func.isRequired, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - render () { - const { approveProviderRequest, providerRequest, rejectProviderRequest } = this.props - return ( - <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/components/pages/provider-approval/provider-approval.container.js deleted file mode 100644 index 28e4531a9..000000000 --- a/ui/app/components/pages/provider-approval/provider-approval.container.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux' -import ProviderApproval from './provider-approval.component' -import { approveProviderRequest, rejectProviderRequest } from '../../../actions' - -function mapDispatchToProps (dispatch) { - return { - approveProviderRequest: tabID => dispatch(approveProviderRequest(tabID)), - rejectProviderRequest: tabID => dispatch(rejectProviderRequest(tabID)), - } -} - -export default connect(null, mapDispatchToProps)(ProviderApproval) 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-tab/settings-tab.component.js b/ui/app/components/pages/settings/settings-tab/settings-tab.component.js deleted file mode 100644 index ba2e81754..000000000 --- a/ui/app/components/pages/settings/settings-tab/settings-tab.component.js +++ /dev/null @@ -1,674 +0,0 @@ -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 ToggleButton from 'react-toggle-button' -import { REVEAL_SEED_ROUTE, MOBILE_SYNC_ROUTE } from '../../../../routes' -import locales from '../../../../../../app/_locales/index.json' -import TextField from '../../../text-field' -import Button from '../../../button' - -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 = { - 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, - 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, - participateInMetaMetrics: PropTypes.bool, - setParticipateInMetaMetrics: PropTypes.func, - } - - state = { - newRpc: '', - chainId: '', - showOptions: false, - ticker: '', - 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('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> - ) - } - - renderNewRpcUrl () { - const { t } = this.context - const { newRpc, chainId, ticker, nickname } = this.state - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('newNetwork') }</span> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <TextField - type="text" - id="new-rpc" - placeholder={t('rpcURL')} - value={newRpc} - onChange={e => this.setState({ newRpc: e.target.value })} - onKeyPress={e => { - if (e.key === 'Enter') { - this.validateRpc(newRpc, chainId, ticker, nickname) - } - }} - fullWidth - margin="dense" - /> - <TextField - type="text" - id="chainid" - placeholder={t('optionalChainId')} - value={chainId} - onChange={e => this.setState({ chainId: e.target.value })} - onKeyPress={e => { - if (e.key === 'Enter') { - this.validateRpc(newRpc, chainId, ticker, nickname) - } - }} - style={{ - display: this.state.showOptions ? null : 'none', - }} - fullWidth - margin="dense" - /> - <TextField - type="text" - id="ticker" - placeholder={t('optionalSymbol')} - value={ticker} - onChange={e => this.setState({ ticker: e.target.value })} - onKeyPress={e => { - if (e.key === 'Enter') { - this.validateRpc(newRpc, chainId, ticker, nickname) - } - }} - style={{ - display: this.state.showOptions ? null : 'none', - }} - fullWidth - margin="dense" - /> - <TextField - type="text" - id="nickname" - placeholder={t('optionalNickname')} - value={nickname} - onChange={e => this.setState({ nickname: e.target.value })} - onKeyPress={e => { - if (e.key === 'Enter') { - this.validateRpc(newRpc, chainId, ticker, nickname) - } - }} - style={{ - display: this.state.showOptions ? null : 'none', - }} - fullWidth - margin="dense" - /> - <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 }) - }} - > - { t(this.state.showOptions ? 'hideAdvancedOptions' : 'showAdvancedOptions') } - </span> - <button - className="button btn-primary settings-tab__rpc-save-button" - onClick={e => { - e.preventDefault() - this.validateRpc(newRpc, chainId, ticker, nickname) - }} - > - { t('save') } - </button> - </div> - </div> - </div> - </div> - ) - } - - 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)) { - displayWarning(this.context.t('uriErrorMsg')) - } else { - displayWarning(this.context.t('invalidRPC')) - } - } - } - - 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> - ) - } - - - renderMobileSync () { - const { t } = this.context - const { history } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('syncWithMobile') }</span> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <Button - type="primary" - large - onClick={event => { - event.preventDefault() - history.push(MOBILE_SYNC_ROUTE) - }} - > - { t('syncWithMobile') } - </Button> - </div> - </div> - </div> - ) - } - - - renderResetAccount () { - const { t } = this.context - const { showResetAccountConfirmationModal } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('resetAccount') }</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() - this.context.metricsEvent({ - eventOpts: { - category: 'Settings', - action: 'Reset Account', - name: 'Reset Account', - }, - }) - showResetAccountConfirmationModal() - }} - > - { t('resetAccount') } - </Button> - </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> - ) - } - - renderHexDataOptIn () { - const { t } = this.context - const { sendHexData, setHexDataFeatureFlag } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('showHexData') }</span> - <div className="settings-page__content-description"> - { t('showHexDataDescription') } - </div> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <ToggleButton - value={sendHexData} - onToggle={value => setHexDataFeatureFlag(!value)} - activeLabel="" - inactiveLabel="" - /> - </div> - </div> - </div> - ) - } - - renderAdvancedGasInputInline () { - const { t } = this.context - const { advancedInlineGas, setAdvancedInlineGasFeatureFlag } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('showAdvancedGasInline') }</span> - <div className="settings-page__content-description"> - { t('showAdvancedGasInlineDescription') } - </div> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <ToggleButton - value={advancedInlineGas} - onToggle={value => setAdvancedInlineGasFeatureFlag(!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> - ) - } - - renderShowConversionInTestnets () { - const { t } = this.context - const { - showFiatInTestnets, - setShowFiatConversionOnTestnetsPreference, - } = this.props - - return ( - <div className="settings-page__content-row"> - <div className="settings-page__content-item"> - <span>{ t('showFiatConversionInTestnets') }</span> - <div className="settings-page__content-description"> - { t('showFiatConversionInTestnetsDescription') } - </div> - </div> - <div className="settings-page__content-item"> - <div className="settings-page__content-item-col"> - <ToggleButton - value={showFiatInTestnets} - onToggle={value => setShowFiatConversionOnTestnetsPreference(!value)} - activeLabel="" - inactiveLabel="" - /> - </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> - ) - } - - render () { - const { warning } = this.props - - return ( - <div className="settings-page__content"> - { warning && <div className="settings-tab__error">{ warning }</div> } - { this.renderCurrentConversion() } - { this.renderUsePrimaryCurrencyOptions() } - { this.renderShowConversionInTestnets() } - { this.renderCurrentLocale() } - { this.renderNewRpcUrl() } - { this.renderStateLogs() } - { this.renderSeedWords() } - { this.renderResetAccount() } - { this.renderClearApproval() } - { this.renderPrivacyOptIn() } - { this.renderHexDataOptIn() } - { this.renderAdvancedGasInputInline() } - { this.renderBlockieOptIn() } - { this.renderMobileSync() } - { this.renderMetaMetricsOptIn() } - </div> - ) - } -} diff --git a/ui/app/components/pages/settings/settings-tab/settings-tab.container.js b/ui/app/components/pages/settings/settings-tab/settings-tab.container.js deleted file mode 100644 index e266c8c9a..000000000 --- a/ui/app/components/pages/settings/settings-tab/settings-tab.container.js +++ /dev/null @@ -1,81 +0,0 @@ -import SettingsTab from './settings-tab.component' -import { compose } from 'recompose' -import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom' -import { - setCurrentCurrency, - updateAndSetCustomRpc, - displayWarning, - revealSeedConfirmation, - setUseBlockie, - updateCurrentLocale, - setFeatureFlag, - showModal, - setUseNativeCurrencyAsPrimaryCurrencyPreference, - setShowFiatConversionOnTestnetsPreference, - setParticipateInMetaMetrics, -} from '../../../../actions' -import { preferencesSelector } from '../../../../selectors' - -const mapStateToProps = state => { - const { appState: { warning }, metamask } = state - const { - currentCurrency, - conversionDate, - nativeCurrency, - useBlockie, - featureFlags: { - sendHexData, - privacyMode, - advancedInlineGas, - } = {}, - provider = {}, - currentLocale, - participateInMetaMetrics, - } = metamask - const { useNativeCurrencyAsPrimaryCurrency, showFiatInTestnets } = preferencesSelector(state) - - return { - warning, - currentLocale, - currentCurrency, - conversionDate, - nativeCurrency, - useBlockie, - sendHexData, - advancedInlineGas, - privacyMode, - provider, - useNativeCurrencyAsPrimaryCurrency, - showFiatInTestnets, - participateInMetaMetrics, - } -} - -const mapDispatchToProps = dispatch => { - return { - setCurrentCurrency: currency => dispatch(setCurrentCurrency(currency)), - setRpcTarget: (newRpc, chainId, ticker, nickname) => dispatch(updateAndSetCustomRpc(newRpc, chainId, ticker, nickname)), - displayWarning: warning => dispatch(displayWarning(warning)), - revealSeedConfirmation: () => dispatch(revealSeedConfirmation()), - setUseBlockie: value => dispatch(setUseBlockie(value)), - updateCurrentLocale: key => dispatch(updateCurrentLocale(key)), - setHexDataFeatureFlag: shouldShow => dispatch(setFeatureFlag('sendHexData', shouldShow)), - setAdvancedInlineGasFeatureFlag: shouldShow => dispatch(setFeatureFlag('advancedInlineGas', shouldShow)), - setPrivacyMode: enabled => dispatch(setFeatureFlag('privacyMode', enabled)), - showResetAccountConfirmationModal: () => dispatch(showModal({ name: 'CONFIRM_RESET_ACCOUNT' })), - setUseNativeCurrencyAsPrimaryCurrencyPreference: value => { - return dispatch(setUseNativeCurrencyAsPrimaryCurrencyPreference(value)) - }, - setShowFiatConversionOnTestnetsPreference: value => { - return dispatch(setShowFiatConversionOnTestnetsPreference(value)) - }, - showClearApprovalModal: () => dispatch(showModal({ name: 'CLEAR_APPROVED_ORIGINS' })), - setParticipateInMetaMetrics: (val) => dispatch(setParticipateInMetaMetrics(val)), - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(SettingsTab) 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/pages/unlock-page/unlock-page.component.js b/ui/app/components/pages/unlock-page/unlock-page.component.js deleted file mode 100644 index cc86d5872..000000000 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ /dev/null @@ -1,191 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import Button from '@material-ui/core/Button' -import TextField from '../../text-field' -import getCaretCoordinates from 'textarea-caret' -import { EventEmitter } from 'events' -import Mascot from '../../mascot' -import { DEFAULT_ROUTE } from '../../../routes' - -export default class UnlockPage extends Component { - static contextTypes = { - metricsEvent: PropTypes.func, - t: PropTypes.func, - } - - static propTypes = { - history: PropTypes.object, - isUnlocked: PropTypes.bool, - onImport: PropTypes.func, - onRestore: PropTypes.func, - onSubmit: PropTypes.func, - forceUpdateMetamaskState: PropTypes.func, - showOptInModal: PropTypes.func, - } - - constructor (props) { - super(props) - - this.state = { - password: '', - error: null, - } - - this.submitting = false - this.animationEventEmitter = new EventEmitter() - } - - componentWillMount () { - const { isUnlocked, history } = this.props - - if (isUnlocked) { - history.push(DEFAULT_ROUTE) - } - } - - handleSubmit = async event => { - event.preventDefault() - event.stopPropagation() - - const { password } = this.state - const { onSubmit, forceUpdateMetamaskState, showOptInModal } = this.props - - if (password === '' || this.submitting) { - return - } - - this.setState({ error: null }) - this.submitting = true - - try { - 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 - } - } - - handleInputChange ({ target }) { - this.setState({ password: target.value, error: null }) - - // tell mascot to look at page action - const element = target - const boundingRect = element.getBoundingClientRect() - const coordinates = getCaretCoordinates(element, element.selectionEnd) - this.animationEventEmitter.emit('point', { - x: boundingRect.left + coordinates.left - element.scrollLeft, - y: boundingRect.top + coordinates.top - element.scrollTop, - }) - } - - renderSubmitButton () { - const style = { - backgroundColor: '#f7861c', - color: 'white', - marginTop: '20px', - height: '60px', - fontWeight: '400', - boxShadow: 'none', - borderRadius: '4px', - } - - return ( - <Button - type="submit" - style={style} - disabled={!this.state.password} - fullWidth - variant="raised" - size="large" - onClick={this.handleSubmit} - disableRipple - > - { this.context.t('login') } - </Button> - ) - } - - render () { - const { password, error } = this.state - const { t } = this.context - const { onImport, onRestore } = this.props - - return ( - <div className="unlock-page__container"> - <div className="unlock-page"> - <div className="unlock-page__mascot-container"> - <Mascot - animationEventEmitter={this.animationEventEmitter} - width="120" - height="120" - /> - </div> - <h1 className="unlock-page__title"> - { t('welcomeBack') } - </h1> - <div>{ t('unlockMessage') }</div> - <form - className="unlock-page__form" - onSubmit={this.handleSubmit} - > - <TextField - id="password" - label={t('password')} - type="password" - value={password} - onChange={event => this.handleInputChange(event)} - error={error} - autoFocus - autoComplete="current-password" - material - fullWidth - /> - </form> - { this.renderSubmitButton() } - <div className="unlock-page__links"> - <div - className="unlock-page__link" - onClick={() => onRestore()} - > - { t('restoreFromSeed') } - </div> - <div - className="unlock-page__link unlock-page__link--import" - onClick={() => onImport()} - > - { t('importUsingSeed') } - </div> - </div> - </div> - </div> - ) - } -} diff --git a/ui/app/components/pages/unlock-page/unlock-page.container.js b/ui/app/components/pages/unlock-page/unlock-page.container.js deleted file mode 100644 index fe51c8095..000000000 --- a/ui/app/components/pages/unlock-page/unlock-page.container.js +++ /dev/null @@ -1,64 +0,0 @@ -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 '../../../routes' -import { - tryUnlockMetamask, - forgotPassword, - markPasswordForgotten, - forceUpdateMetamaskState, - showModal, -} from '../../../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() - } - } - - 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/components/provider-page-container/provider-page-container-content/provider-page-container-content.component.js b/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.component.js deleted file mode 100644 index 268db613f..000000000 --- a/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.component.js +++ /dev/null @@ -1,77 +0,0 @@ -import PropTypes from 'prop-types' -import React, {PureComponent} from 'react' -import Identicon from '../../identicon' - -export default class ProviderPageContainerContent extends PureComponent { - static propTypes = { - origin: PropTypes.string.isRequired, - selectedIdentity: PropTypes.string.isRequired, - siteImage: PropTypes.string, - siteTitle: PropTypes.string.isRequired, - } - - static contextTypes = { - t: PropTypes.func, - }; - - renderConnectVisual = () => { - const { origin, selectedIdentity, siteImage, siteTitle } = this.props - - return ( - <div className="provider-approval-visual"> - <section> - {siteImage ? ( - <img - className="provider-approval-visual__identicon" - src={siteImage} - /> - ) : ( - <i className="provider-approval-visual__identicon--default"> - {siteTitle.charAt(0).toUpperCase()} - </i> - )} - <h1>{siteTitle}</h1> - <h2>{origin}</h2> - </section> - <span className="provider-approval-visual__check" /> - <section> - <Identicon - className="provider-approval-visual__identicon" - address={selectedIdentity.address} - diameter={64} - /> - <h1>{selectedIdentity.name}</h1> - </section> - </div> - ) - } - - render () { - const { siteTitle } = this.props - const { t } = this.context - - return ( - <div className="provider-approval-container__content"> - <section> - <h2>{t('connectRequest')}</h2> - {this.renderConnectVisual()} - <h1>{t('providerRequest', [siteTitle])}</h1> - <p> - {t('providerRequestInfo')} - <br/> - <a - href="https://medium.com/metamask/introducing-privacy-mode-42549d4870fa" - target="_blank" - rel="noopener noreferrer" - > - {t('learnMore')}. - </a> - </p> - </section> - <section className="secure-badge"> - <img src="/images/mm-secure.svg" /> - </section> - </div> - ) - } -} diff --git a/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.container.js b/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.container.js deleted file mode 100644 index 3ea1ce20e..000000000 --- a/ui/app/components/provider-page-container/provider-page-container-content/provider-page-container-content.container.js +++ /dev/null @@ -1,11 +0,0 @@ -import { connect } from 'react-redux' -import ProviderPageContainerContent from './provider-page-container-content.component' -import { getSelectedIdentity } from '../../../selectors' - -const mapStateToProps = (state) => { - return { - selectedIdentity: getSelectedIdentity(state), - } -} - -export default connect(mapStateToProps)(ProviderPageContainerContent) diff --git a/ui/app/components/provider-page-container/provider-page-container.component.js b/ui/app/components/provider-page-container/provider-page-container.component.js deleted file mode 100644 index ff063166d..000000000 --- a/ui/app/components/provider-page-container/provider-page-container.component.js +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types' -import React, {PureComponent} from 'react' -import { ProviderPageContainerContent, ProviderPageContainerHeader } from './' -import { PageContainerFooter } from '../page-container' - -export default class ProviderPageContainer extends PureComponent { - static propTypes = { - approveProviderRequest: PropTypes.func.isRequired, - origin: PropTypes.string.isRequired, - 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 { tabID, rejectProviderRequest } = this.props - this.context.metricsEvent({ - eventOpts: { - category: 'Auth', - action: 'Connect', - name: 'Canceled', - }, - }) - rejectProviderRequest(tabID) - } - - onSubmit = () => { - const { approveProviderRequest, tabID } = this.props - this.context.metricsEvent({ - eventOpts: { - category: 'Auth', - action: 'Connect', - name: 'Confirmed', - }, - }) - approveProviderRequest(tabID) - } - - render () { - const {origin, siteImage, siteTitle} = this.props - - return ( - <div className="page-container provider-approval-container"> - <ProviderPageContainerHeader /> - <ProviderPageContainerContent - origin={origin} - siteImage={siteImage} - siteTitle={siteTitle} - /> - <PageContainerFooter - onCancel={() => this.onCancel()} - cancelText={this.context.t('cancel')} - onSubmit={() => this.onSubmit()} - submitText={this.context.t('connect')} - submitButtonType="confirm" - /> - </div> - ) - } -} diff --git a/ui/app/components/qr-code.js b/ui/app/components/qr-code.js deleted file mode 100644 index 312815891..000000000 --- a/ui/app/components/qr-code.js +++ /dev/null @@ -1,63 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -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') - -module.exports = connect(mapStateToProps)(QrCodeView) - -function mapStateToProps (state) { - return { - // Qr code is not fetched from state. 'message' and 'data' props are passed instead. - buyView: state.appState.buyView, - warning: state.appState.warning, - } -} - -inherits(QrCodeView, Component) - -function QrCodeView () { - Component.call(this) -} - -QrCodeView.prototype.render = function () { - const props = this.props - const { message, data, network } = props.Qr - const address = `${isHexPrefixed(data) ? 'ethereum:' : ''}${checksumAddress(data, network)}` - const qrImage = qrCode(4, 'M') - qrImage.addData(address) - qrImage.make() - - return h('.div.flex-column.flex-center', [ - Array.isArray(message) - ? h('.message-container', this.renderMultiMessage()) - : message && h('.qr-header', message), - - this.props.warning ? this.props.warning && h('span.error.flex-center', { - style: { - }, - }, - this.props.warning) : null, - - h('.div.qr-wrapper', { - style: {}, - dangerouslySetInnerHTML: { - __html: qrImage.createTableTag(4), - }, - }), - h(ReadOnlyInput, { - wrapperClass: 'ellip-address-wrapper', - inputClass: 'qr-ellip-address', - value: checksumAddress(data, network), - }), - ]) -} - -QrCodeView.prototype.renderMultiMessage = function () { - var Qr = this.props.Qr - var multiMessage = Qr.message.map((message) => h('.qr-message', message)) - return multiMessage -} diff --git a/ui/app/components/selected-account/selected-account.component.js b/ui/app/components/selected-account/selected-account.component.js deleted file mode 100644 index 47c56e312..000000000 --- a/ui/app/components/selected-account/selected-account.component.js +++ /dev/null @@ -1,55 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import copyToClipboard from 'copy-to-clipboard' -import { addressSlicer, checksumAddress } from '../../util' - -const Tooltip = require('../tooltip-v2.js').default - -class SelectedAccount extends Component { - state = { - copied: false, - } - - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - selectedAddress: PropTypes.string, - selectedIdentity: PropTypes.object, - network: PropTypes.string, - } - - render () { - const { t } = this.context - const { selectedAddress, selectedIdentity, network } = this.props - const checksummedAddress = checksumAddress(selectedAddress, network) - - return ( - <div className="selected-account"> - <Tooltip - position="bottom" - title={this.state.copied ? t('copiedExclamation') : t('copyToClipboard')} - > - <div - className="selected-account__clickable" - onClick={() => { - this.setState({ copied: true }) - setTimeout(() => this.setState({ copied: false }), 3000) - copyToClipboard(checksummedAddress) - }} - > - <div className="selected-account__name"> - { selectedIdentity.name } - </div> - <div className="selected-account__address"> - { addressSlicer(checksummedAddress) } - </div> - </div> - </Tooltip> - </div> - ) - } -} - -export default SelectedAccount diff --git a/ui/app/components/selected-account/selected-account.container.js b/ui/app/components/selected-account/selected-account.container.js deleted file mode 100644 index 1c8d17d8d..000000000 --- a/ui/app/components/selected-account/selected-account.container.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux' -import SelectedAccount from './selected-account.component' - -const selectors = require('../../selectors') - -const mapStateToProps = state => { - return { - selectedAddress: selectors.getSelectedAddress(state), - selectedIdentity: selectors.getSelectedIdentity(state), - network: state.metamask.network, - } -} - -export default connect(mapStateToProps)(SelectedAccount) 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 0420af46b..000000000 --- a/ui/app/components/send/account-list-item/account-list-item.component.js +++ /dev/null @@ -1,108 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { checksumAddress } from '../../../util' -import Identicon from '../../identicon' -import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../constants/common' -import Tooltip from '../../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/send/account-list-item/account-list-item.container.js deleted file mode 100644 index c045ef14f..000000000 --- a/ui/app/components/send/account-list-item/account-list-item.container.js +++ /dev/null @@ -1,27 +0,0 @@ -import { connect } from 'react-redux' -import { - getConversionRate, - getCurrentCurrency, - getNativeCurrency, -} from '../send.selectors.js' -import { - getIsMainnet, - isBalanceCached, - preferencesSelector, -} from '../../../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/tests/account-list-item-component.test.js b/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js deleted file mode 100644 index 2bd2ce0c5..000000000 --- a/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js +++ /dev/null @@ -1,148 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import sinon from 'sinon' -import proxyquire from 'proxyquire' -import Identicon from '../../../identicon' -import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display' - -const utilsMethodStubs = { - checksumAddress: sinon.stub().returns('mockCheckSumAddress'), -} - -const AccountListItem = proxyquire('../account-list-item.component.js', { - '../../../util': utilsMethodStubs, -}).default - - -const propsMethodSpies = { - handleClick: sinon.spy(), -} - -describe('AccountListItem Component', function () { - let wrapper - - beforeEach(() => { - wrapper = shallow(<AccountListItem - account={ { address: 'mockAddress', name: 'mockName', balance: 'mockBalance' } } - className={'mockClassName'} - conversionRate={4} - currentCurrency={'mockCurrentyCurrency'} - nativeCurrency={'ETH'} - displayAddress={false} - displayBalance={false} - handleClick={propsMethodSpies.handleClick} - icon={<i className="mockIcon" />} - />, { context: { t: str => str + '_t' } }) - }) - - afterEach(() => { - propsMethodSpies.handleClick.resetHistory() - }) - - describe('render', () => { - it('should render a div with the passed className', () => { - assert.equal(wrapper.find('.mockClassName').length, 1) - assert(wrapper.find('.mockClassName').is('div')) - assert(wrapper.find('.mockClassName').hasClass('account-list-item')) - }) - - it('should call handleClick with the expected props when the root div is clicked', () => { - const { onClick } = wrapper.find('.mockClassName').props() - assert.equal(propsMethodSpies.handleClick.callCount, 0) - onClick() - assert.equal(propsMethodSpies.handleClick.callCount, 1) - assert.deepEqual( - propsMethodSpies.handleClick.getCall(0).args, - [{ address: 'mockAddress', name: 'mockName', balance: 'mockBalance' }] - ) - }) - - it('should have a top row div', () => { - assert.equal(wrapper.find('.mockClassName > .account-list-item__top-row').length, 1) - assert(wrapper.find('.mockClassName > .account-list-item__top-row').is('div')) - }) - - it('should have an identicon, name and icon in the top row', () => { - const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') - assert.equal(topRow.find(Identicon).length, 1) - assert.equal(topRow.find('.account-list-item__account-name').length, 1) - assert.equal(topRow.find('.account-list-item__icon').length, 1) - }) - - it('should show the account name if it exists', () => { - const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') - assert.equal(topRow.find('.account-list-item__account-name').text(), 'mockName') - }) - - it('should show the account address if there is no name', () => { - wrapper.setProps({ account: { address: 'addressButNoName' } }) - const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') - assert.equal(topRow.find('.account-list-item__account-name').text(), 'addressButNoName') - }) - - it('should render the passed icon', () => { - const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') - assert(topRow.find('.account-list-item__icon').childAt(0).is('i')) - assert(topRow.find('.account-list-item__icon').childAt(0).hasClass('mockIcon')) - }) - - it('should not render an icon if none is passed', () => { - wrapper.setProps({ icon: null }) - const topRow = wrapper.find('.mockClassName > .account-list-item__top-row') - assert.equal(topRow.find('.account-list-item__icon').length, 0) - }) - - it('should render the account address as a checksumAddress if displayAddress is true and name is provided', () => { - wrapper.setProps({ displayAddress: true }) - assert.equal(wrapper.find('.account-list-item__account-address').length, 1) - assert.equal(wrapper.find('.account-list-item__account-address').text(), 'mockCheckSumAddress') - assert.deepEqual( - utilsMethodStubs.checksumAddress.getCall(0).args, - ['mockAddress'] - ) - }) - - it('should not render the account address as a checksumAddress if displayAddress is false', () => { - wrapper.setProps({ displayAddress: false }) - assert.equal(wrapper.find('.account-list-item__account-address').length, 0) - }) - - it('should not render the account address as a checksumAddress if name is not provided', () => { - wrapper.setProps({ account: { address: 'someAddressButNoName' } }) - assert.equal(wrapper.find('.account-list-item__account-address').length, 0) - }) - - it('should render a CurrencyDisplay with the correct props if displayBalance is true', () => { - wrapper.setProps({ displayBalance: true }) - assert.equal(wrapper.find(UserPreferencedCurrencyDisplay).length, 2) - assert.deepEqual( - wrapper.find(UserPreferencedCurrencyDisplay).at(0).props(), - { - 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, - } - ) - }) - - it('should not render a CurrencyDisplay if displayBalance is false', () => { - wrapper.setProps({ displayBalance: false }) - assert.equal(wrapper.find(UserPreferencedCurrencyDisplay).length, 0) - }) - - }) -}) 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 662880aa0..000000000 --- a/ui/app/components/send/account-list-item/tests/account-list-item-container.test.js +++ /dev/null @@ -1,73 +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: () => `mockConversionRate`, - getCurrentCurrency: () => `mockCurrentCurrency`, - getNativeCurrency: () => `mockNativeCurrency`, - }, - '../../../selectors.js': { - 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/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js deleted file mode 100644 index 2d2ec42f7..000000000 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ /dev/null @@ -1,40 +0,0 @@ -import { connect } from 'react-redux' -import { - getGasTotal, - getSelectedToken, - getSendFromBalance, - getTokenBalance, -} from '../../../send.selectors.js' -import { getMaxModeOn } from './amount-max-button.selectors.js' -import { calcMaxAmount } from './amount-max-button.utils.js' -import { - updateSendAmount, - setMaxModeTo, -} from '../../../../../actions' -import AmountMaxButton from './amount-max-button.component' -import { - updateSendErrors, -} from '../../../../../ducks/send.duck' - -export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton) - -function mapStateToProps (state) { - - return { - balance: getSendFromBalance(state), - gasTotal: getGasTotal(state), - maxModeOn: getMaxModeOn(state), - selectedToken: getSelectedToken(state), - tokenBalance: getTokenBalance(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - setAmountToMax: maxAmountDataObject => { - dispatch(updateSendErrors({ amount: null })) - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))) - }, - setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), - } -} 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 27181d2f5..000000000 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js +++ /dev/null @@ -1,29 +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', - 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/tests/amount-max-button-container.test.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js deleted file mode 100644 index 2cc00d6d6..000000000 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - setMaxModeTo: sinon.spy(), - updateSendAmount: sinon.spy(), -} -const duckActionSpies = { - updateSendErrors: sinon.spy(), -} - -proxyquire('../amount-max-button.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - '../../../send.selectors.js': { - getGasTotal: (s) => `mockGasTotal:${s}`, - getSelectedToken: (s) => `mockSelectedToken:${s}`, - getSendFromBalance: (s) => `mockBalance:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - }, - './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, -}) - -describe('amount-max-button container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - balance: 'mockBalance:mockState', - gasTotal: 'mockGasTotal:mockState', - maxModeOn: 'mockMaxModeOn:mockState', - selectedToken: 'mockSelectedToken:mockState', - tokenBalance: 'mockTokenBalance:mockState', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - }) - - describe('setAmountToMax()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' }) - assert(dispatchSpy.calledTwice) - assert(duckActionSpies.updateSendErrors.calledOnce) - assert.deepEqual( - duckActionSpies.updateSendErrors.getCall(0).args[0], - { amount: null } - ) - assert(actionSpies.updateSendAmount.calledOnce) - assert.equal( - actionSpies.updateSendAmount.getCall(0).args[0], - 12 - ) - }) - }) - - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockVal') - assert(dispatchSpy.calledOnce) - assert.equal( - actionSpies.setMaxModeTo.getCall(0).args[0], - 'mockVal' - ) - }) - }) - - }) - -}) diff --git a/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js b/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js deleted file mode 100644 index 4df1e0ffa..000000000 --- a/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js +++ /dev/null @@ -1,119 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -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' - -export default class SendAmountRow extends Component { - - static propTypes = { - amount: PropTypes.string, - amountConversionRate: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]), - balance: PropTypes.string, - conversionRate: PropTypes.number, - convertedCurrency: PropTypes.string, - gasTotal: PropTypes.string, - inError: PropTypes.bool, - primaryCurrency: PropTypes.string, - selectedToken: PropTypes.object, - setMaxModeTo: PropTypes.func, - tokenBalance: PropTypes.string, - updateGasFeeError: PropTypes.func, - updateSendAmount: PropTypes.func, - updateSendAmountError: PropTypes.func, - updateGas: PropTypes.func, - } - - static contextTypes = { - t: PropTypes.func, - } - - validateAmount (amount) { - const { - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - selectedToken, - tokenBalance, - updateGasFeeError, - updateSendAmountError, - } = this.props - - updateSendAmountError({ - amount, - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - selectedToken, - tokenBalance, - }) - - if (selectedToken) { - updateGasFeeError({ - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - selectedToken, - tokenBalance, - }) - } - } - - updateAmount (amount) { - const { updateSendAmount, setMaxModeTo } = this.props - - setMaxModeTo(false) - updateSendAmount(amount) - } - - updateGas (amount) { - const { selectedToken, updateGas } = this.props - - if (selectedToken) { - updateGas({ amount }) - } - } - - renderInput () { - const { amount, inError, selectedToken } = this.props - const Component = selectedToken ? UserPreferencedTokenInput : UserPreferencedCurrencyInput - - return ( - <Component - onChange={newAmount => this.validateAmount(newAmount)} - onBlur={newAmount => { - this.updateGas(newAmount) - this.updateAmount(newAmount) - }} - error={inError} - value={amount} - /> - ) - } - - render () { - const { gasTotal, inError } = this.props - - return ( - <SendRowWrapper - label={`${this.context.t('amount')}:`} - showError={inError} - errorType={'amount'} - > - {!inError && gasTotal && <AmountMaxButton />} - { this.renderInput() } - </SendRowWrapper> - ) - } - -} diff --git a/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js b/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js deleted file mode 100644 index 2b6fe0f6c..000000000 --- a/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js +++ /dev/null @@ -1,54 +0,0 @@ -import { connect } from 'react-redux' -import { - getAmountConversionRate, - getConversionRate, - getCurrentCurrency, - getGasTotal, - getPrimaryCurrency, - getSelectedToken, - getSendAmount, - getSendFromBalance, - getTokenBalance, -} from '../../send.selectors' -import { - sendAmountIsInError, -} from './send-amount-row.selectors' -import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils' -import { - setMaxModeTo, - updateSendAmount, -} from '../../../../actions' -import { - updateSendErrors, -} from '../../../../ducks/send.duck' -import SendAmountRow from './send-amount-row.component' - -export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow) - -function mapStateToProps (state) { - return { - amount: getSendAmount(state), - amountConversionRate: getAmountConversionRate(state), - balance: getSendFromBalance(state), - conversionRate: getConversionRate(state), - convertedCurrency: getCurrentCurrency(state), - gasTotal: getGasTotal(state), - inError: sendAmountIsInError(state), - primaryCurrency: getPrimaryCurrency(state), - selectedToken: getSelectedToken(state), - tokenBalance: getTokenBalance(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), - updateSendAmount: newAmount => dispatch(updateSendAmount(newAmount)), - updateGasFeeError: (amountDataObject) => { - dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))) - }, - updateSendAmountError: (amountDataObject) => { - dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))) - }, - } -} diff --git a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js b/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js deleted file mode 100644 index 52e351aee..000000000 --- a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js +++ /dev/null @@ -1,125 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - setMaxModeTo: sinon.spy(), - updateSendAmount: sinon.spy(), -} -const duckActionSpies = { - updateSendErrors: sinon.spy(), -} - -proxyquire('../send-amount-row.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - '../../send.selectors': { - getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`, - getConversionRate: (s) => `mockConversionRate:${s}`, - getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`, - getGasTotal: (s) => `mockGasTotal:${s}`, - getPrimaryCurrency: (s) => `mockPrimaryCurrency:${s}`, - getSelectedToken: (s) => `mockSelectedToken:${s}`, - getSendAmount: (s) => `mockAmount:${s}`, - getSendFromBalance: (s) => `mockBalance:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - }, - './send-amount-row.selectors': { sendAmountIsInError: (s) => `mockInError:${s}` }, - '../../send.utils': { - getAmountErrorObject: (mockDataObject) => ({ ...mockDataObject, mockChange: true }), - getGasFeeErrorObject: (mockDataObject) => ({ ...mockDataObject, mockGasFeeErrorChange: true }), - }, - '../../../../actions': actionSpies, - '../../../../ducks/send.duck': duckActionSpies, -}) - -describe('send-amount-row container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - amount: 'mockAmount:mockState', - amountConversionRate: 'mockAmountConversionRate:mockState', - balance: 'mockBalance:mockState', - conversionRate: 'mockConversionRate:mockState', - convertedCurrency: 'mockConvertedCurrency:mockState', - gasTotal: 'mockGasTotal:mockState', - inError: 'mockInError:mockState', - primaryCurrency: 'mockPrimaryCurrency:mockState', - selectedToken: 'mockSelectedToken:mockState', - tokenBalance: 'mockTokenBalance:mockState', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - duckActionSpies.updateSendErrors.resetHistory() - }) - - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockBool') - assert(dispatchSpy.calledOnce) - assert(actionSpies.setMaxModeTo.calledOnce) - assert.equal( - actionSpies.setMaxModeTo.getCall(0).args[0], - 'mockBool' - ) - }) - }) - - describe('updateSendAmount()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendAmount('mockAmount') - assert(dispatchSpy.calledOnce) - assert(actionSpies.updateSendAmount.calledOnce) - assert.equal( - actionSpies.updateSendAmount.getCall(0).args[0], - 'mockAmount' - ) - }) - }) - - describe('updateGasFeeError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateGasFeeError({ some: 'data' }) - assert(dispatchSpy.calledOnce) - assert(duckActionSpies.updateSendErrors.calledOnce) - assert.deepEqual( - duckActionSpies.updateSendErrors.getCall(0).args[0], - { some: 'data', mockGasFeeErrorChange: true } - ) - }) - }) - - describe('updateSendAmountError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendAmountError({ some: 'data' }) - assert(dispatchSpy.calledOnce) - assert(duckActionSpies.updateSendErrors.calledOnce) - assert.deepEqual( - duckActionSpies.updateSendErrors.getCall(0).args[0], - { some: 'data', mockChange: true } - ) - }) - }) - - }) - -}) diff --git a/ui/app/components/send/send-content/send-content.component.js b/ui/app/components/send/send-content/send-content.component.js deleted file mode 100644 index c780c88f5..000000000 --- a/ui/app/components/send/send-content/send-content.component.js +++ /dev/null @@ -1,41 +0,0 @@ -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 SendHexDataRow from './send-hex-data-row' -import SendToRow from './send-to-row/' - -export default class SendContent extends Component { - - static propTypes = { - updateGas: PropTypes.func, - scanQrCode: PropTypes.func, - showHexData: PropTypes.bool, - } - - updateGas = (updateData) => this.props.updateGas(updateData) - - render () { - return ( - <PageContainerContent> - <div className="send-v2__form"> - <SendFromRow /> - <SendToRow - updateGas={this.updateGas} - scanQrCode={ _ => this.props.scanQrCode()} - /> - <SendAmountRow updateGas={this.updateGas} /> - <SendGasRow /> - {(this.props.showHexData && ( - <SendHexDataRow - updateGas={this.updateGas} - /> - ))} - </div> - </PageContainerContent> - ) - } - -} diff --git a/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js b/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js deleted file mode 100644 index bedac1259..000000000 --- a/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js +++ /dev/null @@ -1,52 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import AccountListItem from '../../account-list-item/' - -export default class SendDropdownList extends Component { - - static propTypes = { - accounts: PropTypes.array, - closeDropdown: PropTypes.func, - onSelect: PropTypes.func, - activeAddress: PropTypes.string, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - getListItemIcon (accountAddress, activeAddress) { - return accountAddress === activeAddress - ? <i className={`fa fa-check fa-lg`} style={ { color: '#02c9b1' } }/> - : null - } - - render () { - const { - accounts, - closeDropdown, - onSelect, - activeAddress, - } = this.props - - return (<div> - <div - className="send-v2__from-dropdown__close-area" - onClick={() => closeDropdown()} - /> - <div className="send-v2__from-dropdown__list"> - {accounts.map((account, index) => <AccountListItem - account={account} - className="account-list-item__dropdown" - handleClick={() => { - onSelect(account) - closeDropdown() - }} - icon={this.getListItemIcon(account.address, activeAddress)} - key={`send-dropdown-account-#${index}`} - />)} - </div> - </div>) - } - -} 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 f8aa084d8..000000000 --- a/ui/app/components/send/send-content/send-from-row/send-from-row.component.js +++ /dev/null @@ -1,27 +0,0 @@ -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/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js deleted file mode 100644 index b667aa037..000000000 --- a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js +++ /dev/null @@ -1,57 +0,0 @@ -import React, {Component} from 'react' -import PropTypes from 'prop-types' -import UserPreferencedCurrencyDisplay from '../../../../user-preferenced-currency-display' -import { PRIMARY, SECONDARY } from '../../../../../constants/common' - -export default class GasFeeDisplay extends Component { - - static propTypes = { - conversionRate: PropTypes.number, - primaryCurrency: PropTypes.string, - convertedCurrency: PropTypes.string, - gasLoadingError: PropTypes.bool, - gasTotal: PropTypes.string, - onReset: PropTypes.func, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - render () { - const { gasTotal, gasLoadingError, onReset } = this.props - - return ( - <div className="send-v2__gas-fee-display"> - {gasTotal - ? ( - <div className="currency-display"> - <UserPreferencedCurrencyDisplay - value={gasTotal} - type={PRIMARY} - /> - <UserPreferencedCurrencyDisplay - className="currency-display__converted-value" - value={gasTotal} - type={SECONDARY} - /> - </div> - ) - : gasLoadingError - ? <div className="currency-display.currency-display--message"> - {this.context.t('setGasPrice')} - </div> - : <div className="currency-display"> - {this.context.t('loading')} - </div> - } - <button - className="gas-fee-reset" - onClick={onReset} - > - { this.context.t('reset') } - </button> - </div> - ) - } -} 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 bf7446626..000000000 --- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js +++ /dev/null @@ -1,131 +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' -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/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 a187d61a2..000000000 --- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js +++ /dev/null @@ -1,118 +0,0 @@ -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.duck' -import { - resetCustomData, - setCustomGasPrice, - setCustomGasLimit, -} from '../../../../ducks/gas.duck' -import { getGasLoadingError, gasFeeIsInError, getGasButtonGroupShown } from './send-gas-row.selectors.js' -import { showModal, setGasPrice, setGasLimit, setGasTotal } from '../../../../actions' -import { getAdvancedInlineGasShown, getCurrentEthBalance, getSelectedToken } from '../../../../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/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 12e78657b..000000000 --- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js +++ /dev/null @@ -1,200 +0,0 @@ -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': { - 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}`, - }, - '../../../../actions': actionSpies, - '../../../../selectors/custom-gas': { - getBasicGasEstimateLoadingStatus: (s) => `mockBasicGasEstimateLoadingStatus:${s}`, - getRenderableEstimateDataForSmallButtonsFromGWEI: (s) => `mockGasButtonInfo:${s}`, - getDefaultActiveButtonIndex: (gasButtonInfo, gasPrice) => gasButtonInfo.length + gasPrice.length, - }, - '../../../../ducks/send.duck': sendDuckSpies, - '../../../../ducks/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-hex-data-row/send-hex-data-row.container.js b/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.container.js deleted file mode 100644 index df554ca5f..000000000 --- a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.container.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux' -import { - updateSendHexData, -} from '../../../../actions' -import SendHexDataRow from './send-hex-data-row.component' - -export default connect(mapStateToProps, mapDispatchToProps)(SendHexDataRow) - -function mapStateToProps (state) { - return { - data: state.metamask.send.data, - } -} - -function mapDispatchToProps (dispatch) { - return { - updateSendHexData (data) { - return dispatch(updateSendHexData(data)) - }, - } -} diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js deleted file mode 100644 index 0146ce645..000000000 --- a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js +++ /dev/null @@ -1,48 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import SendRowErrorMessage from './send-row-error-message/' -import SendRowWarningMessage from './send-row-warning-message/' - -export default class SendRowWrapper extends Component { - - static propTypes = { - children: PropTypes.node, - errorType: PropTypes.string, - label: PropTypes.string, - showError: PropTypes.bool, - showWarning: PropTypes.bool, - warningType: PropTypes.string, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - render () { - const { - children, - 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}/>} - {!showError && showWarning && <SendRowWarningMessage warningType={warningType} />} - {customLabelContent} - </div> - <div className="send-v2__form-field"> - {formField} - </div> - </div> - ) - } - -} diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js deleted file mode 100644 index 434204490..000000000 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js +++ /dev/null @@ -1,91 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import SendRowWrapper from '../send-row-wrapper/' -import EnsInput from '../../../ens-input' -import { getToErrorObject, getToWarningObject } from './send-to-row.utils.js' - -export default class SendToRow extends Component { - - static propTypes = { - 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, 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 }) - } - } - - render () { - const { - closeToDropdown, - inError, - inWarning, - network, - openToDropdown, - to, - toAccounts, - toDropdownOpen, - } = this.props - - return ( - <SendRowWrapper - errorType={'to'} - label={`${this.context.t('to')}: `} - showError={inError} - showWarning={inWarning} - warningType={'to'} - > - <EnsInput - 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, toWarning }) => this.handleToChange(toAddress, nickname, toError, toWarning, this.props.network)} - openDropdown={() => openToDropdown()} - placeholder={this.context.t('recipientAddress')} - to={to} - /> - </SendRowWrapper> - ) - } - -} diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.container.js b/ui/app/components/send/send-content/send-to-row/send-to-row.container.js deleted file mode 100644 index fc6742df0..000000000 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.container.js +++ /dev/null @@ -1,54 +0,0 @@ -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' -import { - updateSendErrors, - updateSendWarnings, - openToDropdown, - closeToDropdown, -} from '../../../../ducks/send.duck' -import SendToRow from './send-to-row.component' - -export default connect(mapStateToProps, mapDispatchToProps)(SendToRow) - -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), - } -} - -function mapDispatchToProps (dispatch) { - return { - closeToDropdown: () => dispatch(closeToDropdown()), - openToDropdown: () => dispatch(openToDropdown()), - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), - 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.utils.js b/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js deleted file mode 100644 index 2bd3ea45e..000000000 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js +++ /dev/null @@ -1,36 +0,0 @@ -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('../../../../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-container.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js deleted file mode 100644 index aa09f01a9..000000000 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js +++ /dev/null @@ -1,134 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - updateSendTo: sinon.spy(), -} -const duckActionSpies = { - closeToDropdown: sinon.spy(), - openToDropdown: sinon.spy(), - updateSendErrors: sinon.spy(), - updateSendWarnings: sinon.spy(), -} - -proxyquire('../send-to-row.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - '../../send.selectors.js': { - getCurrentNetwork: (s) => `mockNetwork:${s}`, - getSelectedToken: (s) => `mockSelectedToken:${s}`, - getSendHexData: (s) => s, - getSendTo: (s) => `mockTo:${s}`, - getSendToAccounts: (s) => `mockToAccounts:${s}`, - }, - './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, -}) - -describe('send-to-row container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - 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', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - }) - - describe('closeToDropdown()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.closeToDropdown() - assert(dispatchSpy.calledOnce) - assert(duckActionSpies.closeToDropdown.calledOnce) - assert.equal( - duckActionSpies.closeToDropdown.getCall(0).args[0], - undefined - ) - }) - }) - - describe('openToDropdown()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.openToDropdown() - assert(dispatchSpy.calledOnce) - assert(duckActionSpies.openToDropdown.calledOnce) - assert.equal( - duckActionSpies.openToDropdown.getCall(0).args[0], - undefined - ) - }) - }) - - describe('updateSendTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname') - assert(dispatchSpy.calledOnce) - assert(actionSpies.updateSendTo.calledOnce) - assert.deepEqual( - actionSpies.updateSendTo.getCall(0).args, - ['mockTo', 'mockNickname'] - ) - }) - }) - - describe('updateSendToError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendToError('mockToErrorObject') - assert(dispatchSpy.calledOnce) - assert(duckActionSpies.updateSendErrors.calledOnce) - assert.equal( - duckActionSpies.updateSendErrors.getCall(0).args[0], - 'mockToErrorObject' - ) - }) - }) - - 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-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 f6abb26e6..000000000 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js +++ /dev/null @@ -1,107 +0,0 @@ -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', { - '../../../../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/send/send-content/tests/send-content-component.test.js deleted file mode 100644 index c5a11c8bb..000000000 --- a/ui/app/components/send/send-content/tests/send-content-component.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' -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 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' -import SendToRow from '../send-to-row/send-to-row.container' -import SendHexDataRow from '../send-hex-data-row/send-hex-data-row.container' - -describe('SendContent Component', function () { - let wrapper - - beforeEach(() => { - wrapper = shallow(<SendContent showHexData={true} />) - }) - - describe('render', () => { - it('should render a PageContainerContent component', () => { - assert.equal(wrapper.find(PageContainerContent).length, 1) - }) - - it('should render a div with a .send-v2__form class as a child of PageContainerContent', () => { - const PageContainerContentChild = wrapper.find(PageContainerContent).children() - PageContainerContentChild.is('div') - PageContainerContentChild.is('.send-v2__form') - }) - - it('should render the correct row components as grandchildren of the PageContainerContent component', () => { - const PageContainerContentChild = wrapper.find(PageContainerContent).children() - assert(PageContainerContentChild.childAt(0).is(SendFromRow)) - assert(PageContainerContentChild.childAt(1).is(SendToRow)) - assert(PageContainerContentChild.childAt(2).is(SendAmountRow)) - assert(PageContainerContentChild.childAt(3).is(SendGasRow)) - assert(PageContainerContentChild.childAt(4).is(SendHexDataRow)) - }) - - it('should not render the SendHexDataRow if props.showHexData is false', () => { - wrapper.setProps({ showHexData: false }) - const PageContainerContentChild = wrapper.find(PageContainerContent).children() - assert(PageContainerContentChild.childAt(0).is(SendFromRow)) - assert(PageContainerContentChild.childAt(1).is(SendToRow)) - assert(PageContainerContentChild.childAt(2).is(SendAmountRow)) - assert(PageContainerContentChild.childAt(3).is(SendGasRow)) - assert.equal(PageContainerContentChild.childAt(4).exists(), false) - }) - }) -}) diff --git a/ui/app/components/send/send-footer/send-footer.component.js b/ui/app/components/send/send-footer/send-footer.component.js deleted file mode 100644 index d943b4b22..000000000 --- a/ui/app/components/send/send-footer/send-footer.component.js +++ /dev/null @@ -1,137 +0,0 @@ -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' - -export default class SendFooter extends Component { - - static propTypes = { - addToAddressBookIfNew: PropTypes.func, - amount: PropTypes.string, - data: PropTypes.string, - clearSend: PropTypes.func, - disabled: PropTypes.bool, - editingTransactionId: PropTypes.string, - errors: PropTypes.object, - from: PropTypes.object, - gasLimit: PropTypes.string, - gasPrice: PropTypes.string, - gasTotal: PropTypes.string, - history: PropTypes.object, - inError: PropTypes.bool, - selectedToken: PropTypes.object, - sign: PropTypes.func, - to: PropTypes.string, - toAccounts: PropTypes.array, - tokenBalance: PropTypes.string, - unapprovedTxs: PropTypes.object, - update: PropTypes.func, - sendErrors: PropTypes.object, - } - - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - }; - - onCancel () { - this.props.clearSend() - this.props.history.push(DEFAULT_ROUTE) - } - - onSubmit (event) { - event.preventDefault() - const { - addToAddressBookIfNew, - amount, - data, - editingTransactionId, - from: {address: from}, - gasLimit: gas, - gasPrice, - selectedToken, - sign, - to, - unapprovedTxs, - // updateTx, - update, - 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 - - // if (!noErrors) { - // return - // } - - // TODO: add nickname functionality - addToAddressBookIfNew(to, toAccounts) - const promise = editingTransactionId - ? update({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - selectedToken, - to, - unapprovedTxs, - }) - : sign({ data, selectedToken, to, amount, from, gas, gasPrice }) - - Promise.resolve(promise) - .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 - 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 () { - return ( - <PageContainerFooter - onCancel={() => this.onCancel()} - onSubmit={e => this.onSubmit(e)} - disabled={this.formShouldBeDisabled()} - /> - ) - } - -} diff --git a/ui/app/components/send/send-footer/send-footer.container.js b/ui/app/components/send/send-footer/send-footer.container.js deleted file mode 100644 index 0c6120cc5..000000000 --- a/ui/app/components/send/send-footer/send-footer.container.js +++ /dev/null @@ -1,107 +0,0 @@ -import { connect } from 'react-redux' -import ethUtil from 'ethereumjs-util' -import { - addToAddressBook, - clearSend, - signTokenTx, - signTx, - updateTransaction, -} from '../../../actions' -import SendFooter from './send-footer.component' -import { - getGasLimit, - getGasPrice, - getGasTotal, - getSelectedToken, - getSendAmount, - getSendEditingTransactionId, - getSendFromObject, - getSendTo, - getSendToAccounts, - getSendHexData, - getTokenBalance, - getUnapprovedTxs, - getSendErrors, -} from '../send.selectors' -import { - isSendFormInError, -} from './send-footer.selectors' -import { - addressIsNew, - constructTxParams, - constructUpdatedTx, -} from './send-footer.utils' - -export default connect(mapStateToProps, mapDispatchToProps)(SendFooter) - -function mapStateToProps (state) { - return { - amount: getSendAmount(state), - data: getSendHexData(state), - editingTransactionId: getSendEditingTransactionId(state), - from: getSendFromObject(state), - gasLimit: getGasLimit(state), - gasPrice: getGasPrice(state), - gasTotal: getGasTotal(state), - inError: isSendFormInError(state), - selectedToken: getSelectedToken(state), - to: getSendTo(state), - toAccounts: getSendToAccounts(state), - tokenBalance: getTokenBalance(state), - unapprovedTxs: getUnapprovedTxs(state), - sendErrors: getSendErrors(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - clearSend: () => dispatch(clearSend()), - sign: ({ selectedToken, to, amount, from, gas, gasPrice, data }) => { - const txParams = constructTxParams({ - amount, - data, - from, - gas, - gasPrice, - selectedToken, - to, - }) - - selectedToken - ? dispatch(signTokenTx(selectedToken.address, to, amount, txParams)) - : dispatch(signTx(txParams)) - }, - update: ({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - selectedToken, - to, - unapprovedTxs, - }) => { - const editingTx = constructUpdatedTx({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - selectedToken, - to, - unapprovedTxs, - }) - - return dispatch(updateTransaction(editingTx)) - }, - addToAddressBookIfNew: (newAddress, toAccounts, nickname = '') => { - const hexPrefixedAddress = ethUtil.addHexPrefix(newAddress) - if (addressIsNew(toAccounts)) { - // TODO: nickname, i.e. addToAddressBook(recipient, nickname) - dispatch(addToAddressBook(hexPrefixedAddress, nickname)) - } - }, - } -} diff --git a/ui/app/components/send/send-footer/tests/send-footer-component.test.js b/ui/app/components/send/send-footer/tests/send-footer-component.test.js deleted file mode 100644 index 4b63e422d..000000000 --- a/ui/app/components/send/send-footer/tests/send-footer-component.test.js +++ /dev/null @@ -1,233 +0,0 @@ -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 SendFooter from '../send-footer.component.js' - -import PageContainerFooter from '../../../page-container/page-container-footer' - -const propsMethodSpies = { - addToAddressBookIfNew: sinon.spy(), - clearSend: sinon.spy(), - sign: sinon.spy(), - update: sinon.spy(), -} -const historySpies = { - push: sinon.spy(), -} -const MOCK_EVENT = { preventDefault: () => {} } - -sinon.spy(SendFooter.prototype, 'onCancel') -sinon.spy(SendFooter.prototype, 'onSubmit') - -describe('SendFooter Component', function () { - let wrapper - - beforeEach(() => { - wrapper = shallow(<SendFooter - addToAddressBookIfNew={propsMethodSpies.addToAddressBookIfNew} - amount={'mockAmount'} - clearSend={propsMethodSpies.clearSend} - disabled={true} - editingTransactionId={'mockEditingTransactionId'} - errors={{}} - from={ { address: 'mockAddress', balance: 'mockBalance' } } - gasLimit={'mockGasLimit'} - gasPrice={'mockGasPrice'} - gasTotal={'mockGasTotal'} - history={historySpies} - inError={false} - selectedToken={{ mockProp: 'mockSelectedTokenProp' }} - sign={propsMethodSpies.sign} - to={'mockTo'} - toAccounts={['mockAccount']} - tokenBalance={'mockTokenBalance'} - unapprovedTxs={['mockTx']} - update={propsMethodSpies.update} - sendErrors={{}} - />, { context: { t: str => str, metricsEvent: () => ({}) } }) - }) - - afterEach(() => { - propsMethodSpies.clearSend.resetHistory() - propsMethodSpies.addToAddressBookIfNew.resetHistory() - propsMethodSpies.clearSend.resetHistory() - propsMethodSpies.sign.resetHistory() - propsMethodSpies.update.resetHistory() - historySpies.push.resetHistory() - SendFooter.prototype.onCancel.resetHistory() - SendFooter.prototype.onSubmit.resetHistory() - }) - - describe('onCancel', () => { - it('should call clearSend', () => { - assert.equal(propsMethodSpies.clearSend.callCount, 0) - wrapper.instance().onCancel() - assert.equal(propsMethodSpies.clearSend.callCount, 1) - }) - - it('should call history.push', () => { - assert.equal(historySpies.push.callCount, 0) - wrapper.instance().onCancel() - assert.equal(historySpies.push.callCount, 1) - assert.equal(historySpies.push.getCall(0).args[0], DEFAULT_ROUTE) - }) - }) - - - describe('formShouldBeDisabled()', () => { - const config = { - 'should return true if inError is truthy': { - inError: true, - expectedResult: true, - }, - 'should return true if gasTotal is falsy': { - inError: false, - gasTotal: false, - expectedResult: true, - }, - 'should return true if to is truthy': { - to: '0xsomevalidAddress', - inError: false, - gasTotal: false, - expectedResult: true, - }, - 'should return true if selectedToken is truthy and tokenBalance is falsy': { - selectedToken: true, - tokenBalance: null, - expectedResult: true, - }, - 'should return false if inError is false and all other params are truthy': { - inError: false, - gasTotal: '0x123', - selectedToken: true, - tokenBalance: 123, - expectedResult: false, - }, - } - Object.entries(config).map(([description, obj]) => { - it(description, () => { - wrapper.setProps(obj) - assert.equal(wrapper.instance().formShouldBeDisabled(), obj.expectedResult) - }) - }) - }) - - describe('onSubmit', () => { - it('should call addToAddressBookIfNew with the correct params', () => { - wrapper.instance().onSubmit(MOCK_EVENT) - assert(propsMethodSpies.addToAddressBookIfNew.calledOnce) - assert.deepEqual( - propsMethodSpies.addToAddressBookIfNew.getCall(0).args, - ['mockTo', ['mockAccount']] - ) - }) - - it('should call props.update if editingTransactionId is truthy', () => { - wrapper.instance().onSubmit(MOCK_EVENT) - assert(propsMethodSpies.update.calledOnce) - assert.deepEqual( - propsMethodSpies.update.getCall(0).args[0], - { - data: undefined, - amount: 'mockAmount', - editingTransactionId: 'mockEditingTransactionId', - from: 'mockAddress', - gas: 'mockGasLimit', - gasPrice: 'mockGasPrice', - selectedToken: { mockProp: 'mockSelectedTokenProp' }, - to: 'mockTo', - unapprovedTxs: ['mockTx'], - } - ) - }) - - it('should not call props.sign if editingTransactionId is truthy', () => { - assert.equal(propsMethodSpies.sign.callCount, 0) - }) - - it('should call props.sign if editingTransactionId is falsy', () => { - wrapper.setProps({ editingTransactionId: null }) - wrapper.instance().onSubmit(MOCK_EVENT) - assert(propsMethodSpies.sign.calledOnce) - assert.deepEqual( - propsMethodSpies.sign.getCall(0).args[0], - { - data: undefined, - amount: 'mockAmount', - from: 'mockAddress', - gas: 'mockGasLimit', - gasPrice: 'mockGasPrice', - selectedToken: { mockProp: 'mockSelectedTokenProp' }, - to: 'mockTo', - } - ) - }) - - it('should not call props.update if editingTransactionId is falsy', () => { - assert.equal(propsMethodSpies.update.callCount, 0) - }) - - it('should call history.push', done => { - Promise.resolve(wrapper.instance().onSubmit(MOCK_EVENT)) - .then(() => { - assert.equal(historySpies.push.callCount, 1) - assert.equal(historySpies.push.getCall(0).args[0], CONFIRM_TRANSACTION_ROUTE) - done() - }) - }) - }) - - describe('render', () => { - beforeEach(() => { - sinon.stub(SendFooter.prototype, 'formShouldBeDisabled').returns('formShouldBeDisabledReturn') - wrapper = shallow(<SendFooter - addToAddressBookIfNew={propsMethodSpies.addToAddressBookIfNew} - amount={'mockAmount'} - clearSend={propsMethodSpies.clearSend} - disabled={true} - editingTransactionId={'mockEditingTransactionId'} - errors={{}} - from={ { address: 'mockAddress', balance: 'mockBalance' } } - gasLimit={'mockGasLimit'} - gasPrice={'mockGasPrice'} - gasTotal={'mockGasTotal'} - history={historySpies} - inError={false} - selectedToken={{ mockProp: 'mockSelectedTokenProp' }} - sign={propsMethodSpies.sign} - to={'mockTo'} - toAccounts={['mockAccount']} - tokenBalance={'mockTokenBalance'} - unapprovedTxs={['mockTx']} - update={propsMethodSpies.update} - />, { context: { t: str => str, metricsEvent: () => ({}) } }) - }) - - afterEach(() => { - SendFooter.prototype.formShouldBeDisabled.restore() - }) - - it('should render a PageContainerFooter component', () => { - assert.equal(wrapper.find(PageContainerFooter).length, 1) - }) - - it('should pass the correct props to PageContainerFooter', () => { - const { - onCancel, - onSubmit, - disabled, - } = wrapper.find(PageContainerFooter).props() - assert.equal(disabled, 'formShouldBeDisabledReturn') - - assert.equal(SendFooter.prototype.onSubmit.callCount, 0) - onSubmit(MOCK_EVENT) - assert.equal(SendFooter.prototype.onSubmit.callCount, 1) - - assert.equal(SendFooter.prototype.onCancel.callCount, 0) - onCancel() - assert.equal(SendFooter.prototype.onCancel.callCount, 1) - }) - }) -}) diff --git a/ui/app/components/send/send-footer/tests/send-footer-container.test.js b/ui/app/components/send/send-footer/tests/send-footer-container.test.js deleted file mode 100644 index 70cb28df3..000000000 --- a/ui/app/components/send/send-footer/tests/send-footer-container.test.js +++ /dev/null @@ -1,200 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - addToAddressBook: sinon.spy(), - clearSend: sinon.spy(), - signTokenTx: sinon.spy(), - signTx: sinon.spy(), - updateTransaction: sinon.spy(), -} -const utilsStubs = { - addressIsNew: sinon.stub().returns(true), - constructTxParams: sinon.stub().returns({ - value: 'mockAmount', - }), - constructUpdatedTx: sinon.stub().returns('mockConstructedUpdatedTxParams'), -} - -proxyquire('../send-footer.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - '../../../actions': actionSpies, - '../send.selectors': { - getGasLimit: (s) => `mockGasLimit:${s}`, - getGasPrice: (s) => `mockGasPrice:${s}`, - getGasTotal: (s) => `mockGasTotal:${s}`, - getSelectedToken: (s) => `mockSelectedToken:${s}`, - getSendAmount: (s) => `mockAmount:${s}`, - getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, - getSendFromObject: (s) => `mockFromObject:${s}`, - getSendTo: (s) => `mockTo:${s}`, - getSendToAccounts: (s) => `mockToAccounts:${s}`, - 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, -}) - -describe('send-footer container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - amount: 'mockAmount:mockState', - data: 'mockHexData:mockState', - selectedToken: 'mockSelectedToken:mockState', - editingTransactionId: 'mockEditingTransactionId:mockState', - from: 'mockFromObject:mockState', - gasLimit: 'mockGasLimit:mockState', - gasPrice: 'mockGasPrice:mockState', - gasTotal: 'mockGasTotal:mockState', - inError: 'mockInError:mockState', - to: 'mockTo:mockState', - toAccounts: 'mockToAccounts:mockState', - tokenBalance: 'mockTokenBalance:mockState', - unapprovedTxs: 'mockUnapprovedTxs:mockState', - sendErrors: 'mockSendErrors:mockState', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - }) - - describe('clearSend()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.clearSend() - assert(dispatchSpy.calledOnce) - assert(actionSpies.clearSend.calledOnce) - }) - }) - - describe('sign()', () => { - it('should dispatch a signTokenTx action if selectedToken is defined', () => { - mapDispatchToPropsObject.sign({ - selectedToken: { - address: '0xabc', - }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }) - assert(dispatchSpy.calledOnce) - assert.deepEqual( - utilsStubs.constructTxParams.getCall(0).args[0], - { - data: undefined, - selectedToken: { - address: '0xabc', - }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - } - ) - assert.deepEqual( - actionSpies.signTokenTx.getCall(0).args, - [ '0xabc', 'mockTo', 'mockAmount', { value: 'mockAmount' } ] - ) - }) - - it('should dispatch a sign action if selectedToken is not defined', () => { - utilsStubs.constructTxParams.resetHistory() - mapDispatchToPropsObject.sign({ - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }) - assert(dispatchSpy.calledOnce) - assert.deepEqual( - utilsStubs.constructTxParams.getCall(0).args[0], - { - data: undefined, - selectedToken: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - } - ) - assert.deepEqual( - actionSpies.signTx.getCall(0).args, - [ { value: 'mockAmount' } ] - ) - }) - }) - - describe('update()', () => { - it('should dispatch an updateTransaction action', () => { - mapDispatchToPropsObject.update({ - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - editingTransactionId: 'mockEditingTransactionId', - selectedToken: 'mockSelectedToken', - unapprovedTxs: 'mockUnapprovedTxs', - }) - assert(dispatchSpy.calledOnce) - assert.deepEqual( - utilsStubs.constructUpdatedTx.getCall(0).args[0], - { - data: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - editingTransactionId: 'mockEditingTransactionId', - selectedToken: 'mockSelectedToken', - unapprovedTxs: 'mockUnapprovedTxs', - } - ) - assert.equal(actionSpies.updateTransaction.getCall(0).args[0], 'mockConstructedUpdatedTxParams') - }) - }) - - describe('addToAddressBookIfNew()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.addToAddressBookIfNew('mockNewAddress', 'mockToAccounts', 'mockNickname') - assert(dispatchSpy.calledOnce) - assert.equal(utilsStubs.addressIsNew.getCall(0).args[0], 'mockToAccounts') - assert.deepEqual( - actionSpies.addToAddressBook.getCall(0).args, - [ '0xmockNewAddress', 'mockNickname' ] - ) - }) - }) - - }) - -}) diff --git a/ui/app/components/send/send-header/send-header.component.js b/ui/app/components/send/send-header/send-header.component.js deleted file mode 100644 index efc4bbf27..000000000 --- a/ui/app/components/send/send-header/send-header.component.js +++ /dev/null @@ -1,34 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import PageContainerHeader from '../../page-container/page-container-header' -import { DEFAULT_ROUTE } from '../../../routes' - -export default class SendHeader extends Component { - - static propTypes = { - clearSend: PropTypes.func, - history: PropTypes.object, - titleKey: PropTypes.string, - subtitleParams: PropTypes.array, - }; - - static contextTypes = { - t: PropTypes.func, - }; - - onClose () { - this.props.clearSend() - this.props.history.push(DEFAULT_ROUTE) - } - - render () { - return ( - <PageContainerHeader - onClose={() => this.onClose()} - subtitle={this.context.t(...this.props.subtitleParams)} - title={this.context.t(this.props.titleKey)} - /> - ) - } - -} diff --git a/ui/app/components/send/send-header/send-header.container.js b/ui/app/components/send/send-header/send-header.container.js deleted file mode 100644 index 4bcd0d1b6..000000000 --- a/ui/app/components/send/send-header/send-header.container.js +++ /dev/null @@ -1,19 +0,0 @@ -import { connect } from 'react-redux' -import { clearSend } from '../../../actions' -import SendHeader from './send-header.component' -import { getSubtitleParams, getTitleKey } from './send-header.selectors' - -export default connect(mapStateToProps, mapDispatchToProps)(SendHeader) - -function mapStateToProps (state) { - return { - titleKey: getTitleKey(state), - subtitleParams: getSubtitleParams(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - clearSend: () => dispatch(clearSend()), - } -} diff --git a/ui/app/components/send/send-header/tests/send-header-component.test.js b/ui/app/components/send/send-header/tests/send-header-component.test.js deleted file mode 100644 index 930bfa387..000000000 --- a/ui/app/components/send/send-header/tests/send-header-component.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import sinon from 'sinon' -import { DEFAULT_ROUTE } from '../../../../routes' -import SendHeader from '../send-header.component.js' - -import PageContainerHeader from '../../../page-container/page-container-header' - -const propsMethodSpies = { - clearSend: sinon.spy(), -} -const historySpies = { - push: sinon.spy(), -} - -sinon.spy(SendHeader.prototype, 'onClose') - -describe('SendHeader Component', function () { - let wrapper - - beforeEach(() => { - wrapper = shallow(<SendHeader - clearSend={propsMethodSpies.clearSend} - history={historySpies} - titleKey={'mockTitleKey'} - subtitleParams={[ 'mockSubtitleKey', 'mockVal']} - />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }) - }) - - afterEach(() => { - propsMethodSpies.clearSend.resetHistory() - historySpies.push.resetHistory() - SendHeader.prototype.onClose.resetHistory() - }) - - describe('onClose', () => { - it('should call clearSend', () => { - assert.equal(propsMethodSpies.clearSend.callCount, 0) - wrapper.instance().onClose() - assert.equal(propsMethodSpies.clearSend.callCount, 1) - }) - - it('should call history.push', () => { - assert.equal(historySpies.push.callCount, 0) - wrapper.instance().onClose() - assert.equal(historySpies.push.callCount, 1) - assert.equal(historySpies.push.getCall(0).args[0], DEFAULT_ROUTE) - }) - }) - - describe('render', () => { - it('should render a PageContainerHeader compenent', () => { - assert.equal(wrapper.find(PageContainerHeader).length, 1) - }) - - it('should pass the correct props to PageContainerHeader', () => { - const { - onClose, - subtitle, - title, - } = wrapper.find(PageContainerHeader).props() - assert.equal(subtitle, 'mockSubtitleKeymockVal') - assert.equal(title, 'mockTitleKey') - assert.equal(SendHeader.prototype.onClose.callCount, 0) - onClose() - assert.equal(SendHeader.prototype.onClose.callCount, 1) - }) - }) -}) diff --git a/ui/app/components/send/send-header/tests/send-header-container.test.js b/ui/app/components/send/send-header/tests/send-header-container.test.js deleted file mode 100644 index 41a7e8a89..000000000 --- a/ui/app/components/send/send-header/tests/send-header-container.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - clearSend: sinon.spy(), -} - -proxyquire('../send-header.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - '../../../actions': actionSpies, - './send-header.selectors': { - getTitleKey: (s) => `mockTitleKey:${s}`, - getSubtitleParams: (s) => `mockSubtitleParams:${s}`, - }, -}) - -describe('send-header container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - titleKey: 'mockTitleKey:mockState', - subtitleParams: 'mockSubtitleParams:mockState', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - }) - - describe('clearSend()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.clearSend() - assert(dispatchSpy.calledOnce) - assert(actionSpies.clearSend.calledOnce) - }) - }) - - }) - -}) diff --git a/ui/app/components/send/send.component.js b/ui/app/components/send/send.component.js deleted file mode 100644 index 9b512aaf6..000000000 --- a/ui/app/components/send/send.component.js +++ /dev/null @@ -1,218 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import PersistentForm from '../../../lib/persistent-form' -import { - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - doesAmountErrorRequireUpdate, -} from './send.utils' - -import SendHeader from './send-header/' -import SendContent from './send-content/' -import SendFooter from './send-footer/' - -export default class SendTransactionScreen extends PersistentForm { - - static propTypes = { - amount: PropTypes.string, - amountConversionRate: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]), - blockGasLimit: PropTypes.string, - conversionRate: PropTypes.number, - editingTransactionId: PropTypes.string, - from: PropTypes.object, - gasLimit: PropTypes.string, - gasPrice: PropTypes.string, - gasTotal: PropTypes.string, - history: PropTypes.object, - network: PropTypes.string, - primaryCurrency: PropTypes.string, - recentBlocks: PropTypes.array, - selectedAddress: PropTypes.string, - 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) { - if (nextProps.qrCodeData.type === 'address') { - const scannedAddress = nextProps.qrCodeData.values.address.toLowerCase() - const currentAddress = this.props.to && this.props.to.toLowerCase() - if (currentAddress !== scannedAddress) { - this.props.updateSendTo(scannedAddress) - this.updateGas({ to: scannedAddress }) - // Clean up QR code data after handling - this.props.qrCodeDetected(null) - } - } - } - } - - updateGas ({ to: updatedToAddress, amount: value, data } = {}) { - const { - amount, - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - recentBlocks, - selectedAddress, - selectedToken = {}, - to: currentToAddress, - updateAndSetGasLimit, - } = this.props - - updateAndSetGasLimit({ - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - recentBlocks, - selectedAddress, - selectedToken, - to: getToAddressForGasUpdate(updatedToAddress, currentToAddress), - value: value || amount, - data, - }) - } - - componentDidUpdate (prevProps) { - const { - amount, - amountConversionRate, - conversionRate, - from: { address, balance }, - gasTotal, - network, - primaryCurrency, - selectedToken, - tokenBalance, - updateSendErrors, - updateSendTokenBalance, - tokenContract, - } = this.props - - const { - from: { balance: prevBalance }, - gasTotal: prevGasTotal, - tokenBalance: prevTokenBalance, - network: prevNetwork, - } = prevProps - - const uninitialized = [prevBalance, prevGasTotal].every(n => n === null) - - const amountErrorRequiresUpdate = doesAmountErrorRequireUpdate({ - balance, - gasTotal, - prevBalance, - prevGasTotal, - prevTokenBalance, - selectedToken, - tokenBalance, - }) - - if (amountErrorRequiresUpdate) { - const amountErrorObject = getAmountErrorObject({ - amount, - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - selectedToken, - tokenBalance, - }) - const gasFeeErrorObject = selectedToken - ? getGasFeeErrorObject({ - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - selectedToken, - }) - : { gasFee: null } - updateSendErrors(Object.assign(amountErrorObject, gasFeeErrorObject)) - } - - if (!uninitialized) { - - if (network !== prevNetwork && network !== 'loading') { - updateSendTokenBalance({ - selectedToken, - tokenContract, - address, - }) - this.updateGas() - } - } - } - - componentDidMount () { - this.props.fetchBasicGasEstimates() - .then(() => { - this.updateGas() - }) - } - - componentWillMount () { - const { - from: { address }, - selectedToken, - tokenContract, - updateSendTokenBalance, - } = this.props - - updateSendTokenBalance({ - selectedToken, - tokenContract, - address, - }) - - // Show QR Scanner modal if ?scan=true - if (window.location.search === '?scan=true') { - this.props.scanQrCode() - - // Clear the queryString param after showing the modal - const cleanUrl = location.href.split('?')[0] - history.pushState({}, null, `${cleanUrl}`) - window.location.hash = '#send' - } - } - - componentWillUnmount () { - this.props.resetSendState() - } - - render () { - const { history, showHexData } = this.props - - return ( - <div className="page-container"> - <SendHeader history={history}/> - <SendContent - updateGas={(updateData) => this.updateGas(updateData)} - scanQrCode={_ => this.props.scanQrCode()} - showHexData={showHexData} - /> - <SendFooter history={history}/> - </div> - ) - } - -} diff --git a/ui/app/components/send/send.constants.js b/ui/app/components/send/send.constants.js deleted file mode 100644 index 490bc6fd2..000000000 --- a/ui/app/components/send/send.constants.js +++ /dev/null @@ -1,61 +0,0 @@ -const ethUtil = require('ethereumjs-util') -const { conversionUtil, multiplyCurrencies } = require('../../conversion-util') - -const MIN_GAS_PRICE_DEC = '0' -const MIN_GAS_PRICE_HEX = (parseInt(MIN_GAS_PRICE_DEC)).toString(16) -const MIN_GAS_LIMIT_DEC = '21000' -const MIN_GAS_LIMIT_HEX = (parseInt(MIN_GAS_LIMIT_DEC)).toString(16) - -const MIN_GAS_PRICE_GWEI = ethUtil.addHexPrefix(conversionUtil(MIN_GAS_PRICE_HEX, { - fromDenomination: 'WEI', - toDenomination: 'GWEI', - fromNumericBase: 'hex', - toNumericBase: 'hex', - numberOfDecimals: 1, -})) - -const MIN_GAS_TOTAL = multiplyCurrencies(MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_HEX, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, -}) - -const TOKEN_TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb' - -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', - toDenomination: 'WEI', - fromNumericBase: 'hex', - toNumericBase: 'hex', -})) - -const SIMPLE_GAS_COST = '0x5208' // Hex for 21000, cost of a simple send. -const BASE_TOKEN_GAS_COST = '0x186a0' // Hex for 100000, a base estimate for token transfers. - -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, - MIN_GAS_PRICE_GWEI, - MIN_GAS_PRICE_HEX, - MIN_GAS_TOTAL, - NEGATIVE_ETH_ERROR, - ONE_GWEI_IN_WEI_HEX, - REQUIRED_ERROR, - SIMPLE_GAS_COST, - TOKEN_TRANSFER_FUNCTION_SIGNATURE, - BASE_TOKEN_GAS_COST, -} diff --git a/ui/app/components/send/send.container.js b/ui/app/components/send/send.container.js deleted file mode 100644 index 402e4bbe5..000000000 --- a/ui/app/components/send/send.container.js +++ /dev/null @@ -1,112 +0,0 @@ -import { connect } from 'react-redux' -import SendEther from './send.component' -import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' -import { - getAmountConversionRate, - getBlockGasLimit, - getConversionRate, - getCurrentNetwork, - getGasLimit, - getGasPrice, - getGasTotal, - getPrimaryCurrency, - getRecentBlocks, - getSelectedAddress, - getSelectedToken, - getSelectedTokenContract, - getSelectedTokenToFiatRate, - getSendAmount, - getSendEditingTransactionId, - getSendHexDataFeatureFlagState, - getSendFromObject, - getSendTo, - getTokenBalance, - getQrCodeData, -} from './send.selectors' -import { - updateSendTo, - updateSendTokenBalance, - updateGasData, - setGasTotal, - showQrScanner, - qrCodeDetected, -} from '../../actions' -import { - resetSendState, - updateSendErrors, -} from '../../ducks/send.duck' -import { - fetchBasicGasEstimates, -} from '../../ducks/gas.duck' -import { - calcGasTotal, -} from './send.utils.js' - -import { - SEND_ROUTE, -} from '../../routes' - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(SendEther) - -function mapStateToProps (state) { - return { - amount: getSendAmount(state), - amountConversionRate: getAmountConversionRate(state), - blockGasLimit: getBlockGasLimit(state), - conversionRate: getConversionRate(state), - editingTransactionId: getSendEditingTransactionId(state), - from: getSendFromObject(state), - gasLimit: getGasLimit(state), - gasPrice: getGasPrice(state), - gasTotal: getGasTotal(state), - network: getCurrentNetwork(state), - primaryCurrency: getPrimaryCurrency(state), - recentBlocks: getRecentBlocks(state), - selectedAddress: getSelectedAddress(state), - selectedToken: getSelectedToken(state), - showHexData: getSendHexDataFeatureFlagState(state), - to: getSendTo(state), - tokenBalance: getTokenBalance(state), - tokenContract: getSelectedTokenContract(state), - tokenToFiatRate: getSelectedTokenToFiatRate(state), - qrCodeData: getQrCodeData(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - updateAndSetGasLimit: ({ - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - recentBlocks, - selectedAddress, - selectedToken, - to, - value, - data, - }) => { - !editingTransactionId - ? dispatch(updateGasData({ gasPrice, recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value, data })) - : dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) - }, - updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => { - dispatch(updateSendTokenBalance({ - selectedToken, - tokenContract, - address, - })) - }, - updateSendErrors: newError => dispatch(updateSendErrors(newError)), - resetSendState: () => dispatch(resetSendState()), - 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.selectors.js b/ui/app/components/send/send.selectors.js deleted file mode 100644 index 47a49500f..000000000 --- a/ui/app/components/send/send.selectors.js +++ /dev/null @@ -1,291 +0,0 @@ -const { valuesFor } = require('../../util') -const abi = require('human-standard-token-abi') -const { - multiplyCurrencies, -} = require('../../conversion-util') -const { - getMetaMaskAccounts, -} = require('../../selectors') -const { - estimateGasPriceFromRecentBlocks, - calcGasTotal, -} = require('./send.utils') -import { - getFastPriceEstimateInHexWEI, -} from '../../selectors/custom-gas' - -const selectors = { - accountsWithSendEtherInfoSelector, - getAddressBook, - getAmountConversionRate, - getBlockGasLimit, - getConversionRate, - getCurrentAccountWithSendEtherInfo, - getCurrentCurrency, - getCurrentNetwork, - getCurrentViewContext, - getForceGasMin, - getNativeCurrency, - getGasLimit, - getGasPrice, - getGasPriceFromRecentBlocks, - getGasTotal, - getPrimaryCurrency, - getRecentBlocks, - getSelectedAccount, - getSelectedAddress, - getSelectedIdentity, - getSelectedToken, - getSelectedTokenContract, - getSelectedTokenExchangeRate, - getSelectedTokenToFiatRate, - getSendAmount, - getSendHexData, - getSendHexDataFeatureFlagState, - getSendEditingTransactionId, - getSendErrors, - getSendFrom, - getSendFromBalance, - getSendFromObject, - getSendMaxModeState, - getSendTo, - getSendToAccounts, - getSendWarnings, - getTokenBalance, - getTokenExchangeRate, - getUnapprovedTxs, - transactionsSelector, - getQrCodeData, -} - -module.exports = selectors - -function accountsWithSendEtherInfoSelector (state) { - const accounts = getMetaMaskAccounts(state) - const { identities } = state.metamask - - const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { - return Object.assign({}, account, identities[key]) - }) - - return accountsWithSendEtherInfo -} - -function getAddressBook (state) { - return state.metamask.addressBook -} - -function getAmountConversionRate (state) { - return getSelectedToken(state) - ? getSelectedTokenToFiatRate(state) - : getConversionRate(state) -} - -function getBlockGasLimit (state) { - return state.metamask.currentBlockGasLimit -} - -function getConversionRate (state) { - return state.metamask.conversionRate -} - -function getCurrentAccountWithSendEtherInfo (state) { - const currentAddress = getSelectedAddress(state) - const accounts = accountsWithSendEtherInfoSelector(state) - - return accounts.find(({ address }) => address === currentAddress) -} - -function getCurrentCurrency (state) { - return state.metamask.currentCurrency -} - -function getNativeCurrency (state) { - return state.metamask.nativeCurrency -} - -function getCurrentNetwork (state) { - return state.metamask.network -} - -function getCurrentViewContext (state) { - const { currentView = {} } = state.appState - return currentView.context -} - -function getForceGasMin (state) { - return state.metamask.send.forceGasMin -} - -function getGasLimit (state) { - return state.metamask.send.gasLimit || '0' -} - -function getGasPrice (state) { - return state.metamask.send.gasPrice || getFastPriceEstimateInHexWEI(state) -} - -function getGasPriceFromRecentBlocks (state) { - return estimateGasPriceFromRecentBlocks(state.metamask.recentBlocks) -} - -function getGasTotal (state) { - return calcGasTotal(getGasLimit(state), getGasPrice(state)) -} - -function getPrimaryCurrency (state) { - const selectedToken = getSelectedToken(state) - return selectedToken && selectedToken.symbol -} - -function getRecentBlocks (state) { - return state.metamask.recentBlocks -} - -function getSelectedAccount (state) { - const accounts = getMetaMaskAccounts(state) - const selectedAddress = getSelectedAddress(state) - - return accounts[selectedAddress] -} - -function getSelectedAddress (state) { - const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0] - - return selectedAddress -} - -function getSelectedIdentity (state) { - const selectedAddress = getSelectedAddress(state) - const identities = state.metamask.identities - - return identities[selectedAddress] -} - -function getSelectedToken (state) { - const tokens = state.metamask.tokens || [] - const selectedTokenAddress = state.metamask.selectedTokenAddress - const selectedToken = tokens.filter(({ address }) => address === selectedTokenAddress)[0] - const sendToken = state.metamask.send.token - - return selectedToken || sendToken || null -} - -function getSelectedTokenContract (state) { - const selectedToken = getSelectedToken(state) - - return selectedToken - ? global.eth.contract(abi).at(selectedToken.address) - : null -} - -function getSelectedTokenExchangeRate (state) { - const tokenExchangeRates = state.metamask.tokenExchangeRates - const selectedToken = getSelectedToken(state) || {} - const { symbol = '' } = selectedToken - const pair = `${symbol.toLowerCase()}_eth` - const { rate: tokenExchangeRate = 0 } = tokenExchangeRates && tokenExchangeRates[pair] || {} - - return tokenExchangeRate -} - -function getSelectedTokenToFiatRate (state) { - const selectedTokenExchangeRate = getSelectedTokenExchangeRate(state) - const conversionRate = getConversionRate(state) - - const tokenToFiatRate = multiplyCurrencies( - conversionRate, - selectedTokenExchangeRate, - { toNumericBase: 'dec' } - ) - - return tokenToFiatRate -} - -function getSendAmount (state) { - return state.metamask.send.amount -} - -function getSendHexData (state) { - return state.metamask.send.data -} - -function getSendHexDataFeatureFlagState (state) { - return state.metamask.featureFlags.sendHexData -} - -function getSendEditingTransactionId (state) { - return state.metamask.send.editingTransactionId -} - -function getSendErrors (state) { - return state.send.errors -} - -function getSendFrom (state) { - return state.metamask.send.from -} - -function getSendFromBalance (state) { - const from = getSendFrom(state) || getSelectedAccount(state) - return from.balance -} - -function getSendFromObject (state) { - return getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state) -} - -function getSendMaxModeState (state) { - return state.metamask.send.maxModeOn -} - -function getSendTo (state) { - return state.metamask.send.to -} - -function getSendToAccounts (state) { - const fromAccounts = accountsWithSendEtherInfoSelector(state) - const addressBookAccounts = getAddressBook(state) - const allAccounts = [...fromAccounts, ...addressBookAccounts] - // TODO: figure out exactly what the below returns and put a descriptive variable name on it - return Object.entries(allAccounts).map(([key, account]) => account) -} - -function getSendWarnings (state) { - return state.send.warnings -} - -function getTokenBalance (state) { - return state.metamask.send.tokenBalance -} - -function getTokenExchangeRate (state, tokenSymbol) { - const pair = `${tokenSymbol.toLowerCase()}_eth` - const tokenExchangeRates = state.metamask.tokenExchangeRates - const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} - - return tokenExchangeRate -} - -function getUnapprovedTxs (state) { - return state.metamask.unapprovedTxs -} - -function transactionsSelector (state) { - const { network, selectedTokenAddress } = state.metamask - const unapprovedMsgs = valuesFor(state.metamask.unapprovedMsgs) - const shapeShiftTxList = (network === '1') ? state.metamask.shapeShiftTxList : undefined - const transactions = state.metamask.selectedAddressTxList || [] - const txsToRender = !shapeShiftTxList ? transactions.concat(unapprovedMsgs) : transactions.concat(unapprovedMsgs, shapeShiftTxList) - - return selectedTokenAddress - ? txsToRender - .filter(({ txParams }) => txParams && txParams.to === selectedTokenAddress) - .sort((a, b) => b.time - a.time) - : txsToRender - .sort((a, b) => b.time - a.time) -} - -function getQrCodeData (state) { - return state.appState.qrCodeData -} diff --git a/ui/app/components/send/send.utils.js b/ui/app/components/send/send.utils.js deleted file mode 100644 index d78b7736f..000000000 --- a/ui/app/components/send/send.utils.js +++ /dev/null @@ -1,332 +0,0 @@ -const { - addCurrencies, - conversionUtil, - conversionGTE, - multiplyCurrencies, - conversionGreaterThan, - conversionLessThan, -} = require('../../conversion-util') -const { - calcTokenAmount, -} = require('../../token-util') -const { - BASE_TOKEN_GAS_COST, - INSUFFICIENT_FUNDS_ERROR, - INSUFFICIENT_TOKENS_ERROR, - NEGATIVE_ETH_ERROR, - ONE_GWEI_IN_WEI_HEX, - SIMPLE_GAS_COST, - TOKEN_TRANSFER_FUNCTION_SIGNATURE, -} = require('./send.constants') -const abi = require('ethereumjs-abi') -const ethUtil = require('ethereumjs-util') - -module.exports = { - addGasBuffer, - calcGasTotal, - calcTokenBalance, - doesAmountErrorRequireUpdate, - estimateGas, - estimateGasPriceFromRecentBlocks, - generateTokenTransferData, - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - isBalanceSufficient, - isTokenBalanceSufficient, - removeLeadingZeroes, -} - -function calcGasTotal (gasLimit = '0', gasPrice = '0') { - return multiplyCurrencies(gasLimit, gasPrice, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, - }) -} - -function isBalanceSufficient ({ - amount = '0x0', - amountConversionRate = 1, - balance = '0x0', - conversionRate = 1, - gasTotal = '0x0', - primaryCurrency, -}) { - const totalAmount = addCurrencies(amount, gasTotal, { - aBase: 16, - bBase: 16, - toNumericBase: 'hex', - }) - - const balanceIsSufficient = conversionGTE( - { - value: balance, - fromNumericBase: 'hex', - fromCurrency: primaryCurrency, - conversionRate, - }, - { - value: totalAmount, - fromNumericBase: 'hex', - conversionRate: Number(amountConversionRate) || conversionRate, - fromCurrency: primaryCurrency, - }, - ) - - return balanceIsSufficient -} - -function isTokenBalanceSufficient ({ - amount = '0x0', - tokenBalance, - decimals, -}) { - const amountInDec = conversionUtil(amount, { - fromNumericBase: 'hex', - }) - - const tokenBalanceIsSufficient = conversionGTE( - { - value: tokenBalance, - fromNumericBase: 'hex', - }, - { - value: calcTokenAmount(amountInDec, decimals), - }, - ) - - return tokenBalanceIsSufficient -} - -function getAmountErrorObject ({ - amount, - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - selectedToken, - tokenBalance, -}) { - let insufficientFunds = false - if (gasTotal && conversionRate && !selectedToken) { - insufficientFunds = !isBalanceSufficient({ - amount, - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - }) - } - - let inSufficientTokens = false - if (selectedToken && tokenBalance !== null) { - const { decimals } = selectedToken - inSufficientTokens = !isTokenBalanceSufficient({ - tokenBalance, - amount, - decimals, - }) - } - - const amountLessThanZero = conversionGreaterThan( - { value: 0, fromNumericBase: 'dec' }, - { value: amount, fromNumericBase: 'hex' }, - ) - - let amountError = null - - if (insufficientFunds) { - amountError = INSUFFICIENT_FUNDS_ERROR - } else if (inSufficientTokens) { - amountError = INSUFFICIENT_TOKENS_ERROR - } else if (amountLessThanZero) { - amountError = NEGATIVE_ETH_ERROR - } - - return { amount: amountError } -} - -function getGasFeeErrorObject ({ - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, -}) { - let gasFeeError = null - - if (gasTotal && conversionRate) { - const insufficientFunds = !isBalanceSufficient({ - amount: '0x0', - amountConversionRate, - balance, - conversionRate, - gasTotal, - primaryCurrency, - }) - - if (insufficientFunds) { - gasFeeError = INSUFFICIENT_FUNDS_ERROR - } - } - - return { gasFee: gasFeeError } -} - -function calcTokenBalance ({ selectedToken, usersToken }) { - const { decimals } = selectedToken || {} - return calcTokenAmount(usersToken.balance.toString(), decimals).toString(16) -} - -function doesAmountErrorRequireUpdate ({ - balance, - gasTotal, - prevBalance, - prevGasTotal, - prevTokenBalance, - selectedToken, - tokenBalance, -}) { - const balanceHasChanged = balance !== prevBalance - const gasTotalHasChange = gasTotal !== prevGasTotal - const tokenBalanceHasChanged = selectedToken && tokenBalance !== prevTokenBalance - const amountErrorRequiresUpdate = balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged - - return amountErrorRequiresUpdate -} - -async function estimateGas ({ - selectedAddress, - selectedToken, - blockGasLimit, - to, - value, - data, - gasPrice, - estimateGasMethod, -}) { - const paramsForGasEstimate = { from: selectedAddress, value, gasPrice } - - // if recipient has no code, gas is 21k max: - if (!selectedToken && !data) { - const code = Boolean(to) && await global.eth.getCode(to) - // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' - const codeIsEmpty = !code || code === '0x' || code === '0x0' - if (codeIsEmpty) { - return SIMPLE_GAS_COST - } - } else if (selectedToken && !to) { - return BASE_TOKEN_GAS_COST - } - - if (selectedToken) { - paramsForGasEstimate.value = '0x0' - paramsForGasEstimate.data = generateTokenTransferData({ toAddress: to, amount: value, selectedToken }) - paramsForGasEstimate.to = selectedToken.address - } else { - if (data) { - paramsForGasEstimate.data = data - } - - 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 || '0x5208', 0.95, { - multiplicandBase: 16, - multiplierBase: 10, - roundDown: '0', - toNumericBase: 'hex', - })) - // run tx - return new Promise((resolve, reject) => { - return estimateGasMethod(paramsForGasEstimate, (err, estimatedGas) => { - if (err) { - const simulationFailed = ( - err.message.includes('Transaction execution error.') || - err.message.includes('gas required exceeds allowance or always failing transaction') - ) - if (simulationFailed) { - const estimateWithBuffer = addGasBuffer(paramsForGasEstimate.gas, blockGasLimit, 1.5) - return resolve(ethUtil.addHexPrefix(estimateWithBuffer)) - } else { - return reject(err) - } - } - const estimateWithBuffer = addGasBuffer(estimatedGas.toString(16), blockGasLimit, 1.5) - return resolve(ethUtil.addHexPrefix(estimateWithBuffer)) - }) - }) -} - -function addGasBuffer (initialGasLimitHex, blockGasLimitHex, bufferMultiplier = 1.5) { - const upperGasLimit = multiplyCurrencies(blockGasLimitHex, 0.9, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - numberOfDecimals: '0', - }) - const bufferedGasLimit = multiplyCurrencies(initialGasLimitHex, bufferMultiplier, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - numberOfDecimals: '0', - }) - - // if initialGasLimit is above blockGasLimit, dont modify it - if (conversionGreaterThan( - { value: initialGasLimitHex, fromNumericBase: 'hex' }, - { value: upperGasLimit, fromNumericBase: 'hex' }, - )) return initialGasLimitHex - // if bufferedGasLimit is below blockGasLimit, use bufferedGasLimit - if (conversionLessThan( - { value: bufferedGasLimit, fromNumericBase: 'hex' }, - { value: upperGasLimit, fromNumericBase: 'hex' }, - )) return bufferedGasLimit - // otherwise use blockGasLimit - return upperGasLimit -} - -function generateTokenTransferData ({ toAddress = '0x0', amount = '0x0', selectedToken }) { - if (!selectedToken) return - return TOKEN_TRANSFER_FUNCTION_SIGNATURE + Array.prototype.map.call( - abi.rawEncode(['address', 'uint256'], [toAddress, ethUtil.addHexPrefix(amount)]), - x => ('00' + x.toString(16)).slice(-2) - ).join('') -} - -function estimateGasPriceFromRecentBlocks (recentBlocks) { - // Return 1 gwei if no blocks have been observed: - if (!recentBlocks || recentBlocks.length === 0) { - return ONE_GWEI_IN_WEI_HEX - } - - const lowestPrices = recentBlocks.map((block) => { - if (!block.gasPrices || block.gasPrices.length < 1) { - return ONE_GWEI_IN_WEI_HEX - } - return block.gasPrices.reduce((currentLowest, next) => { - return parseInt(next, 16) < parseInt(currentLowest, 16) ? next : currentLowest - }) - }) - .sort((a, b) => parseInt(a, 16) > parseInt(b, 16) ? 1 : -1) - - return lowestPrices[Math.floor(lowestPrices.length / 2)] -} - -function getToAddressForGasUpdate (...addresses) { - return [...addresses, ''].find(str => str !== undefined && str !== null).toLowerCase() -} - -function removeLeadingZeroes (str) { - return str.replace(/^0*(?=\d)/, '') -} diff --git a/ui/app/components/send/tests/send-component.test.js b/ui/app/components/send/tests/send-component.test.js deleted file mode 100644 index 81955cc1d..000000000 --- a/ui/app/components/send/tests/send-component.test.js +++ /dev/null @@ -1,354 +0,0 @@ -import React from 'react' -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 = { - 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' }), - getGasFeeErrorObject: sinon.stub().returns({ gasFee: 'mockGasFeeError' }), - doesAmountErrorRequireUpdate: sinon.stub().callsFake(obj => obj.balance !== obj.prevBalance), -} - -const SendTransactionScreen = proxyquire('../send.component.js', { - './send.utils': utilsMethodStubs, -}).default - -sinon.spy(SendTransactionScreen.prototype, 'componentDidMount') -sinon.spy(SendTransactionScreen.prototype, 'updateGas') - -describe('Send Component', function () { - let wrapper - - beforeEach(() => { - wrapper = shallow(<SendTransactionScreen - amount={'mockAmount'} - amountConversionRate={'mockAmountConversionRate'} - blockGasLimit={'mockBlockGasLimit'} - conversionRate={10} - editingTransactionId={'mockEditingTransactionId'} - fetchBasicGasEstimates={propsMethodSpies.fetchBasicGasEstimates} - fetchGasEstimates={propsMethodSpies.fetchGasEstimates} - from={ { address: 'mockAddress', balance: 'mockBalance' } } - gasLimit={'mockGasLimit'} - gasPrice={'mockGasPrice'} - gasTotal={'mockGasTotal'} - history={{ mockProp: 'history-abc'}} - network={'3'} - primaryCurrency={'mockPrimaryCurrency'} - recentBlocks={['mockBlock']} - selectedAddress={'mockSelectedAddress'} - selectedToken={'mockSelectedToken'} - showHexData={true} - tokenBalance={'mockTokenBalance'} - tokenContract={'mockTokenContract'} - updateAndSetGasLimit={propsMethodSpies.updateAndSetGasLimit} - updateSendErrors={propsMethodSpies.updateSendErrors} - updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance} - resetSendState={propsMethodSpies.resetSendState} - />) - }) - - afterEach(() => { - SendTransactionScreen.prototype.componentDidMount.resetHistory() - SendTransactionScreen.prototype.updateGas.resetHistory() - utilsMethodStubs.doesAmountErrorRequireUpdate.resetHistory() - utilsMethodStubs.getAmountErrorObject.resetHistory() - utilsMethodStubs.getGasFeeErrorObject.resetHistory() - propsMethodSpies.fetchBasicGasEstimates.resetHistory() - propsMethodSpies.updateAndSetGasLimit.resetHistory() - propsMethodSpies.updateSendErrors.resetHistory() - propsMethodSpies.updateSendTokenBalance.resetHistory() - }) - - it('should call componentDidMount', () => { - assert(SendTransactionScreen.prototype.componentDidMount.calledOnce) - }) - - 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().componentDidMount() - await timeout(250) - assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1) - }) - }) - - describe('componentWillUnmount', () => { - it('should call this.props.resetSendState', () => { - propsMethodSpies.resetSendState.resetHistory() - assert.equal(propsMethodSpies.resetSendState.callCount, 0) - wrapper.instance().componentWillUnmount() - assert.equal(propsMethodSpies.resetSendState.callCount, 1) - }) - }) - - describe('componentDidUpdate', () => { - it('should call doesAmountErrorRequireUpdate with the expected params', () => { - utilsMethodStubs.getAmountErrorObject.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { - balance: '', - }, - }) - assert(utilsMethodStubs.doesAmountErrorRequireUpdate.calledOnce) - assert.deepEqual( - utilsMethodStubs.doesAmountErrorRequireUpdate.getCall(0).args[0], - { - balance: 'mockBalance', - gasTotal: 'mockGasTotal', - prevBalance: '', - prevGasTotal: undefined, - prevTokenBalance: undefined, - selectedToken: 'mockSelectedToken', - tokenBalance: 'mockTokenBalance', - } - ) - }) - - it('should not call getAmountErrorObject if doesAmountErrorRequireUpdate returns false', () => { - utilsMethodStubs.getAmountErrorObject.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { - balance: 'mockBalance', - }, - }) - assert.equal(utilsMethodStubs.getAmountErrorObject.callCount, 0) - }) - - it('should call getAmountErrorObject if doesAmountErrorRequireUpdate returns true', () => { - utilsMethodStubs.getAmountErrorObject.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }) - assert.equal(utilsMethodStubs.getAmountErrorObject.callCount, 1) - assert.deepEqual( - utilsMethodStubs.getAmountErrorObject.getCall(0).args[0], - { - amount: 'mockAmount', - amountConversionRate: 'mockAmountConversionRate', - balance: 'mockBalance', - conversionRate: 10, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - selectedToken: 'mockSelectedToken', - tokenBalance: 'mockTokenBalance', - } - ) - }) - - it('should call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true and selectedToken is truthy', () => { - utilsMethodStubs.getGasFeeErrorObject.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }) - assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 1) - assert.deepEqual( - utilsMethodStubs.getGasFeeErrorObject.getCall(0).args[0], - { - amountConversionRate: 'mockAmountConversionRate', - balance: 'mockBalance', - conversionRate: 10, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - selectedToken: 'mockSelectedToken', - } - ) - }) - - it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns false', () => { - utilsMethodStubs.getGasFeeErrorObject.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { address: 'mockAddress', balance: 'mockBalance' }, - }) - assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0) - }) - - it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true but selectedToken is falsy', () => { - utilsMethodStubs.getGasFeeErrorObject.resetHistory() - wrapper.setProps({ selectedToken: null }) - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }) - assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0) - }) - - it('should call updateSendErrors with the expected params if selectedToken is falsy', () => { - propsMethodSpies.updateSendErrors.resetHistory() - wrapper.setProps({ selectedToken: null }) - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }) - assert.equal(propsMethodSpies.updateSendErrors.callCount, 1) - assert.deepEqual( - propsMethodSpies.updateSendErrors.getCall(0).args[0], - { amount: 'mockAmountError', gasFee: null } - ) - }) - - it('should call updateSendErrors with the expected params if selectedToken is truthy', () => { - propsMethodSpies.updateSendErrors.resetHistory() - wrapper.setProps({ selectedToken: 'someToken' }) - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }) - assert.equal(propsMethodSpies.updateSendErrors.callCount, 1) - assert.deepEqual( - propsMethodSpies.updateSendErrors.getCall(0).args[0], - { amount: 'mockAmountError', gasFee: 'mockGasFeeError' } - ) - }) - - it('should not call updateSendTokenBalance or this.updateGas if network === prevNetwork', () => { - SendTransactionScreen.prototype.updateGas.resetHistory() - propsMethodSpies.updateSendTokenBalance.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - network: '3', - }) - assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 0) - assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0) - }) - - it('should not call updateSendTokenBalance or this.updateGas if network === loading', () => { - wrapper.setProps({ network: 'loading' }) - SendTransactionScreen.prototype.updateGas.resetHistory() - propsMethodSpies.updateSendTokenBalance.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - network: '3', - }) - assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 0) - assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0) - }) - - it('should call updateSendTokenBalance and this.updateGas with the correct params', () => { - SendTransactionScreen.prototype.updateGas.resetHistory() - propsMethodSpies.updateSendTokenBalance.resetHistory() - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - network: '2', - }) - assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 1) - assert.deepEqual( - propsMethodSpies.updateSendTokenBalance.getCall(0).args[0], - { - selectedToken: 'mockSelectedToken', - tokenContract: 'mockTokenContract', - address: 'mockAddress', - } - ) - assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1) - assert.deepEqual( - SendTransactionScreen.prototype.updateGas.getCall(0).args, - [] - ) - }) - }) - - describe('updateGas', () => { - it('should call updateAndSetGasLimit with the correct params if no to prop is passed', () => { - propsMethodSpies.updateAndSetGasLimit.resetHistory() - wrapper.instance().updateGas() - assert.equal(propsMethodSpies.updateAndSetGasLimit.callCount, 1) - assert.deepEqual( - propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0], - { - blockGasLimit: 'mockBlockGasLimit', - editingTransactionId: 'mockEditingTransactionId', - gasLimit: 'mockGasLimit', - gasPrice: 'mockGasPrice', - recentBlocks: ['mockBlock'], - selectedAddress: 'mockSelectedAddress', - selectedToken: 'mockSelectedToken', - to: '', - value: 'mockAmount', - data: undefined, - } - ) - }) - - 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.updateAndSetGasLimit.getCall(0).args[0].to, - 'someaddress', - ) - }) - - it('should call updateAndSetGasLimit with to set to lowercase if passed', () => { - propsMethodSpies.updateAndSetGasLimit.resetHistory() - wrapper.instance().updateGas({ to: '0xABC' }) - assert.equal(propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0].to, '0xabc') - }) - }) - - describe('render', () => { - it('should render a page-container class', () => { - assert.equal(wrapper.find('.page-container').length, 1) - }) - - it('should render SendHeader, SendContent and SendFooter', () => { - assert.equal(wrapper.find(SendHeader).length, 1) - assert.equal(wrapper.find(SendContent).length, 1) - assert.equal(wrapper.find(SendFooter).length, 1) - }) - - it('should pass the history prop to SendHeader and SendFooter', () => { - assert.deepEqual( - wrapper.find(SendFooter).props(), - { - history: { mockProp: 'history-abc' }, - } - ) - }) - - it('should pass showHexData to SendContent', () => { - assert.equal(wrapper.find(SendContent).props().showHexData, true) - }) - }) -}) diff --git a/ui/app/components/send/tests/send-container.test.js b/ui/app/components/send/tests/send-container.test.js deleted file mode 100644 index 19b6563e6..000000000 --- a/ui/app/components/send/tests/send-container.test.js +++ /dev/null @@ -1,174 +0,0 @@ -import assert from 'assert' -import proxyquire from 'proxyquire' -import sinon from 'sinon' - -let mapStateToProps -let mapDispatchToProps - -const actionSpies = { - updateSendTokenBalance: sinon.spy(), - updateGasData: sinon.spy(), - setGasTotal: sinon.spy(), -} -const duckActionSpies = { - updateSendErrors: sinon.spy(), - resetSendState: sinon.spy(), -} - -proxyquire('../send.container.js', { - 'react-redux': { - connect: (ms, md) => { - mapStateToProps = ms - mapDispatchToProps = md - return () => ({}) - }, - }, - 'react-router-dom': { withRouter: () => {} }, - 'recompose': { compose: (arg1, arg2) => () => arg2() }, - './send.selectors': { - getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`, - getBlockGasLimit: (s) => `mockBlockGasLimit:${s}`, - getConversionRate: (s) => `mockConversionRate:${s}`, - getCurrentNetwork: (s) => `mockNetwork:${s}`, - getGasLimit: (s) => `mockGasLimit:${s}`, - getGasPrice: (s) => `mockGasPrice:${s}`, - getGasTotal: (s) => `mockGasTotal:${s}`, - getPrimaryCurrency: (s) => `mockPrimaryCurrency:${s}`, - getRecentBlocks: (s) => `mockRecentBlocks:${s}`, - getSelectedAddress: (s) => `mockSelectedAddress:${s}`, - getSelectedToken: (s) => `mockSelectedToken:${s}`, - getSelectedTokenContract: (s) => `mockTokenContract:${s}`, - getSelectedTokenToFiatRate: (s) => `mockTokenToFiatRate:${s}`, - getSendHexDataFeatureFlagState: (s) => `mockSendHexDataFeatureFlagState:${s}`, - getSendAmount: (s) => `mockAmount:${s}`, - getSendTo: (s) => `mockTo:${s}`, - getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, - getSendFromObject: (s) => `mockFrom:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - getQrCodeData: (s) => `mockQrCodeData:${s}`, - }, - '../../actions': actionSpies, - '../../ducks/send.duck': duckActionSpies, - './send.utils.js': { - calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice, - }, -}) - -describe('send container', () => { - - describe('mapStateToProps()', () => { - - it('should map the correct properties to props', () => { - assert.deepEqual(mapStateToProps('mockState'), { - amount: 'mockAmount:mockState', - amountConversionRate: 'mockAmountConversionRate:mockState', - blockGasLimit: 'mockBlockGasLimit:mockState', - conversionRate: 'mockConversionRate:mockState', - editingTransactionId: 'mockEditingTransactionId:mockState', - from: 'mockFrom:mockState', - gasLimit: 'mockGasLimit:mockState', - gasPrice: 'mockGasPrice:mockState', - gasTotal: 'mockGasTotal:mockState', - network: 'mockNetwork:mockState', - primaryCurrency: 'mockPrimaryCurrency:mockState', - recentBlocks: 'mockRecentBlocks:mockState', - selectedAddress: 'mockSelectedAddress:mockState', - selectedToken: 'mockSelectedToken:mockState', - showHexData: 'mockSendHexDataFeatureFlagState:mockState', - to: 'mockTo:mockState', - tokenBalance: 'mockTokenBalance:mockState', - tokenContract: 'mockTokenContract:mockState', - tokenToFiatRate: 'mockTokenToFiatRate:mockState', - qrCodeData: 'mockQrCodeData:mockState', - }) - }) - - }) - - describe('mapDispatchToProps()', () => { - let dispatchSpy - let mapDispatchToPropsObject - - beforeEach(() => { - dispatchSpy = sinon.spy() - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) - }) - - describe('updateAndSetGasLimit()', () => { - const mockProps = { - blockGasLimit: 'mockBlockGasLimit', - editingTransactionId: '0x2', - gasLimit: '0x3', - gasPrice: '0x4', - recentBlocks: ['mockBlock'], - selectedAddress: '0x4', - selectedToken: { address: '0x1' }, - to: 'mockTo', - value: 'mockValue', - data: undefined, - } - - it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => { - mapDispatchToPropsObject.updateAndSetGasLimit(mockProps) - assert(dispatchSpy.calledOnce) - assert.equal( - actionSpies.setGasTotal.getCall(0).args[0], - '0x30x4' - ) - }) - - it('should dispatch an updateGasData action when editingTransactionId is falsy', () => { - 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], - { gasPrice, selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } - ) - }) - }) - - describe('updateSendTokenBalance()', () => { - const mockProps = { - address: '0x10', - tokenContract: '0x00a', - selectedToken: {address: '0x1'}, - } - - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendTokenBalance(Object.assign({}, mockProps)) - assert(dispatchSpy.calledOnce) - assert.deepEqual( - actionSpies.updateSendTokenBalance.getCall(0).args[0], - mockProps - ) - }) - }) - - describe('updateSendErrors()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendErrors('mockError') - assert(dispatchSpy.calledOnce) - assert.equal( - duckActionSpies.updateSendErrors.getCall(0).args[0], - 'mockError' - ) - }) - }) - - describe('resetSendState()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.resetSendState() - assert(dispatchSpy.calledOnce) - assert.equal( - duckActionSpies.resetSendState.getCall(0).args.length, - 0 - ) - }) - }) - - }) - -}) diff --git a/ui/app/components/send/tests/send-utils.test.js b/ui/app/components/send/tests/send-utils.test.js deleted file mode 100644 index 48fa09392..000000000 --- a/ui/app/components/send/tests/send-utils.test.js +++ /dev/null @@ -1,527 +0,0 @@ -import assert from 'assert' -import sinon from 'sinon' -import proxyquire from 'proxyquire' -import { - BASE_TOKEN_GAS_COST, - ONE_GWEI_IN_WEI_HEX, - SIMPLE_GAS_COST, -} from '../send.constants' -const { - addCurrencies, - subtractCurrencies, -} = require('../../../conversion-util') - -const { - INSUFFICIENT_FUNDS_ERROR, - INSUFFICIENT_TOKENS_ERROR, -} = require('../send.constants') - -const stubs = { - addCurrencies: sinon.stub().callsFake((a, b, obj) => { - if (String(a).match(/^0x.+/)) a = Number(String(a).slice(2)) - if (String(b).match(/^0x.+/)) b = Number(String(b).slice(2)) - return a + b - }), - conversionUtil: sinon.stub().callsFake((val, obj) => parseInt(val, 16)), - conversionGTE: sinon.stub().callsFake((obj1, obj2) => obj1.value >= obj2.value), - multiplyCurrencies: sinon.stub().callsFake((a, b) => `${a}x${b}`), - calcTokenAmount: sinon.stub().callsFake((a, d) => 'calc:' + a + d), - rawEncode: sinon.stub().returns([16, 1100]), - conversionGreaterThan: sinon.stub().callsFake((obj1, obj2) => obj1.value > obj2.value), - conversionLessThan: sinon.stub().callsFake((obj1, obj2) => obj1.value < obj2.value), -} - -const sendUtils = proxyquire('../send.utils.js', { - '../../conversion-util': { - addCurrencies: stubs.addCurrencies, - conversionUtil: stubs.conversionUtil, - conversionGTE: stubs.conversionGTE, - multiplyCurrencies: stubs.multiplyCurrencies, - conversionGreaterThan: stubs.conversionGreaterThan, - conversionLessThan: stubs.conversionLessThan, - }, - '../../token-util': { calcTokenAmount: stubs.calcTokenAmount }, - 'ethereumjs-abi': { - rawEncode: stubs.rawEncode, - }, -}) - -const { - calcGasTotal, - estimateGas, - doesAmountErrorRequireUpdate, - estimateGasPriceFromRecentBlocks, - generateTokenTransferData, - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - calcTokenBalance, - isBalanceSufficient, - isTokenBalanceSufficient, - removeLeadingZeroes, -} = sendUtils - -describe('send utils', () => { - - describe('calcGasTotal()', () => { - it('should call multiplyCurrencies with the correct params and return the multiplyCurrencies return', () => { - const result = calcGasTotal(12, 15) - assert.equal(result, '12x15') - const call_ = stubs.multiplyCurrencies.getCall(0).args - assert.deepEqual( - call_, - [12, 15, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, - } ] - ) - }) - }) - - describe('doesAmountErrorRequireUpdate()', () => { - const config = { - 'should return true if balances are different': { - balance: 0, - prevBalance: 1, - expectedResult: true, - }, - 'should return true if gasTotals are different': { - gasTotal: 0, - prevGasTotal: 1, - expectedResult: true, - }, - 'should return true if token balances are different': { - tokenBalance: 0, - prevTokenBalance: 1, - selectedToken: 'someToken', - expectedResult: true, - }, - 'should return false if they are all the same': { - balance: 1, - prevBalance: 1, - gasTotal: 1, - prevGasTotal: 1, - tokenBalance: 1, - prevTokenBalance: 1, - selectedToken: 'someToken', - expectedResult: false, - }, - } - Object.entries(config).map(([description, obj]) => { - it(description, () => { - assert.equal(doesAmountErrorRequireUpdate(obj), obj.expectedResult) - }) - }) - - }) - - describe('generateTokenTransferData()', () => { - it('should return undefined if not passed a selected token', () => { - assert.equal(generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: false}), undefined) - }) - - it('should call abi.rawEncode with the correct params', () => { - stubs.rawEncode.resetHistory() - generateTokenTransferData({ toAddress: 'mockAddress', amount: 'ab', selectedToken: true}) - assert.deepEqual( - stubs.rawEncode.getCall(0).args, - [['address', 'uint256'], ['mockAddress', '0xab']] - ) - }) - - it('should return encoded token transfer data', () => { - assert.equal( - generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: true}), - '0xa9059cbb104c' - ) - }) - }) - - describe('getAmountErrorObject()', () => { - const config = { - 'should return insufficientFunds error if isBalanceSufficient returns false': { - amount: 15, - amountConversionRate: 2, - balance: 1, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - expectedResult: { amount: INSUFFICIENT_FUNDS_ERROR }, - }, - 'should not return insufficientFunds error if selectedToken is truthy': { - amount: '0x0', - amountConversionRate: 2, - balance: 1, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - selectedToken: { symbole: 'DEF', decimals: 0 }, - decimals: 0, - tokenBalance: 'sometokenbalance', - expectedResult: { amount: null }, - }, - 'should return insufficientTokens error if token is selected and isTokenBalanceSufficient returns false': { - amount: '0x10', - amountConversionRate: 2, - balance: 100, - conversionRate: 3, - decimals: 10, - gasTotal: 17, - primaryCurrency: 'ABC', - selectedToken: 'someToken', - tokenBalance: 123, - expectedResult: { amount: INSUFFICIENT_TOKENS_ERROR }, - }, - } - Object.entries(config).map(([description, obj]) => { - it(description, () => { - assert.deepEqual(getAmountErrorObject(obj), obj.expectedResult) - }) - }) - }) - - describe('getGasFeeErrorObject()', () => { - const config = { - 'should return insufficientFunds error if isBalanceSufficient returns false': { - amountConversionRate: 2, - balance: 16, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - expectedResult: { gasFee: INSUFFICIENT_FUNDS_ERROR }, - }, - 'should return null error if isBalanceSufficient returns true': { - amountConversionRate: 2, - balance: 16, - conversionRate: 3, - gasTotal: 15, - primaryCurrency: 'ABC', - expectedResult: { gasFee: null }, - }, - } - Object.entries(config).map(([description, obj]) => { - it(description, () => { - assert.deepEqual(getGasFeeErrorObject(obj), obj.expectedResult) - }) - }) - }) - - describe('calcTokenBalance()', () => { - it('should return the calculated token blance', () => { - assert.equal(calcTokenBalance({ - selectedToken: { - decimals: 11, - }, - usersToken: { - balance: 20, - }, - }), 'calc:2011') - }) - }) - - describe('isBalanceSufficient()', () => { - it('should correctly call addCurrencies and return the result of calling conversionGTE', () => { - stubs.conversionGTE.resetHistory() - const result = isBalanceSufficient({ - amount: 15, - amountConversionRate: 2, - balance: 100, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - }) - assert.deepEqual( - stubs.addCurrencies.getCall(0).args, - [ - 15, 17, { - aBase: 16, - bBase: 16, - toNumericBase: 'hex', - }, - ] - ) - assert.deepEqual( - stubs.conversionGTE.getCall(0).args, - [ - { - value: 100, - fromNumericBase: 'hex', - fromCurrency: 'ABC', - conversionRate: 3, - }, - { - value: 32, - fromNumericBase: 'hex', - conversionRate: 2, - fromCurrency: 'ABC', - }, - ] - ) - - assert.equal(result, true) - }) - }) - - describe('isTokenBalanceSufficient()', () => { - it('should correctly call conversionUtil and return the result of calling conversionGTE', () => { - stubs.conversionGTE.resetHistory() - stubs.conversionUtil.resetHistory() - const result = isTokenBalanceSufficient({ - amount: '0x10', - tokenBalance: 123, - decimals: 10, - }) - assert.deepEqual( - stubs.conversionUtil.getCall(0).args, - [ - '0x10', { - fromNumericBase: 'hex', - }, - ] - ) - assert.deepEqual( - stubs.conversionGTE.getCall(0).args, - [ - { - value: 123, - fromNumericBase: 'hex', - }, - { - value: 'calc:1610', - }, - ] - ) - - assert.equal(result, false) - }) - }) - - describe('estimateGas', () => { - const baseMockParams = { - blockGasLimit: '0x64', - selectedAddress: 'mockAddress', - to: '0xisContract', - estimateGasMethod: sinon.stub().callsFake( - ({to}, cb) => { - const err = typeof to === 'string' && to.match(/willFailBecauseOf:/) - ? new Error(to.match(/:(.+)$/)[1]) - : null - const result = { toString: (n) => `0xabc${n}` } - return cb(err, result) - } - ), - } - const baseExpectedCall = { - from: 'mockAddress', - gas: '0x64x0.95', - to: '0xisContract', - value: '0xff', - } - - beforeEach(() => { - global.eth = { - getCode: sinon.stub().callsFake( - (address) => Promise.resolve(address.match(/isContract/) ? 'not-0x' : '0x') - ), - } - }) - - afterEach(() => { - baseMockParams.estimateGasMethod.resetHistory() - global.eth.getCode.resetHistory() - }) - - it('should call ethQuery.estimateGas with the expected params', async () => { - const result = await sendUtils.estimateGas(baseMockParams) - assert.equal(baseMockParams.estimateGasMethod.callCount, 1) - assert.deepEqual( - baseMockParams.estimateGasMethod.getCall(0).args[0], - Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall) - ) - assert.equal(result, '0xabc16') - }) - - it('should call ethQuery.estimateGas with the expected params when initialGasLimitHex is lower than the upperGasLimit', async () => { - const result = await estimateGas(Object.assign({}, baseMockParams, { blockGasLimit: '0xbcd' })) - assert.equal(baseMockParams.estimateGasMethod.callCount, 1) - assert.deepEqual( - baseMockParams.estimateGasMethod.getCall(0).args[0], - Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall, { gas: '0xbcdx0.95' }) - ) - assert.equal(result, '0xabc16x1.5') - }) - - it('should call ethQuery.estimateGas with a value of 0x0 and the expected data and to if passed a selectedToken', async () => { - const result = await estimateGas(Object.assign({ data: 'mockData', selectedToken: { address: 'mockAddress' } }, baseMockParams)) - assert.equal(baseMockParams.estimateGasMethod.callCount, 1) - assert.deepEqual( - baseMockParams.estimateGasMethod.getCall(0).args[0], - Object.assign({}, baseExpectedCall, { - gasPrice: undefined, - value: '0x0', - data: '0xa9059cbb104c', - to: 'mockAddress', - }) - ) - assert.equal(result, '0xabc16') - }) - - it('should call ethQuery.estimateGas without a recipient if the recipient is empty and data passed', async () => { - const data = 'mockData' - const to = '' - const result = await estimateGas({...baseMockParams, data, to}) - assert.equal(baseMockParams.estimateGasMethod.callCount, 1) - assert.deepEqual( - baseMockParams.estimateGasMethod.getCall(0).args[0], - { gasPrice: undefined, value: '0xff', data, from: baseExpectedCall.from, gas: baseExpectedCall.gas}, - ) - assert.equal(result, '0xabc16') - }) - - it(`should return ${SIMPLE_GAS_COST} if ethQuery.getCode does not return '0x'`, async () => { - assert.equal(baseMockParams.estimateGasMethod.callCount, 0) - const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123' })) - assert.equal(result, SIMPLE_GAS_COST) - }) - - it(`should return ${SIMPLE_GAS_COST} if not passed a selectedToken or truthy to address`, async () => { - assert.equal(baseMockParams.estimateGasMethod.callCount, 0) - const result = await estimateGas(Object.assign({}, baseMockParams, { to: null })) - assert.equal(result, SIMPLE_GAS_COST) - }) - - it(`should not return ${SIMPLE_GAS_COST} if passed a selectedToken`, async () => { - assert.equal(baseMockParams.estimateGasMethod.callCount, 0) - const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123', selectedToken: { address: '' } })) - assert.notEqual(result, SIMPLE_GAS_COST) - }) - - it(`should return ${BASE_TOKEN_GAS_COST} if passed a selectedToken but no to address`, async () => { - const result = await estimateGas(Object.assign({}, baseMockParams, { to: null, selectedToken: { address: '' } })) - assert.equal(result, BASE_TOKEN_GAS_COST) - }) - - it(`should return the adjusted blockGasLimit if it fails with a 'Transaction execution error.'`, async () => { - const result = await estimateGas(Object.assign({}, baseMockParams, { - to: 'isContract willFailBecauseOf:Transaction execution error.', - })) - assert.equal(result, '0x64x0.95') - }) - - it(`should return the adjusted blockGasLimit if it fails with a 'gas required exceeds allowance or always failing transaction.'`, async () => { - const result = await estimateGas(Object.assign({}, baseMockParams, { - to: 'isContract willFailBecauseOf:gas required exceeds allowance or always failing transaction.', - })) - assert.equal(result, '0x64x0.95') - }) - - it(`should reject other errors`, async () => { - try { - await estimateGas(Object.assign({}, baseMockParams, { - to: 'isContract willFailBecauseOf:some other error', - })) - } catch (err) { - assert.equal(err.message, 'some other error') - } - }) - }) - - describe('estimateGasPriceFromRecentBlocks', () => { - const ONE_GWEI_IN_WEI_HEX_PLUS_ONE = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', { - aBase: 16, - bBase: 16, - toNumericBase: 'hex', - }) - const ONE_GWEI_IN_WEI_HEX_PLUS_TWO = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x2', { - aBase: 16, - bBase: 16, - toNumericBase: 'hex', - }) - const ONE_GWEI_IN_WEI_HEX_MINUS_ONE = subtractCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', { - aBase: 16, - bBase: 16, - toNumericBase: 'hex', - }) - - it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is falsy`, () => { - assert.equal(estimateGasPriceFromRecentBlocks(), ONE_GWEI_IN_WEI_HEX) - }) - - it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is empty`, () => { - assert.equal(estimateGasPriceFromRecentBlocks([]), ONE_GWEI_IN_WEI_HEX) - }) - - it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has no gas prices`, () => { - const mockRecentBlocks = [ - { gasPrices: null }, - { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, - { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, - ] - assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX) - }) - - it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has empty gas prices`, () => { - const mockRecentBlocks = [ - { gasPrices: [] }, - { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, - { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, - ] - assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX) - }) - - it(`should return the middle value of all blocks lowest prices`, () => { - const mockRecentBlocks = [ - { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_TWO ] }, - { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, - { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, - ] - assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX_PLUS_ONE) - }) - - it(`should work if a block has multiple gas prices`, () => { - const mockRecentBlocks = [ - { gasPrices: [ '0x1', '0x2', '0x3', '0x4', '0x5' ] }, - { gasPrices: [ '0x101', '0x100', '0x103', '0x104', '0x102' ] }, - { gasPrices: [ '0x150', '0x50', '0x100', '0x200', '0x5' ] }, - ] - assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), '0x5') - }) - }) - - describe('getToAddressForGasUpdate()', () => { - it('should return empty string if all params are undefined or null', () => { - assert.equal(getToAddressForGasUpdate(undefined, null), '') - }) - - it('should return the first string that is not defined or null in lower case', () => { - assert.equal(getToAddressForGasUpdate('A', null), 'a') - assert.equal(getToAddressForGasUpdate(undefined, 'B'), 'b') - }) - }) - - describe('removeLeadingZeroes()', () => { - it('should remove leading zeroes from int when user types', () => { - assert.equal(removeLeadingZeroes('0'), '0') - assert.equal(removeLeadingZeroes('1'), '1') - assert.equal(removeLeadingZeroes('00'), '0') - assert.equal(removeLeadingZeroes('01'), '1') - }) - - it('should remove leading zeroes from int when user copy/paste', () => { - assert.equal(removeLeadingZeroes('001'), '1') - }) - - it('should remove leading zeroes from float when user types', () => { - assert.equal(removeLeadingZeroes('0.'), '0.') - assert.equal(removeLeadingZeroes('0.0'), '0.0') - assert.equal(removeLeadingZeroes('0.00'), '0.00') - assert.equal(removeLeadingZeroes('0.001'), '0.001') - assert.equal(removeLeadingZeroes('0.10'), '0.10') - }) - - it('should remove leading zeroes from float when user copy/paste', () => { - assert.equal(removeLeadingZeroes('00.1'), '0.1') - }) - }) -}) diff --git a/ui/app/components/send/to-autocomplete.component.js b/ui/app/components/send/to-autocomplete.component.js deleted file mode 100644 index 9e270db75..000000000 --- a/ui/app/components/send/to-autocomplete.component.js +++ /dev/null @@ -1,141 +0,0 @@ -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' - - -export default class ToAutoComplete extends Component { - - static propTypes = { - dropdownOpen: PropTypes.bool, - openDropdown: PropTypes.func, - closeDropdown: PropTypes.func, - onChange: PropTypes.func, - to: PropTypes.string, - accounts: PropTypes.array, - inError: PropTypes.bool, - } - - static contextTypes = { - t: PropTypes.func, - } - - state = { - accountsToRender: [], - } - - getListItemIcon (listItemAddress, toAddress) { - return toAddress && listItemAddress === toAddress - ? <i className={'fa fa-check fa-lg'} - style={{ - color: '#02c9b1', - }} - /> - : null - } - - renderDropdown () { - const { - closeDropdown, - onChange, - to, - } = this.props - const {accountsToRender} = this.state - - if (!accountsToRender.length) { - return null - } - - return ( - <div> - <div className={'send-v2__from-dropdown__close-area'} onClick={closeDropdown} /> - <div className={'send-v2__from-dropdown__list'}> - {accountsToRender.map((account, i) => ( - <AccountListItem - key={i} - account={account} - className={'account-list-item__dropdown'} - handleClick={() => { - onChange(account.address) - closeDropdown() - }} - icon={this.getListItemIcon(account.address, to)} - displayBalance={false} - displayAddress={true} - /> - ))} - </div> - </div> - ) - } - - handleInputEvent (event = {}, cb) { - const { - to, - accounts, - closeDropdown, - openDropdown, - } = this.props - - const matchingAccounts = accounts.filter(({address}) => address.match(to || '')) - const matches = matchingAccounts.length - - if (!matches || matchingAccounts[0].address === to) { - this.setState({accountsToRender: []}) - event.target && event.target.select() - closeDropdown() - } else { - this.setState({accountsToRender: matchingAccounts}) - openDropdown() - } - cb && cb(event.target.value) - } - - componentDidUpdate (nextProps) { - if (this.props.to !== nextProps.to) { - this.handleInputEvent() - } - } - - render () { - const { - to, - dropdownOpen, - onChange, - inError, - } = this.props - - return ( - <div className={'send-v2__to-autocomplete'}> - <input - className={classnames('send-v2__to-autocomplete__input', { - 'send-v2__error-border': inError, - })} - placeholder={this.context.t('recipientAddress')} - value={to} - onChange={event => onChange(event.target.value)} - onFocus={event => this.handleInputEvent(event)} - style={{ - borderColor: inError ? 'red' : null, - }} - /> - { - to - ? null - : <i className={'fa fa-caret-down fa-lg send-v2__to-autocomplete__down-caret'} - onClick={() => this.handleInputEvent()} - style={{ - style: {color: '#dedede'}, - }} - /> - } - { - dropdownOpen - ? this.renderDropdown() - : null - } - </div> - ) - } - -} diff --git a/ui/app/components/send/to-autocomplete/to-autocomplete.js b/ui/app/components/send/to-autocomplete/to-autocomplete.js deleted file mode 100644 index 39d15dfa7..000000000 --- a/ui/app/components/send/to-autocomplete/to-autocomplete.js +++ /dev/null @@ -1,129 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -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 - -ToAutoComplete.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(ToAutoComplete) - - -inherits(ToAutoComplete, Component) -function ToAutoComplete () { - Component.call(this) - - this.state = { accountsToRender: [] } -} - -ToAutoComplete.prototype.getListItemIcon = function (listItemAddress, toAddress) { - const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } }) - - return toAddress && listItemAddress === toAddress - ? listItemIcon - : null -} - -ToAutoComplete.prototype.renderDropdown = function () { - const { - closeDropdown, - onChange, - to, - } = this.props - const { accountsToRender } = this.state - - return accountsToRender.length && h('div', {}, [ - - h('div.send-v2__from-dropdown__close-area', { - onClick: closeDropdown, - }), - - h('div.send-v2__from-dropdown__list', {}, [ - - ...accountsToRender.map(account => h(AccountListItem, { - account, - className: 'account-list-item__dropdown', - handleClick: () => { - onChange(checksumAddress(account.address)) - closeDropdown() - }, - icon: this.getListItemIcon(account.address, to), - displayBalance: false, - displayAddress: true, - })), - - ]), - - ]) -} - -ToAutoComplete.prototype.handleInputEvent = function (event = {}, cb) { - const { - to, - accounts, - closeDropdown, - openDropdown, - } = this.props - - const matchingAccounts = accounts.filter(({ address }) => address.match(to || '')) - const matches = matchingAccounts.length - - if (!matches || matchingAccounts[0].address === to) { - this.setState({ accountsToRender: [] }) - event.target && event.target.select() - closeDropdown() - } else { - this.setState({ accountsToRender: matchingAccounts }) - openDropdown() - } - cb && cb(event.target.value) -} - -ToAutoComplete.prototype.componentDidUpdate = function (nextProps, nextState) { - if (this.props.to !== nextProps.to) { - this.handleInputEvent() - } -} - -ToAutoComplete.prototype.render = function () { - const { - to, - dropdownOpen, - onChange, - inError, - qrScanner, - } = this.props - - return h('div.send-v2__to-autocomplete', {}, [ - - h(`input.send-v2__to-autocomplete__input${qrScanner ? '.with-qr' : ''}`, { - placeholder: this.context.t('recipientAddress'), - className: inError ? `send-v2__error-border` : '', - value: to, - onChange: event => onChange(event.target.value), - onFocus: event => this.handleInputEvent(event), - style: { - borderColor: inError ? 'red' : null, - }, - }), - qrScanner && h(Tooltip, { - title: this.context.t('scanQrCode'), - position: 'bottom', - }, h(`i.fa.fa-qrcode.fa-lg.send-v2__to-autocomplete__qr-code`, { - style: { color: '#33333' }, - onClick: () => this.props.scanQrCode(), - })), - !to && h(`i.fa.fa-caret-down.fa-lg.send-v2__to-autocomplete__down-caret`, { - style: { color: '#dedede' }, - onClick: () => this.handleInputEvent(), - }), - - dropdownOpen && this.renderDropdown(), - - ]) -} diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js deleted file mode 100644 index 7d3436dc3..000000000 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js +++ /dev/null @@ -1,186 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import Identicon from '../identicon' -import Tooltip from '../tooltip-v2' -import copyToClipboard from 'copy-to-clipboard' -import { DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT } from './sender-to-recipient.constants' -import { checksumAddress } from '../../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 { - static propTypes = { - senderName: PropTypes.string, - senderAddress: PropTypes.string, - recipientName: PropTypes.string, - recipientAddress: PropTypes.string, - t: PropTypes.func, - variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT]), - addressOnly: PropTypes.bool, - assetImage: PropTypes.string, - onRecipientClick: PropTypes.func, - onSenderClick: PropTypes.func, - } - - static defaultProps = { - variant: DEFAULT_VARIANT, - } - - static contextTypes = { - t: PropTypes.func, - } - - state = { - senderAddressCopied: false, - recipientAddressCopied: false, - } - - renderSenderIdenticon () { - return !this.props.addressOnly && ( - <div className="sender-to-recipient__sender-icon"> - <Identicon - address={checksumAddress(this.props.senderAddress)} - diameter={24} - /> - </div> - ) - } - - renderSenderAddress () { - const { t } = this.context - const { senderName, senderAddress, addressOnly } = this.props - const checksummedSenderAddress = checksumAddress(senderAddress) - - return ( - <Tooltip - position="bottom" - title={this.state.senderAddressCopied ? t('copiedExclamation') : t('copyAddress')} - wrapperClassName="sender-to-recipient__tooltip-wrapper" - containerClassName="sender-to-recipient__tooltip-container" - onHidden={() => this.setState({ senderAddressCopied: false })} - > - <div className="sender-to-recipient__name"> - { addressOnly ? `${t('from')}: ${checksummedSenderAddress}` : senderName } - </div> - </Tooltip> - ) - } - - renderRecipientIdenticon () { - const { recipientAddress, assetImage } = this.props - const checksummedRecipientAddress = checksumAddress(recipientAddress) - - return !this.props.addressOnly && ( - <div className="sender-to-recipient__sender-icon"> - <Identicon - address={checksummedRecipientAddress} - diameter={24} - image={assetImage} - /> - </div> - ) - } - - renderRecipientWithAddress () { - const { t } = this.context - const { recipientName, recipientAddress, addressOnly, onRecipientClick } = this.props - const checksummedRecipientAddress = checksumAddress(recipientAddress) - - return ( - <div - className="sender-to-recipient__party sender-to-recipient__party--recipient sender-to-recipient__party--recipient-with-address" - onClick={() => { - this.setState({ recipientAddressCopied: true }) - copyToClipboard(checksummedRecipientAddress) - if (onRecipientClick) { - onRecipientClick() - } - }} - > - { this.renderRecipientIdenticon() } - <Tooltip - position="bottom" - title={this.state.recipientAddressCopied ? t('copiedExclamation') : t('copyAddress')} - wrapperClassName="sender-to-recipient__tooltip-wrapper" - containerClassName="sender-to-recipient__tooltip-container" - onHidden={() => this.setState({ recipientAddressCopied: false })} - > - <div className="sender-to-recipient__name"> - { - addressOnly - ? `${t('to')}: ${checksummedRecipientAddress}` - : (recipientName || this.context.t('newContract')) - } - </div> - </Tooltip> - </div> - ) - } - - renderRecipientWithoutAddress () { - return ( - <div className="sender-to-recipient__party sender-to-recipient__party--recipient"> - { !this.props.addressOnly && <i className="fa fa-file-text-o" /> } - <div className="sender-to-recipient__name"> - { this.context.t('newContract') } - </div> - </div> - ) - } - - renderArrow () { - return this.props.variant === DEFAULT_VARIANT - ? ( - <div className="sender-to-recipient__arrow-container"> - <div className="sender-to-recipient__arrow-circle"> - <img - height={15} - width={15} - src="./images/arrow-right.svg" - /> - </div> - </div> - ) : ( - <div className="sender-to-recipient__arrow-container"> - <img - height={20} - src="./images/caret-right.svg" - /> - </div> - ) - } - - render () { - const { senderAddress, recipientAddress, variant, onSenderClick } = this.props - const checksummedSenderAddress = checksumAddress(senderAddress) - - return ( - <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() } - { this.renderSenderAddress() } - </div> - { this.renderArrow() } - { - recipientAddress - ? this.renderRecipientWithAddress() - : this.renderRecipientWithoutAddress() - } - </div> - ) - } -} diff --git a/ui/app/components/shapeshift-form.js b/ui/app/components/shapeshift-form.js deleted file mode 100644 index b22c01e8f..000000000 --- a/ui/app/components/shapeshift-form.js +++ /dev/null @@ -1,256 +0,0 @@ -const h = require('react-hyperscript') -const inherits = require('util').inherits -const PropTypes = require('prop-types') -const Component = require('react').Component -const connect = require('react-redux').connect -const classnames = require('classnames') -const qrcode = require('qrcode-generator') -const { shapeShiftSubview, pairUpdate, buyWithShapeShift } = require('../actions') -const { isValidAddress } = require('../util') -const SimpleDropdown = require('./dropdowns/simple-dropdown') - -import Button from './button' - -function mapStateToProps (state) { - const { - coinOptions, - tokenExchangeRates, - selectedAddress, - } = state.metamask - const { warning } = state.appState - - return { - coinOptions, - tokenExchangeRates, - selectedAddress, - warning, - } -} - -function mapDispatchToProps (dispatch) { - return { - shapeShiftSubview: () => dispatch(shapeShiftSubview()), - pairUpdate: coin => dispatch(pairUpdate(coin)), - buyWithShapeShift: data => dispatch(buyWithShapeShift(data)), - } -} - -ShapeshiftForm.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(ShapeshiftForm) - - -inherits(ShapeshiftForm, Component) -function ShapeshiftForm () { - Component.call(this) - - this.state = { - depositCoin: 'btc', - refundAddress: '', - showQrCode: false, - depositAddress: '', - errorMessage: '', - isLoading: false, - bought: false, - } -} - -ShapeshiftForm.prototype.getCoinPair = function () { - return `${this.state.depositCoin.toUpperCase()}_ETH` -} - -ShapeshiftForm.prototype.componentWillMount = function () { - this.props.shapeShiftSubview() -} - -ShapeshiftForm.prototype.onCoinChange = function (coin) { - this.setState({ - depositCoin: coin, - errorMessage: '', - }) - this.props.pairUpdate(coin) -} - -ShapeshiftForm.prototype.onBuyWithShapeShift = function () { - this.setState({ - isLoading: true, - showQrCode: true, - }) - - const { - buyWithShapeShift, - selectedAddress: withdrawal, - } = this.props - const { - refundAddress: returnAddress, - depositCoin, - } = this.state - const pair = `${depositCoin}_eth` - const data = { - withdrawal, - pair, - returnAddress, - // Public api key - 'apiKey': '803d1f5df2ed1b1476e4b9e6bcd089e34d8874595dda6a23b67d93c56ea9cc2445e98a6748b219b2b6ad654d9f075f1f1db139abfa93158c04e825db122c14b6', - } - - if (isValidAddress(withdrawal)) { - buyWithShapeShift(data) - .then(d => this.setState({ - showQrCode: true, - depositAddress: d.deposit, - isLoading: false, - })) - .catch(() => this.setState({ - showQrCode: false, - errorMessage: this.context.t('invalidRequest'), - isLoading: false, - })) - } -} - -ShapeshiftForm.prototype.renderMetadata = function (label, value) { - return h('div', {className: 'shapeshift-form__metadata-wrapper'}, [ - - h('div.shapeshift-form__metadata-label', {}, [ - h('span', `${label}:`), - ]), - - h('div.shapeshift-form__metadata-value', {}, [ - h('span', value), - ]), - - ]) -} - -ShapeshiftForm.prototype.renderMarketInfo = function () { - const { tokenExchangeRates } = this.props - const { - limit, - rate, - minimum, - } = tokenExchangeRates[this.getCoinPair()] || {} - - return h('div.shapeshift-form__metadata', {}, [ - - this.renderMetadata(this.context.t('status'), limit ? this.context.t('available') : this.context.t('unavailable')), - this.renderMetadata(this.context.t('limit'), limit), - this.renderMetadata(this.context.t('exchangeRate'), rate), - this.renderMetadata(this.context.t('min'), minimum), - - ]) -} - -ShapeshiftForm.prototype.renderQrCode = function () { - const { depositAddress, isLoading, depositCoin } = this.state - const qrImage = qrcode(4, 'M') - qrImage.addData(depositAddress) - qrImage.make() - - return h('div.shapeshift-form', {}, [ - - h('div.shapeshift-form__deposit-instruction', [ - this.context.t('depositCoin', [depositCoin.toUpperCase()]), - ]), - - h('div', depositAddress), - - h('div.shapeshift-form__qr-code', [ - isLoading - ? h('img', { - src: 'images/loading.svg', - style: { width: '60px'}, - }) - : h('div', { - dangerouslySetInnerHTML: { __html: qrImage.createTableTag(4) }, - }), - ]), - - this.renderMarketInfo(), - - ]) -} - - -ShapeshiftForm.prototype.render = function () { - const { coinOptions, btnClass, warning } = this.props - const { errorMessage, showQrCode, depositAddress } = this.state - const { tokenExchangeRates } = this.props - const token = tokenExchangeRates[this.getCoinPair()] - - return h('div.shapeshift-form-wrapper', [ - showQrCode - ? this.renderQrCode() - : h('div.modal-shapeshift-form', [ - h('div.shapeshift-form__selectors', [ - - h('div.shapeshift-form__selector', [ - - h('div.shapeshift-form__selector-label', this.context.t('deposit')), - - h(SimpleDropdown, { - selectedOption: this.state.depositCoin, - onSelect: (coin) => this.onCoinChange(coin), - options: Object.entries(coinOptions).map(([coin]) => ({ - value: coin.toLowerCase(), - displayValue: coin, - })), - }), - - ]), - - h('div.icon.shapeshift-form__caret', { - style: { backgroundImage: 'url(images/caret-right.svg)'}, - }), - - h('div.shapeshift-form__selector', [ - - h('div.shapeshift-form__selector-label', [ - this.context.t('receive'), - ]), - - h('div.shapeshift-form__selector-input', ['ETH']), - - ]), - - ]), - - warning && h('div.shapeshift-form__address-input-label', warning), - - !warning && h('div', { - className: classnames('shapeshift-form__address-input-wrapper', { - 'shapeshift-form__address-input-wrapper--error': errorMessage, - }), - }, [ - - h('div.shapeshift-form__address-input-label', [ - this.context.t('refundAddress'), - ]), - - h('input.shapeshift-form__address-input', { - type: 'text', - onChange: e => this.setState({ - refundAddress: e.target.value, - errorMessage: '', - }), - }), - - h('divshapeshift-form__address-input-error-message', [errorMessage]), - ]), - - !warning && this.renderMarketInfo(), - - ]), - - !depositAddress && h(Button, { - type: 'primary', - large: true, - className: `${btnClass} shapeshift-form__shapeshift-buy-btn`, - disabled: !token, - onClick: () => this.onBuyWithShapeShift(), - }, [this.context.t('buy')]), - - ]) -} diff --git a/ui/app/components/shift-list-item.js b/ui/app/components/shift-list-item.js deleted file mode 100644 index 2d08bbddc..000000000 --- a/ui/app/components/shift-list-item.js +++ /dev/null @@ -1,204 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const explorerLink = require('etherscan-link').createExplorerLink -const actions = require('../actions') -const { formatDate, addressSummary } = require('../util') - -const CopyButton = require('./copyButton') -const EthBalance = require('./eth-balance') -const Tooltip = require('./tooltip') - - -ShiftListItem.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(ShiftListItem) - - -function mapStateToProps (state) { - return { - selectedAddress: state.metamask.selectedAddress, - conversionRate: state.metamask.conversionRate, - currentCurrency: state.metamask.currentCurrency, - } -} - -inherits(ShiftListItem, Component) - -function ShiftListItem () { - Component.call(this) -} - -ShiftListItem.prototype.render = function () { - return h('div.transaction-list-item.tx-list-clickable', { - style: { - paddingTop: '20px', - paddingBottom: '20px', - justifyContent: 'space-around', - alignItems: 'center', - flexDirection: 'row', - }, - }, [ - h('div', { - style: { - width: '0px', - position: 'relative', - bottom: '19px', - }, - }, [ - h('img', { - src: 'https://shapeshift.io/logo.png', - style: { - height: '35px', - width: '132px', - position: 'absolute', - clip: 'rect(0px,30px,34px,0px)', - }, - }), - ]), - - this.renderInfo(), - this.renderUtilComponents(), - ]) -} - -ShiftListItem.prototype.renderUtilComponents = function () { - var props = this.props - const { conversionRate, currentCurrency } = props - - switch (props.response.status) { - case 'no_deposits': - return h('.flex-row', [ - h(CopyButton, { - value: this.props.depositAddress, - }), - h(Tooltip, { - title: this.context.t('qrCode'), - }, [ - h('i.fa.fa-qrcode.pointer.pop-hover', { - onClick: () => props.dispatch(actions.reshowQrCode(props.depositAddress, props.depositType)), - style: { - margin: '5px', - marginLeft: '23px', - marginRight: '12px', - fontSize: '20px', - color: '#F7861C', - }, - }), - ]), - ]) - case 'received': - return h('.flex-row') - - case 'complete': - return h('.flex-row', [ - h(CopyButton, { - value: this.props.response.transaction, - }), - h(EthBalance, { - value: `${props.response.outgoingCoin}`, - conversionRate, - currentCurrency, - width: '55px', - shorten: true, - needsParse: false, - incoming: true, - style: { - fontSize: '15px', - color: '#01888C', - }, - }), - ]) - - case 'failed': - return '' - default: - return '' - } -} - -ShiftListItem.prototype.renderInfo = function () { - var props = this.props - switch (props.response.status) { - case 'no_deposits': - return h('.flex-column', { - style: { - overflow: 'hidden', - }, - }, [ - h('div', { - style: { - fontSize: 'x-small', - color: '#ABA9AA', - width: '100%', - }, - }, this.context.t('toETHviaShapeShift', [props.depositType])), - h('div', this.context.t('noDeposits')), - h('div', { - style: { - fontSize: 'x-small', - color: '#ABA9AA', - width: '100%', - }, - }, formatDate(props.time)), - ]) - case 'received': - return h('.flex-column', { - style: { - width: '200px', - overflow: 'hidden', - }, - }, [ - h('div', { - style: { - fontSize: 'x-small', - color: '#ABA9AA', - width: '100%', - }, - }, this.context.t('toETHviaShapeShift', [props.depositType])), - h('div', this.context.t('conversionProgress')), - h('div', { - style: { - fontSize: 'x-small', - color: '#ABA9AA', - width: '100%', - }, - }, formatDate(props.time)), - ]) - case 'complete': - var url = explorerLink(props.response.transaction, parseInt('1')) - - return h('.flex-column.pointer', { - style: { - width: '200px', - overflow: 'hidden', - }, - onClick: () => global.platform.openWindow({ url }), - }, [ - h('div', { - style: { - fontSize: 'x-small', - color: '#ABA9AA', - width: '100%', - }, - }, this.context.t('fromShapeShift')), - h('div', formatDate(props.time)), - h('div', { - style: { - fontSize: 'x-small', - color: '#ABA9AA', - width: '100%', - }, - }, addressSummary(props.response.transaction)), - ]) - - case 'failed': - return h('span.error', '(' + this.context.t('failed') + ')') - default: - return '' - } -} diff --git a/ui/app/components/sidebars/index.scss b/ui/app/components/sidebars/index.scss deleted file mode 100644 index b9845d564..000000000 --- a/ui/app/components/sidebars/index.scss +++ /dev/null @@ -1,81 +0,0 @@ -@import './sidebar-content'; - -.sidebar-right-enter { - transition: transform 300ms ease-in-out; - transform: translateX(-100%); -} - -.sidebar-right-enter.sidebar-right-enter-active { - transition: transform 300ms ease-in-out; - transform: translateX(0%); -} - -.sidebar-right-leave { - transition: transform 200ms ease-out; - transform: translateX(0%); -} - -.sidebar-right-leave.sidebar-right-leave-active { - transition: transform 200ms ease-out; - transform: translateX(-100%); -} - -.sidebar-left-enter { - transition: transform 300ms ease-in-out; - transform: translateX(100%); -} - -.sidebar-left-enter.sidebar-left-enter-active { - transition: transform 300ms ease-in-out; - transform: translateX(0%); -} - -.sidebar-left-leave { - transition: transform 200ms ease-out; - transform: translateX(0%); -} - -.sidebar-left-leave.sidebar-left-leave-active { - transition: transform 200ms ease-out; - transform: translateX(100%); -} - -.sidebar-left { - flex: 1 0 230px; - background: rgb(250, 250, 250); - z-index: $sidebar-z-index; - position: fixed; - left: 15%; - right: 0; - bottom: 0; - opacity: 1; - visibility: visible; - will-change: transform; - overflow-y: auto; - box-shadow: rgba(0, 0, 0, .15) 2px 2px 4px; - width: 85%; - height: 100%; - - @media screen and (min-width: 769px) { - width: 408px; - left: calc(100% - 408px); - } - - @media screen and (max-width: $break-small) { - width: 100%; - left: 0%; - } -} - -.sidebar-overlay { - z-index: $sidebar-overlay-z-index; - position: fixed; - height: 100%; - width: 100%; - left: 0; - right: 0; - bottom: 0; - opacity: 1; - visibility: visible; - background-color: rgba(0, 0, 0, .3); -}
\ No newline at end of file diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js deleted file mode 100644 index 25bd9a7b1..000000000 --- a/ui/app/components/signature-request.js +++ /dev/null @@ -1,316 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -import Identicon from './identicon' -const connect = require('react-redux').connect -const ethUtil = require('ethereumjs-util') -const classnames = require('classnames') -const { compose } = require('recompose') -const { withRouter } = require('react-router-dom') -const { ObjectInspector } = require('react-inspector') - -import AccountDropdownMini from './account-dropdown-mini' - -const actions = require('../actions') -const { conversionUtil } = require('../conversion-util') - -const { - getSelectedAccount, - getCurrentAccountWithSendEtherInfo, - getSelectedAddress, - accountsWithSendEtherInfoSelector, - conversionRateSelector, -} = require('../selectors.js') - -import { clearConfirmTransaction } from '../ducks/confirm-transaction.duck' -import Button from './button' - -const { DEFAULT_ROUTE } = require('../routes') - -function mapStateToProps (state) { - return { - balance: getSelectedAccount(state).balance, - selectedAccount: getCurrentAccountWithSendEtherInfo(state), - selectedAddress: getSelectedAddress(state), - requester: null, - requesterAddress: null, - accounts: accountsWithSendEtherInfoSelector(state), - conversionRate: conversionRateSelector(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - goHome: () => dispatch(actions.goHome()), - clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), - } -} - -SignatureRequest.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(SignatureRequest) - - -inherits(SignatureRequest, Component) -function SignatureRequest (props) { - Component.call(this) - - this.state = { - selectedAccount: props.selectedAccount, - } -} - -SignatureRequest.prototype.renderHeader = function () { - return h('div.request-signature__header', [ - - h('div.request-signature__header-background'), - - h('div.request-signature__header__text', this.context.t('sigRequest')), - - h('div.request-signature__header__tip-container', [ - h('div.request-signature__header__tip'), - ]), - - ]) -} - -SignatureRequest.prototype.renderAccountDropdown = function () { - const { selectedAccount } = this.state - - const { - accounts, - } = this.props - - return h('div.request-signature__account', [ - - h('div.request-signature__account-text', [this.context.t('account') + ':']), - - h(AccountDropdownMini, { - selectedAccount, - accounts, - disabled: true, - }), - - ]) -} - -SignatureRequest.prototype.renderBalance = function () { - const { balance, conversionRate } = this.props - - const balanceInEther = conversionUtil(balance, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromDenomination: 'WEI', - numberOfDecimals: 6, - conversionRate, - }) - - return h('div.request-signature__balance', [ - - h('div.request-signature__balance-text', `${this.context.t('balance')}:`), - - h('div.request-signature__balance-value', `${balanceInEther} ETH`), - - ]) -} - -SignatureRequest.prototype.renderAccountInfo = function () { - return h('div.request-signature__account-info', [ - - this.renderAccountDropdown(), - - this.renderRequestIcon(), - - this.renderBalance(), - - ]) -} - -SignatureRequest.prototype.renderRequestIcon = function () { - const { requesterAddress } = this.props - - return h('div.request-signature__request-icon', [ - h(Identicon, { - diameter: 40, - address: requesterAddress, - }), - ]) -} - -SignatureRequest.prototype.renderRequestInfo = function () { - return h('div.request-signature__request-info', [ - - h('div.request-signature__headline', [ - this.context.t('yourSigRequested'), - ]), - - ]) -} - -SignatureRequest.prototype.msgHexToText = function (hex) { - try { - const stripped = ethUtil.stripHexPrefix(hex) - const buff = Buffer.from(stripped, 'hex') - return buff.length === 32 ? hex : buff.toString('utf8') - } catch (e) { - return hex - } -} - -// eslint-disable-next-line react/display-name -SignatureRequest.prototype.renderTypedDataV3 = function (data) { - const { domain, message } = JSON.parse(data) - return [ - h('div.request-signature__typed-container', [ - domain ? h('div', [ - h('h1', 'Domain'), - h(ObjectInspector, { data: domain, expandLevel: 1, name: 'domain' }), - ]) : '', - message ? h('div', [ - h('h1', 'Message'), - h(ObjectInspector, { data: message, expandLevel: 1, name: 'message' }), - ]) : '', - ]), - ] -} - -SignatureRequest.prototype.renderBody = function () { - let rows - let notice = this.context.t('youSign') + ':' - - const { txData } = this.props - const { type, msgParams: { data, version } } = txData - - if (type === 'personal_sign') { - rows = [{ name: this.context.t('message'), value: this.msgHexToText(data) }] - } else if (type === 'eth_signTypedData') { - rows = data - } else if (type === 'eth_sign') { - rows = [{ name: this.context.t('message'), value: data }] - notice = [this.context.t('signNotice'), - h('span.request-signature__help-link', { - onClick: () => { - global.platform.openWindow({ - url: 'https://metamask.zendesk.com/hc/en-us/articles/360015488751', - }) - }, - }, this.context.t('learnMore'))] - } - - return h('div.request-signature__body', {}, [ - - this.renderAccountInfo(), - - this.renderRequestInfo(), - - h('div.request-signature__notice', { - className: classnames({ - 'request-signature__notice': type === 'personal_sign' || type === 'eth_signTypedData', - 'request-signature__warning': type === 'eth_sign', - }), - }, [notice]), - - h('div.request-signature__rows', type === 'eth_signTypedData' && version === 'V3' ? - this.renderTypedDataV3(data) : - rows.map(({ name, value }) => { - if (typeof value === 'boolean') { - value = value.toString() - } - return h('div.request-signature__row', [ - h('div.request-signature__row-title', [`${name}:`]), - h('div.request-signature__row-value', value), - ]) - }), - ), - ]) -} - -SignatureRequest.prototype.renderFooter = function () { - const { - signPersonalMessage, - signTypedMessage, - cancelPersonalMessage, - cancelTypedMessage, - signMessage, - cancelMessage, - } = this.props - - const { txData } = this.props - const { type } = txData - - let cancel - let sign - if (type === 'personal_sign') { - cancel = cancelPersonalMessage - sign = signPersonalMessage - } else if (type === 'eth_signTypedData') { - cancel = cancelTypedMessage - sign = signTypedMessage - } else if (type === 'eth_sign') { - cancel = cancelMessage - sign = signMessage - } - - return h('div.request-signature__footer', [ - h(Button, { - type: 'default', - large: true, - 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) - }) - }, - }, this.context.t('cancel')), - 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) - }) - }, - }, this.context.t('sign')), - ]) -} - -SignatureRequest.prototype.render = function () { - return ( - - h('div.request-signature__container', [ - - this.renderHeader(), - - this.renderBody(), - - this.renderFooter(), - - ]) - - ) - -} diff --git a/ui/app/components/tabs/index.scss b/ui/app/components/tabs/index.scss deleted file mode 100644 index a3b42f8e3..000000000 --- a/ui/app/components/tabs/index.scss +++ /dev/null @@ -1,11 +0,0 @@ -@import './tab/index'; - -.tabs { - &__list { - display: flex; - justify-content: flex-start; - background-color: #f9fafa; - border-bottom: 1px solid $geyser; - padding: 0 16px; - } -} diff --git a/ui/app/components/text-field/text-field.stories.js b/ui/app/components/text-field/text-field.stories.js deleted file mode 100644 index c00873b8a..000000000 --- a/ui/app/components/text-field/text-field.stories.js +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react' -import { storiesOf } from '@storybook/react' -import TextField from './' - -storiesOf('TextField', module) - .add('text', () => - <TextField - label="Text" - type="text" - /> - ) - .add('password', () => - <TextField - label="Password" - type="password" - /> - ) - .add('error', () => - <TextField - type="text" - label="Name" - error="Invalid value" - /> - ) - .add('Mascara text', () => - <TextField - label="Text" - type="text" - largeLabel - /> - ) - .add('Material text', () => - <TextField - label="Text" - type="text" - material - /> - ) - .add('Material password', () => - <TextField - label="Password" - type="password" - material - /> - ) - .add('Material error', () => - <TextField - type="text" - label="Name" - error="Invalid value" - material - /> - ) diff --git a/ui/app/components/token-balance/token-balance.container.js b/ui/app/components/token-balance/token-balance.container.js deleted file mode 100644 index adc001f83..000000000 --- a/ui/app/components/token-balance/token-balance.container.js +++ /dev/null @@ -1,16 +0,0 @@ -import { connect } from 'react-redux' -import { compose } from 'recompose' -import withTokenTracker from '../../higher-order-components/with-token-tracker' -import TokenBalance from './token-balance.component' -import selectors from '../../selectors' - -const mapStateToProps = state => { - return { - userAddress: selectors.getSelectedAddress(state), - } -} - -export default compose( - connect(mapStateToProps), - withTokenTracker -)(TokenBalance) diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js deleted file mode 100644 index d9c80b4f4..000000000 --- a/ui/app/components/token-cell.js +++ /dev/null @@ -1,177 +0,0 @@ -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') - -const TokenMenuDropdown = require('./dropdowns/token-menu-dropdown.js') - -function mapStateToProps (state) { - return { - network: state.metamask.network, - currentCurrency: state.metamask.currentCurrency, - selectedTokenAddress: state.metamask.selectedTokenAddress, - userAddress: selectors.getSelectedAddress(state), - contractExchangeRates: state.metamask.contractExchangeRates, - conversionRate: state.metamask.conversionRate, - sidebarOpen: state.appState.sidebar.isOpen, - } -} - -function mapDispatchToProps (dispatch) { - return { - setSelectedToken: address => dispatch(actions.setSelectedToken(address)), - hideSidebar: () => dispatch(actions.hideSidebar()), - } -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(TokenCell) - -inherits(TokenCell, Component) -function TokenCell () { - Component.call(this) - - this.state = { - tokenMenuOpen: false, - } -} - -TokenCell.contextTypes = { - metricsEvent: PropTypes.func, -} - -TokenCell.prototype.render = function () { - const { tokenMenuOpen } = this.state - const props = this.props - const { - address, - symbol, - string, - network, - setSelectedToken, - selectedTokenAddress, - contractExchangeRates, - conversionRate, - hideSidebar, - sidebarOpen, - currentCurrency, - // userAddress, - image, - } = props - let currentTokenToFiatRate - let currentTokenInFiat - let formattedFiat = '' - - if (contractExchangeRates[address]) { - currentTokenToFiatRate = multiplyCurrencies( - contractExchangeRates[address], - conversionRate - ) - currentTokenInFiat = conversionUtil(string, { - fromNumericBase: 'dec', - fromCurrency: symbol, - toCurrency: currentCurrency.toUpperCase(), - numberOfDecimals: 2, - conversionRate: currentTokenToFiatRate, - }) - formattedFiat = currentTokenInFiat.toString() === '0' - ? '' - : `${currentTokenInFiat} ${currentCurrency.toUpperCase()}` - } - - const showFiat = Boolean(currentTokenInFiat) && currentCurrency.toUpperCase() !== symbol - - return ( - h('div.token-list-item', { - className: `token-list-item ${selectedTokenAddress === address ? 'token-list-item--active' : ''}`, - // style: { cursor: network === '1' ? 'pointer' : 'default' }, - // onClick: this.view.bind(this, address, userAddress, network), - onClick: () => { - setSelectedToken(address) - this.context.metricsEvent({ - eventOpts: { - category: 'Navigation', - action: 'Token Menu', - name: 'Clicked Token', - }, - }) - selectedTokenAddress !== address && sidebarOpen && hideSidebar() - }, - }, [ - - h(Identicon, { - className: 'token-list-item__identicon', - diameter: 50, - address, - network, - image, - }), - - h('div.token-list-item__balance-ellipsis', null, [ - h('div.token-list-item__balance-wrapper', null, [ - h('div.token-list-item__token-balance', `${string || 0}`), - h('div.token-list-item__token-symbol', symbol), - showFiat && h('div.token-list-item__fiat-amount', { - style: {}, - }, formattedFiat), - ]), - - h('i.fa.fa-ellipsis-h.fa-lg.token-list-item__ellipsis.cursor-pointer', { - onClick: (e) => { - e.stopPropagation() - this.setState({ tokenMenuOpen: true }) - }, - }), - - ]), - - - tokenMenuOpen && h(TokenMenuDropdown, { - onClose: () => this.setState({ tokenMenuOpen: false }), - token: { symbol, address }, - }), - - /* - h('button', { - onClick: this.send.bind(this, address), - }, 'SEND'), - */ - - ]) - ) -} - -TokenCell.prototype.send = function (address, event) { - event.preventDefault() - event.stopPropagation() - const url = tokenFactoryFor(address) - if (url) { - navigateTo(url) - } -} - -TokenCell.prototype.view = function (address, userAddress, network, event) { - const url = etherscanLinkFor(address, userAddress, network) - if (url) { - navigateTo(url) - } -} - -function navigateTo (url) { - global.platform.openWindow({ url }) -} - -function etherscanLinkFor (tokenAddress, address, network) { - const prefix = prefixForNetwork(network) - return `https://${prefix}etherscan.io/token/${tokenAddress}?a=${address}` -} - -function tokenFactoryFor (tokenAddress) { - return `https://tokenfactory.surge.sh/#/token/${tokenAddress}` -} - diff --git a/ui/app/components/token-currency-display/token-currency-display.component.js b/ui/app/components/token-currency-display/token-currency-display.component.js deleted file mode 100644 index f49846449..000000000 --- a/ui/app/components/token-currency-display/token-currency-display.component.js +++ /dev/null @@ -1,57 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import CurrencyDisplay from '../currency-display' -import { getTokenData } from '../../helpers/transactions.util' -import { getTokenValue, calcTokenAmount } from '../../token-util' - -export default class TokenCurrencyDisplay extends PureComponent { - static propTypes = { - transactionData: PropTypes.string, - token: PropTypes.object, - } - - state = { - displayValue: '', - suffix: '', - } - - componentDidMount () { - this.setDisplayValue() - } - - componentDidUpdate (prevProps) { - const { transactionData } = this.props - const { transactionData: prevTransactionData } = prevProps - - if (transactionData !== prevTransactionData) { - this.setDisplayValue() - } - } - - setDisplayValue () { - const { transactionData: data, token } = this.props - const { decimals = '', symbol: suffix = '' } = token - const tokenData = getTokenData(data) - - let displayValue - - if (tokenData && tokenData.params && tokenData.params.length) { - const tokenValue = getTokenValue(tokenData.params) - displayValue = calcTokenAmount(tokenValue, decimals).toString() - } - - this.setState({ displayValue, suffix }) - } - - render () { - const { displayValue, suffix } = this.state - - return ( - <CurrencyDisplay - {...this.props} - displayValue={displayValue} - suffix={suffix} - /> - ) - } -} diff --git a/ui/app/components/token-input/token-input.component.js b/ui/app/components/token-input/token-input.component.js deleted file mode 100644 index 398b762ec..000000000 --- a/ui/app/components/token-input/token-input.component.js +++ /dev/null @@ -1,145 +0,0 @@ -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 ethUtil from 'ethereumjs-util' -import { conversionUtil, multiplyCurrencies } from '../../conversion-util' -import { ETH } from '../../constants/common' - -/** - * Component that allows user to enter token values as a number, and props receive a converted - * hex value. props.value, used as a default or forced value, should be a hex value, which - * gets converted into a decimal value. - */ -export default class TokenInput extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - currentCurrency: PropTypes.string, - onChange: PropTypes.func, - onBlur: PropTypes.func, - value: PropTypes.string, - suffix: PropTypes.string, - showFiat: PropTypes.bool, - hideConversion: PropTypes.bool, - selectedToken: PropTypes.object, - selectedTokenExchangeRate: PropTypes.number, - } - - constructor (props) { - super(props) - - const { value: hexValue } = props - const decimalValue = hexValue ? this.getValue(props) : 0 - - this.state = { - decimalValue, - hexValue, - } - } - - componentDidUpdate (prevProps) { - const { value: prevPropsHexValue } = prevProps - const { value: propsHexValue } = this.props - const { hexValue: stateHexValue } = this.state - - if (prevPropsHexValue !== propsHexValue && propsHexValue !== stateHexValue) { - const decimalValue = this.getValue(this.props) - this.setState({ hexValue: propsHexValue, decimalValue }) - } - } - - getValue (props) { - const { value: hexValue, selectedToken: { decimals, symbol } = {} } = props - - const multiplier = Math.pow(10, Number(decimals || 0)) - const decimalValueString = conversionUtil(ethUtil.addHexPrefix(hexValue), { - fromNumericBase: 'hex', - toNumericBase: 'dec', - toCurrency: symbol, - conversionRate: multiplier, - invertConversionRate: true, - }) - - return Number(decimalValueString) ? decimalValueString : '' - } - - handleChange = decimalValue => { - const { selectedToken: { decimals } = {}, onChange } = this.props - - const multiplier = Math.pow(10, Number(decimals || 0)) - const hexValue = multiplyCurrencies(decimalValue || 0, multiplier, { toNumericBase: 'hex' }) - - this.setState({ hexValue, decimalValue }) - onChange(hexValue) - } - - handleBlur = () => { - this.props.onBlur(this.state.hexValue) - } - - renderConversionComponent () { - 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 - numberOfDecimals = 2 - } else { - // Display ETH - currency = ETH - numberOfDecimals = 6 - } - - const decimalEthValue = (decimalValue * selectedTokenExchangeRate) || 0 - const hexWeiValue = getWeiHexFromDecimalValue({ - value: decimalEthValue, - fromCurrency: ETH, - fromDenomination: ETH, - }) - - return selectedTokenExchangeRate - ? ( - <CurrencyDisplay - className="currency-input__conversion-component" - currency={currency} - value={hexWeiValue} - numberOfDecimals={numberOfDecimals} - /> - ) : ( - <div className="currency-input__conversion-component"> - { this.context.t('noConversionRateAvailable') } - </div> - ) - } - - render () { - const { suffix, ...restProps } = this.props - const { decimalValue } = this.state - - return ( - <UnitInput - {...restProps} - suffix={suffix} - onChange={this.handleChange} - onBlur={this.handleBlur} - value={decimalValue} - > - { this.renderConversionComponent() } - </UnitInput> - ) - } -} diff --git a/ui/app/components/token-input/token-input.container.js b/ui/app/components/token-input/token-input.container.js deleted file mode 100644 index a00d200f7..000000000 --- a/ui/app/components/token-input/token-input.container.js +++ /dev/null @@ -1,30 +0,0 @@ -import { connect } from 'react-redux' -import TokenInput from './token-input.component' -import {getIsMainnet, getSelectedToken, getSelectedTokenExchangeRate, preferencesSelector} from '../../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), - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { selectedToken } = stateProps - const suffix = selectedToken && selectedToken.symbol - - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - suffix, - } -} - -export default connect(mapStateToProps, null, mergeProps)(TokenInput) diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js deleted file mode 100644 index 258abde72..000000000 --- a/ui/app/components/token-list.js +++ /dev/null @@ -1,188 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const TokenTracker = require('eth-token-tracker') -const TokenCell = require('./token-cell.js') -const connect = require('react-redux').connect -const selectors = require('../selectors') -const log = require('loglevel') - -function mapStateToProps (state) { - return { - network: state.metamask.network, - tokens: state.metamask.tokens, - userAddress: selectors.getSelectedAddress(state), - assetImages: state.metamask.assetImages, - } -} - -const defaultTokens = [] -const contracts = require('eth-contract-metadata') -for (const address in contracts) { - const contract = contracts[address] - if (contract.erc20) { - contract.address = address - defaultTokens.push(contract) - } -} - -TokenList.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(TokenList) - - -inherits(TokenList, Component) -function TokenList () { - this.state = { - tokens: [], - isLoading: true, - network: null, - } - Component.call(this) -} - -TokenList.prototype.render = function () { - const { userAddress, assetImages } = this.props - const state = this.state - const { tokens, isLoading, error } = state - if (isLoading) { - return this.message(this.context.t('loadingTokens')) - } - - if (error) { - log.error(error) - return h('.hotFix', { - style: { - padding: '80px', - }, - }, [ - this.context.t('troubleTokenBalances'), - h('span.hotFix', { - style: { - color: 'rgba(247, 134, 28, 1)', - cursor: 'pointer', - }, - onClick: () => { - global.platform.openWindow({ - url: `https://ethplorer.io/address/${userAddress}`, - }) - }, - }, this.context.t('here')), - ]) - } - - return h('div', tokens.map((tokenData) => { - tokenData.image = assetImages[tokenData.address] - return h(TokenCell, tokenData) - })) - -} - -TokenList.prototype.message = function (body) { - return h('div', { - style: { - display: 'flex', - height: '250px', - alignItems: 'center', - justifyContent: 'center', - padding: '30px', - }, - }, body) -} - -TokenList.prototype.componentDidMount = function () { - this.createFreshTokenTracker() -} - -TokenList.prototype.createFreshTokenTracker = function () { - if (this.tracker) { - // Clean up old trackers when refreshing: - this.tracker.stop() - this.tracker.removeListener('update', this.balanceUpdater) - this.tracker.removeListener('error', this.showError) - } - - if (!global.ethereumProvider) return - const { userAddress } = this.props - - this.tracker = new TokenTracker({ - userAddress, - provider: global.ethereumProvider, - tokens: this.props.tokens, - pollingInterval: 8000, - }) - - - // Set up listener instances for cleaning up - this.balanceUpdater = this.updateBalances.bind(this) - this.showError = (error) => { - this.setState({ error, isLoading: false }) - } - this.tracker.on('update', this.balanceUpdater) - this.tracker.on('error', this.showError) - - this.tracker.updateBalances() - .then(() => { - this.updateBalances(this.tracker.serialize()) - }) - .catch((reason) => { - log.error(`Problem updating balances`, reason) - this.setState({ isLoading: false }) - }) -} - -TokenList.prototype.componentDidUpdate = function (prevProps) { - const { - network: oldNet, - userAddress: oldAddress, - tokens, - } = prevProps - const { - network: newNet, - userAddress: newAddress, - tokens: newTokens, - } = this.props - - const isLoading = newNet === 'loading' - const missingInfo = !oldNet || !newNet || !oldAddress || !newAddress - const sameUserAndNetwork = oldAddress === newAddress && oldNet === newNet - const shouldUpdateTokens = isLoading || missingInfo || sameUserAndNetwork - - const oldTokensLength = tokens ? tokens.length : 0 - const tokensLengthUnchanged = oldTokensLength === newTokens.length - - if (tokensLengthUnchanged && shouldUpdateTokens) return - - this.setState({ isLoading: true }) - this.createFreshTokenTracker() -} - -TokenList.prototype.updateBalances = function (tokens) { - if (!this.tracker.running) { - return - } - this.setState({ tokens, isLoading: false }) -} - -TokenList.prototype.componentWillUnmount = function () { - if (!this.tracker) return - this.tracker.stop() - this.tracker.removeListener('update', this.balanceUpdater) - this.tracker.removeListener('error', this.showError) -} - -// function uniqueMergeTokens (tokensA, tokensB = []) { -// const uniqueAddresses = [] -// const result = [] -// tokensA.concat(tokensB).forEach((token) => { -// const normal = normalizeAddress(token.address) -// if (!uniqueAddresses.includes(normal)) { -// uniqueAddresses.push(normal) -// result.push(token) -// } -// }) -// return result -// } diff --git a/ui/app/components/transaction-action/transaction-action.component.js b/ui/app/components/transaction-action/transaction-action.component.js deleted file mode 100644 index 1de91cb71..000000000 --- a/ui/app/components/transaction-action/transaction-action.component.js +++ /dev/null @@ -1,58 +0,0 @@ -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' - -export default class TransactionAction extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - className: PropTypes.string, - transaction: PropTypes.object, - methodData: PropTypes.object, - } - - state = { - transactionAction: '', - } - - componentDidMount () { - this.getTransactionAction() - } - - componentDidUpdate () { - this.getTransactionAction() - } - - async getTransactionAction () { - const { transactionAction } = this.state - const { transaction, methodData } = this.props - const { data, done } = methodData - const { name = '' } = data - - if (!done || transactionAction) { - return - } - - const actionKey = await getTransactionActionKey(transaction, data) - const action = actionKey - ? this.context.t(actionKey) - : camelCaseToCapitalize(name) - - this.setState({ transactionAction: action }) - } - - render () { - const { className, methodData: { done } } = this.props - const { transactionAction } = this.state - - return ( - <div className={classnames('transaction-action', className)}> - { (done && transactionAction) || '--' } - </div> - ) - } -} 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 ca46d7830..000000000 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js +++ /dev/null @@ -1,131 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { getEthConversionFromWeiHex, getValueFromWeiHex } from '../../helpers/conversions.util' -import { formatDate } from '../../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/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 e43229708..000000000 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js +++ /dev/null @@ -1,44 +0,0 @@ -import { connect } from 'react-redux' -import R from 'ramda' -import TransactionActivityLog from './transaction-activity-log.component' -import { conversionRateSelector, getNativeCurrency } from '../../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/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 6206a4678..000000000 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js +++ /dev/null @@ -1,224 +0,0 @@ -import { getHexGasTotal } from '../../helpers/confirm-transaction/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 = [], txReceipt: { 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 = 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.scss b/ui/app/components/transaction-breakdown/index.scss deleted file mode 100644 index b56cbdd7f..000000000 --- a/ui/app/components/transaction-breakdown/index.scss +++ /dev/null @@ -1,24 +0,0 @@ -@import './transaction-breakdown-row/index'; - -.transaction-breakdown { - &__title { - border-bottom: 1px solid #d8d8d8; - padding-bottom: 4px; - text-transform: capitalize; - } - - &__row-title { - text-transform: capitalize; - } - - &__value { - text-align: end; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &--eth-total { - font-weight: 500; - } - } -} diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js b/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js deleted file mode 100644 index c19399dbb..000000000 --- a/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import TransactionBreakdownRow from '../transaction-breakdown-row.component' -import Button from '../../../button' - -describe('TransactionBreakdownRow Component', () => { - it('should render text properly', () => { - const wrapper = shallow( - <TransactionBreakdownRow - title="test" - className="test-class" - > - Test - </TransactionBreakdownRow>, - { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } - ) - - assert.ok(wrapper.hasClass('transaction-breakdown-row')) - assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') - assert.equal(wrapper.find('.transaction-breakdown-row__value').text(), 'Test') - }) - - it('should render components properly', () => { - const wrapper = shallow( - <TransactionBreakdownRow - title="test" - className="test-class" - > - <Button onClick={() => {}} >Button</Button> - </TransactionBreakdownRow>, - { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } - ) - - assert.ok(wrapper.hasClass('transaction-breakdown-row')) - assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') - assert.ok(wrapper.find('.transaction-breakdown-row__value').find(Button)) - }) -}) 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 26dc4c153..000000000 --- a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js +++ /dev/null @@ -1,106 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import TransactionBreakdownRow from './transaction-breakdown-row' -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' - -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/transaction-breakdown/transaction-breakdown.container.js b/ui/app/components/transaction-breakdown/transaction-breakdown.container.js deleted file mode 100644 index 3e85b9e23..000000000 --- a/ui/app/components/transaction-breakdown/transaction-breakdown.container.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux' -import TransactionBreakdown from './transaction-breakdown.component' -import {getIsMainnet, getNativeCurrency, preferencesSelector} from '../../selectors' -import { getHexGasTotal } from '../../helpers/confirm-transaction/util' -import { sumHexes } from '../../helpers/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/tests/transaction-list-item-details.component.test.js b/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js deleted file mode 100644 index 5b55beeb4..000000000 --- a/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js +++ /dev/null @@ -1,81 +0,0 @@ -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 TransactionBreakdown from '../../transaction-breakdown' -import TransactionActivityLog from '../../transaction-activity-log' - -describe('TransactionListItemDetails 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 transactionGroup = { - transactions: [transaction], - primaryTransaction: transaction, - initialTransaction: transaction, - } - - const wrapper = shallow( - <TransactionListItemDetails - transactionGroup={transactionGroup} - />, - { 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(SenderToRecipient).length, 1) - assert.equal(wrapper.find(TransactionBreakdown).length, 1) - assert.equal(wrapper.find(TransactionActivityLog).length, 1) - }) - - it('should render a retry button', () => { - const transaction = { - history: [], - id: 1, - status: 'confirmed', - txParams: { - from: '0x1', - gas: '0x5208', - gasPrice: '0x3b9aca00', - nonce: '0xa4', - to: '0x2', - value: '0x2386f26fc10000', - }, - } - - const transactionGroup = { - transactions: [transaction], - primaryTransaction: transaction, - initialTransaction: transaction, - nonce: '0xa4', - hasRetried: false, - hasCancelled: false, - } - - const wrapper = shallow( - <TransactionListItemDetails - 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, 3) - }) -}) 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 3e39212d3..000000000 --- a/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js +++ /dev/null @@ -1,181 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import copyToClipboard from 'copy-to-clipboard' -import SenderToRecipient from '../sender-to-recipient' -import { FLAT_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, - metricsEvent: PropTypes.func, - } - - static propTypes = { - onCancel: PropTypes.func, - onRetry: PropTypes.func, - showCancel: PropTypes.bool, - showRetry: PropTypes.bool, - transactionGroup: PropTypes.object, - } - - state = { - justCopied: 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) - }) - } - - render () { - const { t } = this.context - const { justCopied } = this.state - const { transactionGroup, showCancel, 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> - ) - } - { - showCancel && ( - <Button - type="raised" - onClick={this.handleCancel} - className="transaction-list-item-details__header-button" - > - { t('cancel') } - </Button> - ) - } - <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/transaction-list-item.component.js b/ui/app/components/transaction-list-item/transaction-list-item.component.js deleted file mode 100644 index e843fe1a0..000000000 --- a/ui/app/components/transaction-list-item/transaction-list-item.component.js +++ /dev/null @@ -1,224 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import Identicon from '../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 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' - -export default class TransactionListItem extends PureComponent { - static propTypes = { - assetImages: PropTypes.object, - history: PropTypes.object, - methodData: PropTypes.object, - nonceAndDate: PropTypes.string, - primaryTransaction: PropTypes.object, - retryTransaction: PropTypes.func, - setSelectedToken: PropTypes.func, - showCancelModal: PropTypes.func, - showCancel: 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 = { - showTransactionDetails: false, - } - - handleClick = () => { - const { - transaction, - history, - } = this.props - const { id, status } = transaction - const { showTransactionDetails } = this.state - - if (status === UNAPPROVED_STATUS) { - history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`) - return - } - - if (!showTransactionDetails) { - this.context.metricsEvent({ - eventOpts: { - category: 'Navigation', - action: 'Home', - name: 'Expand Transaction', - }, - }) - } - - this.setState({ showTransactionDetails: !showTransactionDetails }) - } - - handleCancel = id => { - const { - primaryTransaction: { txParams: { gasPrice } } = {}, - transaction: { id: initialTransactionId }, - showCancelModal, - } = this.props - - const cancelId = id || initialTransactionId - showCancelModal(cancelId, gasPrice) - } - - /** - * @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 { - primaryTransaction: { txParams: { gasPrice } } = {}, - transaction: { txParams: { to } = {}, id: initialTransactionId }, - methodData: { name } = {}, - setSelectedToken, - retryTransaction, - fetchBasicGasAndTimeEstimates, - fetchGasEstimates, - } = this.props - - if (name === TOKEN_METHOD_TRANSFER) { - setSelectedToken(to) - } - - const retryId = id || initialTransactionId - - return fetchBasicGasAndTimeEstimates() - .then(basicEstimates => fetchGasEstimates(basicEstimates.blockTime)) - .then(retryTransaction(retryId, gasPrice)) - } - - renderPrimaryCurrency () { - const { token, primaryTransaction: { txParams: { data } = {} } = {}, value } = this.props - - return token - ? ( - <TokenCurrencyDisplay - className="transaction-list-item__amount transaction-list-item__amount--primary" - token={token} - transactionData={data} - prefix="-" - /> - ) : ( - <UserPreferencedCurrencyDisplay - className="transaction-list-item__amount transaction-list-item__amount--primary" - value={value} - type={PRIMARY} - prefix="-" - /> - ) - } - - renderSecondaryCurrency () { - const { token, value, showFiat } = this.props - - return token || !showFiat - ? null - : ( - <UserPreferencedCurrencyDisplay - className="transaction-list-item__amount transaction-list-item__amount--secondary" - value={value} - prefix="-" - type={SECONDARY} - /> - ) - } - - render () { - const { - assetImages, - transaction, - methodData, - nonceAndDate, - primaryTransaction, - showCancel, - showRetry, - tokenData, - transactionGroup, - } = this.props - const { txParams = {} } = transaction - const { showTransactionDetails } = this.state - const toAddress = tokenData - ? tokenData.params && tokenData.params[0] && tokenData.params[0].value || txParams.to - : txParams.to - - return ( - <div className="transaction-list-item"> - <div - className="transaction-list-item__grid" - onClick={this.handleClick} - > - <Identicon - className="transaction-list-item__identicon" - address={toAddress} - diameter={34} - image={assetImages[toAddress]} - /> - <TransactionAction - transaction={transaction} - methodData={methodData} - className="transaction-list-item__action" - /> - <div - className="transaction-list-item__nonce" - title={nonceAndDate} - > - { nonceAndDate } - </div> - <TransactionStatus - className="transaction-list-item__status" - statusKey={getStatusKey(primaryTransaction)} - title={( - (primaryTransaction.err && primaryTransaction.err.rpc) - ? primaryTransaction.err.rpc.message - : primaryTransaction.err && primaryTransaction.err.message - )} - /> - { this.renderPrimaryCurrency() } - { this.renderSecondaryCurrency() } - </div> - <div className={classnames('transaction-list-item__expander', { - 'transaction-list-item__expander--show': showTransactionDetails, - })}> - { - showTransactionDetails && ( - <div className="transaction-list-item__details-container"> - <TransactionListItemDetails - transactionGroup={transactionGroup} - onRetry={this.handleRetry} - showRetry={showRetry && methodData.done} - onCancel={this.handleCancel} - showCancel={showCancel} - /> - </div> - ) - } - </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 93a82849e..000000000 --- a/ui/app/components/transaction-list-item/transaction-list-item.container.js +++ /dev/null @@ -1,82 +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, showModal, showSidebar, addKnownMethodData } from '../../actions' -import { hexToDecimal } from '../../helpers/conversions.util' -import { getTokenData } from '../../helpers/transactions.util' -import { increaseLastGasPrice } from '../../helpers/confirm-transaction/util' -import { formatDate } from '../../util' -import { - fetchBasicGasAndTimeEstimates, - fetchGasEstimates, - setCustomGasPriceForRetry, - setCustomGasLimit, -} from '../../ducks/gas.duck' -import {getIsMainnet, preferencesSelector} from '../../selectors' - -const mapStateToProps = state => { - const { metamask: { knownMethodData } } = state - const { showFiatInTestnets } = preferencesSelector(state) - const isMainnet = getIsMainnet(state) - - return { - knownMethodData, - showFiat: (isMainnet || !!showFiatInTestnets), - } -} - -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/transaction-list.component.js b/ui/app/components/transaction-list/transaction-list.component.js deleted file mode 100644 index ddab3b290..000000000 --- a/ui/app/components/transaction-list/transaction-list.component.js +++ /dev/null @@ -1,126 +0,0 @@ -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' - -export default class TransactionList extends PureComponent { - static contextTypes = { - t: PropTypes.func, - } - - static defaultProps = { - pendingTransactions: [], - completedTransactions: [], - } - - static propTypes = { - pendingTransactions: PropTypes.array, - completedTransactions: PropTypes.array, - selectedToken: PropTypes.object, - updateNetworkNonce: PropTypes.func, - assetImages: PropTypes.object, - } - - componentDidMount () { - this.props.updateNetworkNonce() - } - - componentDidUpdate (prevProps) { - const { pendingTransactions: prevPendingTransactions = [] } = prevProps - const { pendingTransactions = [], updateNetworkNonce } = this.props - - if (pendingTransactions.length > prevPendingTransactions.length) { - updateNetworkNonce() - } - } - - 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"> - { - pendingLength > 0 && ( - <div className="transaction-list__pending-transactions"> - <div className="transaction-list__header"> - { `${t('queue')} (${pendingTransactions.length})` } - </div> - { - pendingTransactions.map((transactionGroup, index) => ( - this.renderTransaction(transactionGroup, index, true) - )) - } - </div> - ) - } - <div className="transaction-list__completed-transactions"> - <div className="transaction-list__header"> - { t('history') } - </div> - { - completedTransactions.length > 0 - ? completedTransactions.map((transactionGroup, index) => ( - this.renderTransaction(transactionGroup, index) - )) - : this.renderEmpty() - } - </div> - </div> - ) - } - - renderTransaction (transactionGroup, index, isPendingTx = false) { - const { selectedToken, assetImages } = this.props - const { transactions = [] } = transactionGroup - - return transactions[0].key === TRANSACTION_TYPE_SHAPESHIFT - ? ( - <ShapeShiftTransactionListItem - { ...transactions[0] } - key={`shapeshift${index}`} - /> - ) : ( - <TransactionListItem - transactionGroup={transactionGroup} - key={`${transactionGroup.nonce}:${index}`} - showRetry={isPendingTx && this.shouldShowRetry(transactionGroup, index === 0)} - showCancel={isPendingTx && this.shouldShowCancel(transactionGroup)} - token={selectedToken} - assetImages={assetImages} - /> - ) - } - - renderEmpty () { - return ( - <div className="transaction-list__empty"> - <div className="transaction-list__empty-text"> - { this.context.t('noTransactions') } - </div> - </div> - ) - } - - render () { - return ( - <div className="transaction-list"> - { this.renderTransactions() } - </div> - ) - } -} diff --git a/ui/app/components/transaction-list/transaction-list.container.js b/ui/app/components/transaction-list/transaction-list.container.js deleted file mode 100644 index e70ca15c5..000000000 --- a/ui/app/components/transaction-list/transaction-list.container.js +++ /dev/null @@ -1,44 +0,0 @@ -import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom' -import { compose } from 'recompose' -import TransactionList from './transaction-list.component' -import { - nonceSortedCompletedTransactionsSelector, - nonceSortedPendingTransactionsSelector, -} from '../../selectors/transactions' -import { getSelectedAddress, getAssetImages } from '../../selectors' -import { selectedTokenSelector } from '../../selectors/tokens' -import { updateNetworkNonce } from '../../actions' - -const mapStateToProps = state => { - return { - completedTransactions: nonceSortedCompletedTransactionsSelector(state), - pendingTransactions: nonceSortedPendingTransactionsSelector(state), - selectedToken: selectedTokenSelector(state), - selectedAddress: getSelectedAddress(state), - assetImages: getAssetImages(state), - } -} - -const mapDispatchToProps = dispatch => { - return { - updateNetworkNonce: address => dispatch(updateNetworkNonce(address)), - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { selectedAddress, ...restStateProps } = stateProps - const { updateNetworkNonce, ...restDispatchProps } = dispatchProps - - return { - ...restStateProps, - ...restDispatchProps, - ...ownProps, - updateNetworkNonce: () => updateNetworkNonce(selectedAddress), - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps, mergeProps) -)(TransactionList) diff --git a/ui/app/components/transaction-status/tests/transaction-status.component.test.js b/ui/app/components/transaction-status/tests/transaction-status.component.test.js deleted file mode 100644 index f4ddc9206..000000000 --- a/ui/app/components/transaction-status/tests/transaction-status.component.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { mount } from 'enzyme' -import TransactionStatus from '../transaction-status.component' -import Tooltip from '../../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/transaction-status/transaction-status.component.js deleted file mode 100644 index 28544d2cd..000000000 --- a/ui/app/components/transaction-status/transaction-status.component.js +++ /dev/null @@ -1,63 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import Tooltip from '../tooltip-v2' -import { - UNAPPROVED_STATUS, - REJECTED_STATUS, - APPROVED_STATUS, - SIGNED_STATUS, - SUBMITTED_STATUS, - CONFIRMED_STATUS, - FAILED_STATUS, - DROPPED_STATUS, - CANCELLED_STATUS, -} from '../../constants/transactions' - -const statusToClassNameHash = { - [UNAPPROVED_STATUS]: 'transaction-status--unapproved', - [REJECTED_STATUS]: 'transaction-status--rejected', - [APPROVED_STATUS]: 'transaction-status--approved', - [SIGNED_STATUS]: 'transaction-status--signed', - [SUBMITTED_STATUS]: 'transaction-status--submitted', - [CONFIRMED_STATUS]: 'transaction-status--confirmed', - [FAILED_STATUS]: 'transaction-status--failed', - [DROPPED_STATUS]: 'transaction-status--dropped', - [CANCELLED_STATUS]: 'transaction-status--failed', -} - -const statusToTextHash = { - [SUBMITTED_STATUS]: 'pending', -} - -export default class TransactionStatus extends PureComponent { - static defaultProps = { - title: null, - } - - static contextTypes = { - t: PropTypes.func, - } - - static propTypes = { - statusKey: PropTypes.string, - className: PropTypes.string, - title: PropTypes.string, - } - - render () { - const { className, statusKey, title } = this.props - const statusText = this.context.t(statusToTextHash[statusKey] || statusKey) - - return ( - <div className={classnames('transaction-status', className, statusToClassNameHash[statusKey])}> - <Tooltip - position="top" - title={title} - > - { statusText } - </Tooltip> - </div> - ) - } -} diff --git a/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js b/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js deleted file mode 100644 index efc987371..000000000 --- a/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react' -import assert from 'assert' -import { shallow } from 'enzyme' -import sinon from 'sinon' -import TokenBalance from '../../token-balance' -import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display' -import { SEND_ROUTE } from '../../../routes' -import TransactionViewBalance from '../transaction-view-balance.component' - -const propsMethodSpies = { - showDepositModal: sinon.spy(), -} - -const historySpies = { - push: sinon.spy(), -} - -const t = (str1, str2) => str2 ? str1 + str2 : str1 -const metricsEvent = () => ({}) - -describe('TransactionViewBalance Component', () => { - afterEach(() => { - propsMethodSpies.showDepositModal.resetHistory() - historySpies.push.resetHistory() - }) - - it('should render ETH balance properly', () => { - const wrapper = shallow(<TransactionViewBalance - showDepositModal={propsMethodSpies.showDepositModal} - history={historySpies} - network="3" - ethBalance={123} - fiatBalance={456} - currentCurrency="usd" - />, { context: { t, metricsEvent } }) - - assert.equal(wrapper.find('.transaction-view-balance').length, 1) - assert.equal(wrapper.find('.transaction-view-balance__button').length, 2) - assert.equal(wrapper.find(UserPreferencedCurrencyDisplay).length, 2) - - const buttons = wrapper.find('.transaction-view-balance__buttons') - assert.equal(propsMethodSpies.showDepositModal.callCount, 0) - buttons.childAt(0).simulate('click') - assert.equal(propsMethodSpies.showDepositModal.callCount, 1) - assert.equal(historySpies.push.callCount, 0) - buttons.childAt(1).simulate('click') - assert.equal(historySpies.push.callCount, 1) - assert.equal(historySpies.push.getCall(0).args[0], SEND_ROUTE) - }) - - it('should render token balance properly', () => { - const token = { - address: '0x35865238f0bec9d5ce6abff0fdaebe7b853dfcc5', - decimals: '2', - symbol: 'ABC', - } - - const wrapper = shallow(<TransactionViewBalance - showDepositModal={propsMethodSpies.showDepositModal} - history={historySpies} - network="3" - ethBalance={123} - fiatBalance={456} - currentCurrency="usd" - selectedToken={token} - />, { context: { t } }) - - assert.equal(wrapper.find('.transaction-view-balance').length, 1) - assert.equal(wrapper.find('.transaction-view-balance__button').length, 1) - assert.equal(wrapper.find(TokenBalance).length, 1) - }) -}) 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 a18e959b5..000000000 --- a/ui/app/components/transaction-view-balance/transaction-view-balance.component.js +++ /dev/null @@ -1,145 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -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' -import Tooltip from '../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/transaction-view-balance/transaction-view-balance.container.js deleted file mode 100644 index df6d287eb..000000000 --- a/ui/app/components/transaction-view-balance/transaction-view-balance.container.js +++ /dev/null @@ -1,46 +0,0 @@ -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, - getMetaMaskAccounts, - isBalanceCached, - preferencesSelector, - getIsMainnet, -} from '../../selectors' -import { showModal } from '../../actions' - -const mapStateToProps = state => { - const { showFiatInTestnets } = preferencesSelector(state) - const isMainnet = getIsMainnet(state) - const selectedAddress = getSelectedAddress(state) - const { metamask: { network } } = state - const accounts = getMetaMaskAccounts(state) - const account = accounts[selectedAddress] - const { balance } = account - - return { - selectedToken: getSelectedToken(state), - network, - balance, - nativeCurrency: getNativeCurrency(state), - assetImage: getSelectedTokenAssetImage(state), - balanceIsCached: isBalanceCached(state), - showFiat: (isMainnet || !!showFiatInTestnets), - } -} - -const mapDispatchToProps = dispatch => { - return { - showDepositModal: () => dispatch(showModal({ name: 'DEPOSIT_ETHER' })), - } -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(TransactionViewBalance) diff --git a/ui/app/components/ui-migration-annoucement/ui-migration-announcement.container.js b/ui/app/components/ui-migration-annoucement/ui-migration-announcement.container.js deleted file mode 100644 index 6dc993b87..000000000 --- a/ui/app/components/ui-migration-annoucement/ui-migration-announcement.container.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux' -import UiMigrationAnnouncement from './ui-migration-annoucement.component' -import { setCompletedUiMigration } from '../../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/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/account-dropdown-mini/index.js b/ui/app/components/ui/account-dropdown-mini/index.js index cb0839e72..cb0839e72 100644 --- a/ui/app/components/account-dropdown-mini/index.js +++ b/ui/app/components/ui/account-dropdown-mini/index.js 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/balance/index.js b/ui/app/components/ui/balance/index.js index f8fb9ea19..f8fb9ea19 100644 --- a/ui/app/components/balance/index.js +++ b/ui/app/components/ui/balance/index.js diff --git a/ui/app/components/breadcrumbs/breadcrumbs.component.js b/ui/app/components/ui/breadcrumbs/breadcrumbs.component.js index 6644836db..6644836db 100644 --- a/ui/app/components/breadcrumbs/breadcrumbs.component.js +++ b/ui/app/components/ui/breadcrumbs/breadcrumbs.component.js diff --git a/ui/app/components/breadcrumbs/index.js b/ui/app/components/ui/breadcrumbs/index.js index 07a11574f..07a11574f 100644 --- a/ui/app/components/breadcrumbs/index.js +++ b/ui/app/components/ui/breadcrumbs/index.js diff --git a/ui/app/components/breadcrumbs/index.scss b/ui/app/components/ui/breadcrumbs/index.scss index e23aa7970..e23aa7970 100644 --- a/ui/app/components/breadcrumbs/index.scss +++ b/ui/app/components/ui/breadcrumbs/index.scss diff --git a/ui/app/components/breadcrumbs/tests/breadcrumbs.component.test.js b/ui/app/components/ui/breadcrumbs/tests/breadcrumbs.component.test.js index 5013c5b60..5013c5b60 100644 --- a/ui/app/components/breadcrumbs/tests/breadcrumbs.component.test.js +++ b/ui/app/components/ui/breadcrumbs/tests/breadcrumbs.component.test.js diff --git a/ui/app/components/button-group/button-group.component.js b/ui/app/components/ui/button-group/button-group.component.js index 17a281030..17a281030 100644 --- a/ui/app/components/button-group/button-group.component.js +++ b/ui/app/components/ui/button-group/button-group.component.js diff --git a/ui/app/components/ui/button-group/button-group.stories.js b/ui/app/components/ui/button-group/button-group.stories.js new file mode 100644 index 000000000..c58c628b3 --- /dev/null +++ b/ui/app/components/ui/button-group/button-group.stories.js @@ -0,0 +1,49 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { action } from '@storybook/addon-actions' +import ButtonGroup from '.' +import Button from '../button' +import { text, boolean } from '@storybook/addon-knobs/react' + +storiesOf('ButtonGroup', module) + .add('with Buttons', () => + <ButtonGroup + style={{ width: '300px' }} + disabled={boolean('Disabled', false)} + defaultActiveButtonIndex={1} + > + <Button + onClick={action('cheap')} + > + {text('Button1', 'Cheap')} + </Button> + <Button + onClick={action('average')} + > + {text('Button2', 'Average')} + </Button> + <Button + onClick={action('fast')} + > + {text('Button3', 'Fast')} + </Button> + </ButtonGroup> + ) + .add('with a disabled Button', () => + <ButtonGroup + style={{ width: '300px' }} + disabled={boolean('Disabled', false)} + > + <Button + onClick={action('enabled')} + > + {text('Button1', 'Enabled')} + </Button> + <Button + onClick={action('disabled')} + disabled + > + {text('Button2', 'Disabled')} + </Button> + </ButtonGroup> + ) 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 0bece90d6..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 diff --git a/ui/app/components/button/button.component.js b/ui/app/components/ui/button/button.component.js index 5d19219b4..5d19219b4 100644 --- a/ui/app/components/button/button.component.js +++ b/ui/app/components/ui/button/button.component.js diff --git a/ui/app/components/ui/button/button.stories.js b/ui/app/components/ui/button/button.stories.js new file mode 100644 index 000000000..667824a47 --- /dev/null +++ b/ui/app/components/ui/button/button.stories.js @@ -0,0 +1,58 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { action } from '@storybook/addon-actions' +import Button from '.' +import { text } from '@storybook/addon-knobs/react' + +storiesOf('Button', module) + .add('primary', () => + <Button + onClick={action('clicked')} + type="primary" + > + {text('text', 'Click me')} + </Button> + ) + .add('secondary', () => + <Button + onClick={action('clicked')} + type="secondary" + > + {text('text', 'Click me')} + </Button> + ) + .add('default', () => ( + <Button + onClick={action('clicked')} + type="default" + > + {text('text', 'Click me')} + </Button> + )) + .add('large primary', () => ( + <Button + onClick={action('clicked')} + type="primary" + large + > + {text('text', 'Click me')} + </Button> + )) + .add('large secondary', () => ( + <Button + onClick={action('clicked')} + type="secondary" + large + > + {text('text', 'Click me')} + </Button> + )) + .add('large default', () => ( + <Button + onClick={action('clicked')} + type="default" + large + > + {text('text', 'Click me')} + </Button> + )) 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/ui/currency-display/currency-display.component.js b/ui/app/components/ui/currency-display/currency-display.component.js new file mode 100644 index 000000000..04dd89892 --- /dev/null +++ b/ui/app/components/ui/currency-display/currency-display.component.js @@ -0,0 +1,46 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { GWEI } from '../../../helpers/constants/common' + +export default class CurrencyDisplay extends PureComponent { + static propTypes = { + className: PropTypes.string, + displayValue: PropTypes.string, + prefix: PropTypes.string, + prefixComponent: PropTypes.node, + style: PropTypes.object, + suffix: PropTypes.string, + // Used in container + currency: PropTypes.string, + denomination: PropTypes.oneOf([GWEI]), + value: PropTypes.string, + numberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hideLabel: PropTypes.bool, + hideTitle: PropTypes.bool, + } + + render () { + const { className, displayValue, prefix, prefixComponent, style, suffix, hideTitle } = this.props + const text = `${prefix || ''}${displayValue}` + const title = `${text} ${suffix}` + + return ( + <div + className={classnames('currency-display-component', className)} + style={style} + title={!hideTitle && title || null} + > + { prefixComponent } + <span className="currency-display-component__text">{ text }</span> + { + suffix && ( + <span className="currency-display-component__suffix"> + { suffix } + </span> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/ui/currency-display/currency-display.container.js b/ui/app/components/ui/currency-display/currency-display.container.js new file mode 100644 index 000000000..093d99c8e --- /dev/null +++ b/ui/app/components/ui/currency-display/currency-display.container.js @@ -0,0 +1,51 @@ +import { connect } from 'react-redux' +import CurrencyDisplay from './currency-display.component' +import { getValueFromWeiHex, formatCurrency } from '../../../helpers/utils/confirm-tx.util' + +const mapStateToProps = state => { + const { metamask: { nativeCurrency, currentCurrency, conversionRate } } = state + + return { + currentCurrency, + conversionRate, + nativeCurrency, + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { nativeCurrency, currentCurrency, conversionRate, ...restStateProps } = stateProps + const { + value, + numberOfDecimals = 2, + currency, + denomination, + hideLabel, + displayValue: propsDisplayValue, + suffix: propsSuffix, + ...restOwnProps + } = ownProps + + const toCurrency = currency || currentCurrency + + const displayValue = propsDisplayValue || formatCurrency( + getValueFromWeiHex({ + value, + fromCurrency: nativeCurrency, + toCurrency, conversionRate, + numberOfDecimals, + toDenomination: denomination, + }), + toCurrency + ) + const suffix = propsSuffix || (hideLabel ? undefined : toCurrency.toUpperCase()) + + return { + ...restStateProps, + ...dispatchProps, + ...restOwnProps, + displayValue, + suffix, + } +} + +export default connect(mapStateToProps, null, mergeProps)(CurrencyDisplay) 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 9888c366e..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 diff --git a/ui/app/components/ui/currency-input/currency-input.component.js b/ui/app/components/ui/currency-input/currency-input.component.js new file mode 100644 index 000000000..b5be0972b --- /dev/null +++ b/ui/app/components/ui/currency-input/currency-input.component.js @@ -0,0 +1,160 @@ +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/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 + * hex value in WEI. props.value, used as a default or forced value, should be a hex value, which + * 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, + useFiat: PropTypes.bool, + hideFiat: PropTypes.bool, + value: PropTypes.string, + fiatSuffix: PropTypes.string, + nativeSuffix: PropTypes.string, + } + + constructor (props) { + super(props) + + const { value: hexValue } = props + const decimalValue = hexValue ? this.getDecimalValue(props) : 0 + + this.state = { + decimalValue, + hexValue, + isSwapped: false, + } + } + + componentDidUpdate (prevProps) { + const { value: prevPropsHexValue } = prevProps + const { value: propsHexValue } = this.props + const { hexValue: stateHexValue } = this.state + + if (prevPropsHexValue !== propsHexValue && propsHexValue !== stateHexValue) { + const decimalValue = this.getDecimalValue(this.props) + this.setState({ hexValue: propsHexValue, decimalValue }) + } + } + + getDecimalValue (props) { + const { value: hexValue, currentCurrency, conversionRate } = props + const decimalValueString = this.shouldUseFiat() + ? getValueFromWeiHex({ + value: hexValue, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, + }) + : getValueFromWeiHex({ + value: hexValue, toCurrency: ETH, numberOfDecimals: 6, + }) + + 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 { currentCurrency: fromCurrency, conversionRate, onChange } = this.props + + const hexValue = this.shouldUseFiat() + ? getWeiHexFromDecimalValue({ + value: decimalValue, fromCurrency, conversionRate, invertConversionRate: true, + }) + : getWeiHexFromDecimalValue({ + value: decimalValue, fromCurrency: ETH, fromDenomination: ETH, conversionRate, + }) + + this.setState({ hexValue, decimalValue }) + onChange(hexValue) + } + + handleBlur = () => { + this.props.onBlur(this.state.hexValue) + } + + renderConversionComponent () { + const { currentCurrency, nativeCurrency, hideFiat } = this.props + const { hexValue } = this.state + let currency, numberOfDecimals + + if (hideFiat) { + return ( + <div className="currency-input__conversion-component"> + { this.context.t('noConversionRateAvailable') } + </div> + ) + } + + if (this.shouldUseFiat()) { + // Display ETH + currency = nativeCurrency || ETH + numberOfDecimals = 6 + } else { + // Display Fiat + currency = currentCurrency + numberOfDecimals = 2 + } + + return ( + <CurrencyDisplay + className="currency-input__conversion-component" + currency={currency} + value={hexValue} + numberOfDecimals={numberOfDecimals} + /> + ) + } + + render () { + const { fiatSuffix, nativeSuffix, ...restProps } = this.props + const { decimalValue } = this.state + + return ( + <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/ui/currency-input/currency-input.container.js b/ui/app/components/ui/currency-input/currency-input.container.js new file mode 100644 index 000000000..b5d7dfe6d --- /dev/null +++ b/ui/app/components/ui/currency-input/currency-input.container.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux' +import CurrencyInput from './currency-input.component' +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 + + return { + ...stateProps, + ...dispatchProps, + ...ownProps, + nativeSuffix: nativeCurrency || ETH, + fiatSuffix: currentCurrency.toUpperCase(), + } +} + +export default connect(mapStateToProps, null, mergeProps)(CurrencyInput) 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/currency-input/index.scss b/ui/app/components/ui/currency-input/index.scss index f659f5b35..f659f5b35 100644 --- a/ui/app/components/currency-input/index.scss +++ b/ui/app/components/ui/currency-input/index.scss 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 6d4612e3c..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 diff --git a/ui/app/components/currency-input/tests/currency-input.container.test.js b/ui/app/components/ui/currency-input/tests/currency-input.container.test.js index 6109d29b6..6109d29b6 100644 --- a/ui/app/components/currency-input/tests/currency-input.container.test.js +++ b/ui/app/components/ui/currency-input/tests/currency-input.container.test.js 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/ui/eth-balance.js b/ui/app/components/ui/eth-balance.js new file mode 100644 index 000000000..7d577b716 --- /dev/null +++ b/ui/app/components/ui/eth-balance.js @@ -0,0 +1,102 @@ +const { Component } = require('react') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const { inherits } = require('util') +const { + formatBalance, + generateBalanceObject, +} = require('../../helpers/utils/util') +const Tooltip = require('./tooltip.js') +const FiatValue = require('./fiat-value.js') + +module.exports = connect(mapStateToProps)(EthBalanceComponent) +function mapStateToProps (state) { + return { + ticker: state.metamask.ticker, + } +} + +inherits(EthBalanceComponent, Component) +function EthBalanceComponent () { + Component.call(this) +} + +EthBalanceComponent.prototype.render = function () { + const props = this.props + const { ticker, value, style, width, needsParse = true } = props + + const formattedValue = value ? formatBalance(value, 6, needsParse, ticker) : '...' + + return ( + + h('.ether-balance.ether-balance-amount', { + style, + }, [ + h('div', { + style: { + display: 'inline', + width, + }, + }, this.renderBalance(formattedValue)), + ]) + + ) +} +EthBalanceComponent.prototype.renderBalance = function (value) { + if (value === 'None') return value + if (value === '...') return value + + const { + conversionRate, + shorten, + incoming, + currentCurrency, + hideTooltip, + styleOveride = {}, + showFiat = true, + } = this.props + const { fontSize, color, fontFamily, lineHeight } = styleOveride + + const { shortBalance, balance, label } = generateBalanceObject(value, shorten ? 1 : 3) + const balanceToRender = shorten ? shortBalance : balance + + const [ethNumber, ethSuffix] = value.split(' ') + const containerProps = hideTooltip ? {} : { + position: 'bottom', + title: `${ethNumber} ${ethSuffix}`, + } + + return ( + h(hideTooltip ? 'div' : Tooltip, + containerProps, + h('div.flex-column', [ + h('.flex-row', { + style: { + alignItems: 'flex-end', + lineHeight: lineHeight || '13px', + fontFamily: fontFamily || 'Montserrat Light', + textRendering: 'geometricPrecision', + }, + }, [ + h('div', { + style: { + width: '100%', + textAlign: 'right', + fontSize: fontSize || 'inherit', + color: color || 'inherit', + }, + }, incoming ? `+${balanceToRender}` : balanceToRender), + h('div', { + style: { + color: color || '#AEAEAE', + fontSize: fontSize || '12px', + marginLeft: '5px', + }, + }, label), + ]), + + showFiat ? h(FiatValue, { value: this.props.value, conversionRate, currentCurrency }) : null, + ]) + ) + ) +} diff --git a/ui/app/components/ui/export-text-container/export-text-container.component.js b/ui/app/components/ui/export-text-container/export-text-container.component.js new file mode 100644 index 000000000..c632e8f26 --- /dev/null +++ b/ui/app/components/ui/export-text-container/export-text-container.component.js @@ -0,0 +1,45 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const copyToClipboard = require('copy-to-clipboard') +const { exportAsFile } = require('../../../helpers/utils/util') + +class ExportTextContainer extends Component { + render () { + const { text = '', filename = '' } = this.props + const { t } = this.context + + return ( + h('.export-text-container', [ + h('.export-text-container__text-container', [ + h('.export-text-container__text', text), + ]), + h('.export-text-container__buttons-container', [ + h('.export-text-container__button.export-text-container__button--copy', { + onClick: () => copyToClipboard(text), + }, [ + h('img', { src: 'images/copy-to-clipboard.svg' }), + h('.export-text-container__button-text', t('copyToClipboard')), + ]), + h('.export-text-container__button', { + onClick: () => exportAsFile(filename, text), + }, [ + h('img', { src: 'images/download.svg' }), + h('.export-text-container__button-text', t('saveAsCsvFile')), + ]), + ]), + ]) + ) + } +} + +ExportTextContainer.propTypes = { + text: PropTypes.string, + filename: PropTypes.string, +} + +ExportTextContainer.contextTypes = { + t: PropTypes.func, +} + +module.exports = ExportTextContainer 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/ui/fiat-value.js b/ui/app/components/ui/fiat-value.js new file mode 100644 index 000000000..02111ba49 --- /dev/null +++ b/ui/app/components/ui/fiat-value.js @@ -0,0 +1,66 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const formatBalance = require('../../helpers/utils/util').formatBalance + +module.exports = FiatValue + +inherits(FiatValue, Component) +function FiatValue () { + Component.call(this) +} + +FiatValue.prototype.render = function () { + const props = this.props + const { conversionRate, currentCurrency, style } = props + const renderedCurrency = currentCurrency || '' + + const value = formatBalance(props.value, 6) + + if (value === 'None') return value + var fiatDisplayNumber, fiatTooltipNumber + var splitBalance = value.split(' ') + + if (conversionRate !== 0) { + fiatTooltipNumber = Number(splitBalance[0]) * conversionRate + fiatDisplayNumber = fiatTooltipNumber.toFixed(2) + } else { + fiatDisplayNumber = 'N/A' + fiatTooltipNumber = 'Unknown' + } + + return fiatDisplay(fiatDisplayNumber, renderedCurrency.toUpperCase(), style) +} + +function fiatDisplay (fiatDisplayNumber, fiatSuffix, styleOveride = {}) { + const { fontSize, color, fontFamily, lineHeight } = styleOveride + + if (fiatDisplayNumber !== 'N/A') { + return h('.flex-row', { + style: { + alignItems: 'flex-end', + lineHeight: lineHeight || '13px', + fontFamily: fontFamily || 'Montserrat Light', + textRendering: 'geometricPrecision', + }, + }, [ + h('div', { + style: { + width: '100%', + textAlign: 'right', + fontSize: fontSize || '12px', + color: color || '#333333', + }, + }, fiatDisplayNumber), + h('div', { + style: { + color: color || '#AEAEAE', + marginLeft: '5px', + fontSize: fontSize || '12px', + }, + }, fiatSuffix), + ]) + } else { + return h('div') + } +} diff --git a/ui/app/components/ui/hex-to-decimal/hex-to-decimal.component.js b/ui/app/components/ui/hex-to-decimal/hex-to-decimal.component.js new file mode 100644 index 000000000..f03aaf255 --- /dev/null +++ b/ui/app/components/ui/hex-to-decimal/hex-to-decimal.component.js @@ -0,0 +1,21 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { hexToDecimal } from '../../../helpers/utils/conversions.util' + +export default class HexToDecimal extends PureComponent { + static propTypes = { + className: PropTypes.string, + value: PropTypes.string, + } + + render () { + const { className, value } = this.props + const decimalValue = hexToDecimal(value) + + return ( + <div className={className}> + { decimalValue } + </div> + ) + } +} 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/ui/identicon/identicon.component.js b/ui/app/components/ui/identicon/identicon.component.js new file mode 100644 index 000000000..88521247c --- /dev/null +++ b/ui/app/components/ui/identicon/identicon.component.js @@ -0,0 +1,99 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { toDataUrl } from '../../../../lib/blockies' +import contractMap from 'eth-contract-metadata' +import { checksumAddress } from '../../../helpers/utils/util' +import Jazzicon from '../jazzicon' + +const getStyles = diameter => ( + { + height: diameter, + width: diameter, + borderRadius: diameter / 2, + } +) + +export default class Identicon extends PureComponent { + static propTypes = { + address: PropTypes.string, + className: PropTypes.string, + diameter: PropTypes.number, + image: PropTypes.string, + useBlockie: PropTypes.bool, + } + + static defaultProps = { + diameter: 46, + } + + renderImage () { + const { className, diameter, image } = this.props + + return ( + <img + className={classnames('identicon', className)} + src={image} + style={getStyles(diameter)} + /> + ) + } + + renderJazzicon () { + const { address, className, diameter } = this.props + + return ( + <Jazzicon + address={address} + diameter={diameter} + className={classnames('identicon', className)} + style={getStyles(diameter)} + /> + ) + } + + renderBlockie () { + const { address, className, diameter } = this.props + + return ( + <div + className={classnames('identicon', className)} + style={getStyles(diameter)} + > + <img + src={toDataUrl(address)} + height={diameter} + width={diameter} + /> + </div> + ) + } + + render () { + const { className, address, image, diameter, useBlockie } = this.props + + if (image) { + return this.renderImage() + } + + if (address) { + const checksummedAddress = checksumAddress(address) + + if (contractMap[checksummedAddress] && contractMap[checksummedAddress].logo) { + return this.renderJazzicon() + } + + return useBlockie + ? this.renderBlockie() + : this.renderJazzicon() + } + + return ( + <img + className={classnames('balance-icon', className)} + src="./images/eth_logo.svg" + style={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/ui/jazzicon/jazzicon.component.js b/ui/app/components/ui/jazzicon/jazzicon.component.js new file mode 100644 index 000000000..3a17e446f --- /dev/null +++ b/ui/app/components/ui/jazzicon/jazzicon.component.js @@ -0,0 +1,69 @@ +import React, { PureComponent } from 'react' +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' +const iconFactory = iconFactoryGenerator(jazzicon) + +/** + * Wrapper around the jazzicon library to return a React component, as the library returns an + * HTMLDivElement which needs to be appended. + */ +export default class Jazzicon extends PureComponent { + static propTypes = { + address: PropTypes.string.isRequired, + className: PropTypes.string, + diameter: PropTypes.number, + style: PropTypes.object, + } + + static defaultProps = { + diameter: 46, + } + + componentDidMount () { + if (!isNode) { + this.appendJazzicon() + } + } + + componentDidUpdate (prevProps) { + const { address: prevAddress } = prevProps + const { address } = this.props + + if (!isNode && address !== prevAddress) { + this.removeExistingChildren() + this.appendJazzicon() + } + } + + removeExistingChildren () { + // eslint-disable-next-line react/no-find-dom-node + const container = findDOMNode(this) + const { children } = container + + for (let i = 0; i < children.length; i++) { + container.removeChild(children[i]) + } + } + + appendJazzicon () { + // eslint-disable-next-line react/no-find-dom-node + const container = findDOMNode(this) + const { address, diameter } = this.props + const image = iconFactory.iconForAddress(address, diameter) + container.appendChild(image) + } + + render () { + const { className, style } = this.props + + return ( + <div + className={className} + style={style} + /> + ) + } +} 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/lock-icon/index.js b/ui/app/components/ui/lock-icon/index.js index 6b4df0e58..6b4df0e58 100644 --- a/ui/app/components/lock-icon/index.js +++ b/ui/app/components/ui/lock-icon/index.js diff --git a/ui/app/components/lock-icon/lock-icon.component.js b/ui/app/components/ui/lock-icon/lock-icon.component.js index d010cb6b2..d010cb6b2 100644 --- a/ui/app/components/lock-icon/lock-icon.component.js +++ b/ui/app/components/ui/lock-icon/lock-icon.component.js 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 b71a3cb9d..b71a3cb9d 100644 --- a/ui/app/components/page-container/index.scss +++ b/ui/app/components/ui/page-container/index.scss 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 85b16cefe..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 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 08f9c7544..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 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 45dfff517..45dfff517 100644 --- a/ui/app/components/page-container/page-container.component.js +++ b/ui/app/components/ui/page-container/page-container.component.js diff --git a/ui/app/components/page-container/tests/page-container.component.test.js b/ui/app/components/ui/page-container/tests/page-container.component.test.js index e69de29bb..e69de29bb 100644 --- a/ui/app/components/page-container/tests/page-container.component.test.js +++ b/ui/app/components/ui/page-container/tests/page-container.component.test.js diff --git a/ui/app/components/ui/qr-code.js b/ui/app/components/ui/qr-code.js new file mode 100644 index 000000000..351e072e2 --- /dev/null +++ b/ui/app/components/ui/qr-code.js @@ -0,0 +1,63 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +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('../../helpers/utils/util') + +module.exports = connect(mapStateToProps)(QrCodeView) + +function mapStateToProps (state) { + return { + // Qr code is not fetched from state. 'message' and 'data' props are passed instead. + buyView: state.appState.buyView, + warning: state.appState.warning, + } +} + +inherits(QrCodeView, Component) + +function QrCodeView () { + Component.call(this) +} + +QrCodeView.prototype.render = function () { + const props = this.props + const { message, data, network } = props.Qr + const address = `${isHexPrefixed(data) ? 'ethereum:' : ''}${checksumAddress(data, network)}` + const qrImage = qrCode(4, 'M') + qrImage.addData(address) + qrImage.make() + + return h('.div.flex-column.flex-center', [ + Array.isArray(message) + ? h('.message-container', this.renderMultiMessage()) + : message && h('.qr-header', message), + + this.props.warning ? this.props.warning && h('span.error.flex-center', { + style: { + }, + }, + this.props.warning) : null, + + h('.div.qr-wrapper', { + style: {}, + dangerouslySetInnerHTML: { + __html: qrImage.createTableTag(4), + }, + }), + h(ReadOnlyInput, { + wrapperClass: 'ellip-address-wrapper', + inputClass: 'qr-ellip-address', + value: checksumAddress(data, network), + }), + ]) +} + +QrCodeView.prototype.renderMultiMessage = function () { + var Qr = this.props.Qr + var multiMessage = Qr.message.map((message) => h('.qr-message', message)) + return multiMessage +} 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 b21e4e1bb..b21e4e1bb 100644 --- a/ui/app/components/sender-to-recipient/index.scss +++ b/ui/app/components/ui/sender-to-recipient/index.scss diff --git a/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js new file mode 100644 index 000000000..57b595d48 --- /dev/null +++ b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js @@ -0,0 +1,186 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Identicon from '../identicon' +import Tooltip from '../tooltip-v2' +import copyToClipboard from 'copy-to-clipboard' +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 { + static propTypes = { + senderName: PropTypes.string, + senderAddress: PropTypes.string, + recipientName: PropTypes.string, + recipientAddress: PropTypes.string, + t: PropTypes.func, + variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT]), + addressOnly: PropTypes.bool, + assetImage: PropTypes.string, + onRecipientClick: PropTypes.func, + onSenderClick: PropTypes.func, + } + + static defaultProps = { + variant: DEFAULT_VARIANT, + } + + static contextTypes = { + t: PropTypes.func, + } + + state = { + senderAddressCopied: false, + recipientAddressCopied: false, + } + + renderSenderIdenticon () { + return !this.props.addressOnly && ( + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={checksumAddress(this.props.senderAddress)} + diameter={24} + /> + </div> + ) + } + + renderSenderAddress () { + const { t } = this.context + const { senderName, senderAddress, addressOnly } = this.props + const checksummedSenderAddress = checksumAddress(senderAddress) + + return ( + <Tooltip + position="bottom" + title={this.state.senderAddressCopied ? t('copiedExclamation') : t('copyAddress')} + wrapperClassName="sender-to-recipient__tooltip-wrapper" + containerClassName="sender-to-recipient__tooltip-container" + onHidden={() => this.setState({ senderAddressCopied: false })} + > + <div className="sender-to-recipient__name"> + { addressOnly ? `${t('from')}: ${checksummedSenderAddress}` : senderName } + </div> + </Tooltip> + ) + } + + renderRecipientIdenticon () { + const { recipientAddress, assetImage } = this.props + const checksummedRecipientAddress = checksumAddress(recipientAddress) + + return !this.props.addressOnly && ( + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={checksummedRecipientAddress} + diameter={24} + image={assetImage} + /> + </div> + ) + } + + renderRecipientWithAddress () { + const { t } = this.context + const { recipientName, recipientAddress, addressOnly, onRecipientClick } = this.props + const checksummedRecipientAddress = checksumAddress(recipientAddress) + + return ( + <div + className="sender-to-recipient__party sender-to-recipient__party--recipient sender-to-recipient__party--recipient-with-address" + onClick={() => { + this.setState({ recipientAddressCopied: true }) + copyToClipboard(checksummedRecipientAddress) + if (onRecipientClick) { + onRecipientClick() + } + }} + > + { this.renderRecipientIdenticon() } + <Tooltip + position="bottom" + title={this.state.recipientAddressCopied ? t('copiedExclamation') : t('copyAddress')} + wrapperClassName="sender-to-recipient__tooltip-wrapper" + containerClassName="sender-to-recipient__tooltip-container" + onHidden={() => this.setState({ recipientAddressCopied: false })} + > + <div className="sender-to-recipient__name"> + { + addressOnly + ? `${t('to')}: ${checksummedRecipientAddress}` + : (recipientName || this.context.t('newContract')) + } + </div> + </Tooltip> + </div> + ) + } + + renderRecipientWithoutAddress () { + return ( + <div className="sender-to-recipient__party sender-to-recipient__party--recipient"> + { !this.props.addressOnly && <i className="fa fa-file-text-o" /> } + <div className="sender-to-recipient__name"> + { this.context.t('newContract') } + </div> + </div> + ) + } + + renderArrow () { + return this.props.variant === DEFAULT_VARIANT + ? ( + <div className="sender-to-recipient__arrow-container"> + <div className="sender-to-recipient__arrow-circle"> + <img + height={15} + width={15} + src="./images/arrow-right.svg" + /> + </div> + </div> + ) : ( + <div className="sender-to-recipient__arrow-container"> + <img + height={20} + src="./images/caret-right.svg" + /> + </div> + ) + } + + render () { + const { senderAddress, recipientAddress, variant, onSenderClick } = this.props + const checksummedSenderAddress = checksumAddress(senderAddress) + + return ( + <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() } + { this.renderSenderAddress() } + </div> + { this.renderArrow() } + { + recipientAddress + ? this.renderRecipientWithAddress() + : this.renderRecipientWithoutAddress() + } + </div> + ) + } +} 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 f53a5115d..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 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/ui/tabs/index.scss b/ui/app/components/ui/tabs/index.scss new file mode 100644 index 000000000..25143ff35 --- /dev/null +++ b/ui/app/components/ui/tabs/index.scss @@ -0,0 +1,11 @@ +@import 'tab/index'; + +.tabs { + &__list { + display: flex; + justify-content: flex-start; + background-color: #f9fafa; + border-bottom: 1px solid $geyser; + padding: 0 16px; + } +} 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/ui/text-field/text-field.stories.js b/ui/app/components/ui/text-field/text-field.stories.js new file mode 100644 index 000000000..337f78ecf --- /dev/null +++ b/ui/app/components/ui/text-field/text-field.stories.js @@ -0,0 +1,53 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import TextField from '.' + +storiesOf('TextField', module) + .add('text', () => + <TextField + label="Text" + type="text" + /> + ) + .add('password', () => + <TextField + label="Password" + type="password" + /> + ) + .add('error', () => + <TextField + type="text" + label="Name" + error="Invalid value" + /> + ) + .add('Mascara text', () => + <TextField + label="Text" + type="text" + largeLabel + /> + ) + .add('Material text', () => + <TextField + label="Text" + type="text" + material + /> + ) + .add('Material password', () => + <TextField + label="Password" + type="password" + material + /> + ) + .add('Material error', () => + <TextField + type="text" + label="Name" + error="Invalid value" + material + /> + ) 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/token-balance/index.scss b/ui/app/components/ui/token-balance/index.scss index 2ff6dfbc8..2ff6dfbc8 100644 --- a/ui/app/components/token-balance/index.scss +++ b/ui/app/components/ui/token-balance/index.scss diff --git a/ui/app/components/token-balance/token-balance.component.js b/ui/app/components/ui/token-balance/token-balance.component.js index af1a32578..af1a32578 100644 --- a/ui/app/components/token-balance/token-balance.component.js +++ b/ui/app/components/ui/token-balance/token-balance.component.js diff --git a/ui/app/components/ui/token-balance/token-balance.container.js b/ui/app/components/ui/token-balance/token-balance.container.js new file mode 100644 index 000000000..a0f1efc20 --- /dev/null +++ b/ui/app/components/ui/token-balance/token-balance.container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import withTokenTracker from '../../../helpers/higher-order-components/with-token-tracker' +import TokenBalance from './token-balance.component' +import selectors from '../../../selectors/selectors' + +const mapStateToProps = state => { + return { + userAddress: selectors.getSelectedAddress(state), + } +} + +export default compose( + connect(mapStateToProps), + withTokenTracker +)(TokenBalance) 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/ui/token-currency-display/token-currency-display.component.js b/ui/app/components/ui/token-currency-display/token-currency-display.component.js new file mode 100644 index 000000000..3c2722b36 --- /dev/null +++ b/ui/app/components/ui/token-currency-display/token-currency-display.component.js @@ -0,0 +1,57 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +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 = { + transactionData: PropTypes.string, + token: PropTypes.object, + } + + state = { + displayValue: '', + suffix: '', + } + + componentDidMount () { + this.setDisplayValue() + } + + componentDidUpdate (prevProps) { + const { transactionData } = this.props + const { transactionData: prevTransactionData } = prevProps + + if (transactionData !== prevTransactionData) { + this.setDisplayValue() + } + } + + setDisplayValue () { + const { transactionData: data, token } = this.props + const { decimals = '', symbol: suffix = '' } = token + const tokenData = getTokenData(data) + + let displayValue + + if (tokenData && tokenData.params && tokenData.params.length) { + const tokenValue = getTokenValue(tokenData.params) + displayValue = calcTokenAmount(tokenValue, decimals).toString() + } + + this.setState({ displayValue, suffix }) + } + + render () { + const { displayValue, suffix } = this.state + + return ( + <CurrencyDisplay + {...this.props} + 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 881101880..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 diff --git a/ui/app/components/token-input/tests/token-input.container.test.js b/ui/app/components/ui/token-input/tests/token-input.container.test.js index 2b1c102c8..2b1c102c8 100644 --- a/ui/app/components/token-input/tests/token-input.container.test.js +++ b/ui/app/components/ui/token-input/tests/token-input.container.test.js diff --git a/ui/app/components/ui/token-input/token-input.component.js b/ui/app/components/ui/token-input/token-input.component.js new file mode 100644 index 000000000..c28af5fde --- /dev/null +++ b/ui/app/components/ui/token-input/token-input.component.js @@ -0,0 +1,145 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import UnitInput from '../unit-input' +import CurrencyDisplay from '../currency-display' +import { getWeiHexFromDecimalValue } from '../../../helpers/utils/conversions.util' +import ethUtil from 'ethereumjs-util' +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 + * hex value. props.value, used as a default or forced value, should be a hex value, which + * gets converted into a decimal value. + */ +export default class TokenInput extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + currentCurrency: PropTypes.string, + onChange: PropTypes.func, + onBlur: PropTypes.func, + value: PropTypes.string, + suffix: PropTypes.string, + showFiat: PropTypes.bool, + hideConversion: PropTypes.bool, + selectedToken: PropTypes.object, + selectedTokenExchangeRate: PropTypes.number, + } + + constructor (props) { + super(props) + + const { value: hexValue } = props + const decimalValue = hexValue ? this.getValue(props) : 0 + + this.state = { + decimalValue, + hexValue, + } + } + + componentDidUpdate (prevProps) { + const { value: prevPropsHexValue } = prevProps + const { value: propsHexValue } = this.props + const { hexValue: stateHexValue } = this.state + + if (prevPropsHexValue !== propsHexValue && propsHexValue !== stateHexValue) { + const decimalValue = this.getValue(this.props) + this.setState({ hexValue: propsHexValue, decimalValue }) + } + } + + getValue (props) { + const { value: hexValue, selectedToken: { decimals, symbol } = {} } = props + + const multiplier = Math.pow(10, Number(decimals || 0)) + const decimalValueString = conversionUtil(ethUtil.addHexPrefix(hexValue), { + fromNumericBase: 'hex', + toNumericBase: 'dec', + toCurrency: symbol, + conversionRate: multiplier, + invertConversionRate: true, + }) + + return Number(decimalValueString) ? decimalValueString : '' + } + + handleChange = decimalValue => { + const { selectedToken: { decimals } = {}, onChange } = this.props + + const multiplier = Math.pow(10, Number(decimals || 0)) + const hexValue = multiplyCurrencies(decimalValue || 0, multiplier, { toNumericBase: 'hex' }) + + this.setState({ hexValue, decimalValue }) + onChange(hexValue) + } + + handleBlur = () => { + this.props.onBlur(this.state.hexValue) + } + + renderConversionComponent () { + 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 + numberOfDecimals = 2 + } else { + // Display ETH + currency = ETH + numberOfDecimals = 6 + } + + const decimalEthValue = (decimalValue * selectedTokenExchangeRate) || 0 + const hexWeiValue = getWeiHexFromDecimalValue({ + value: decimalEthValue, + fromCurrency: ETH, + fromDenomination: ETH, + }) + + return selectedTokenExchangeRate + ? ( + <CurrencyDisplay + className="currency-input__conversion-component" + currency={currency} + value={hexWeiValue} + numberOfDecimals={numberOfDecimals} + /> + ) : ( + <div className="currency-input__conversion-component"> + { this.context.t('noConversionRateAvailable') } + </div> + ) + } + + render () { + const { suffix, ...restProps } = this.props + const { decimalValue } = this.state + + return ( + <UnitInput + {...restProps} + suffix={suffix} + onChange={this.handleChange} + onBlur={this.handleBlur} + value={decimalValue} + > + { this.renderConversionComponent() } + </UnitInput> + ) + } +} diff --git a/ui/app/components/ui/token-input/token-input.container.js b/ui/app/components/ui/token-input/token-input.container.js new file mode 100644 index 000000000..981cb3598 --- /dev/null +++ b/ui/app/components/ui/token-input/token-input.container.js @@ -0,0 +1,30 @@ +import { connect } from 'react-redux' +import TokenInput from './token-input.component' +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), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { selectedToken } = stateProps + const suffix = selectedToken && selectedToken.symbol + + return { + ...stateProps, + ...dispatchProps, + ...ownProps, + suffix, + } +} + +export default connect(mapStateToProps, null, mergeProps)(TokenInput) diff --git a/ui/app/components/tooltip-v2.js b/ui/app/components/ui/tooltip-v2.js index b54026794..b54026794 100644 --- a/ui/app/components/tooltip-v2.js +++ b/ui/app/components/ui/tooltip-v2.js 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 e4075d225..e4075d225 100644 --- a/ui/app/components/unit-input/index.scss +++ b/ui/app/components/ui/unit-input/index.scss 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/ui/unit-input/unit-input.component.js b/ui/app/components/ui/unit-input/unit-input.component.js new file mode 100644 index 000000000..7b414f177 --- /dev/null +++ b/ui/app/components/ui/unit-input/unit-input.component.js @@ -0,0 +1,108 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { removeLeadingZeroes } from '../../app/send/send.utils' + +/** + * Component that attaches a suffix or unit of measurement trailing user input, ex. 'ETH'. Also + * allows rendering a child component underneath the input to, for example, display conversions of + * the shown suffix. + */ +export default class UnitInput extends PureComponent { + static propTypes = { + children: PropTypes.node, + actionComponent: PropTypes.node, + error: PropTypes.bool, + onBlur: PropTypes.func, + onChange: PropTypes.func, + placeholder: PropTypes.string, + suffix: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + } + + static defaultProps = { + placeholder: '0', + } + + constructor (props) { + super(props) + + this.state = { + value: props.value || '', + } + } + + componentDidUpdate (prevProps) { + const { value: prevPropsValue } = prevProps + const { value: propsValue } = this.props + const { value: stateValue } = this.state + + if (prevPropsValue !== propsValue && propsValue !== stateValue) { + this.setState({ value: propsValue }) + } + } + + handleFocus = () => { + this.unitInput.focus() + } + + handleChange = event => { + const { value: userInput } = event.target + let value = userInput + + if (userInput.length && userInput.length > 1) { + value = removeLeadingZeroes(userInput) + } + + this.setState({ value }) + this.props.onChange(value) + } + + handleBlur = event => { + const { onBlur } = this.props + typeof onBlur === 'function' && onBlur(this.state.value) + } + + getInputWidth (value) { + const valueString = String(value) + const valueLength = valueString.length || 1 + const decimalPointDeficit = valueString.match(/\./) ? -0.5 : 0 + return (valueLength + decimalPointDeficit + 0.5) + 'ch' + } + + render () { + const { error, placeholder, suffix, actionComponent, children } = this.props + const { value } = this.state + + return ( + <div + className={classnames('unit-input', { 'unit-input--error': error })} + onClick={this.handleFocus} + > + <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> + {actionComponent} + </div> + ) + } +} diff --git a/ui/app/components/unit-input/unit-input.component.js b/ui/app/components/unit-input/unit-input.component.js deleted file mode 100644 index 230eecfe6..000000000 --- a/ui/app/components/unit-input/unit-input.component.js +++ /dev/null @@ -1,108 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { removeLeadingZeroes } from '../send/send.utils' - -/** - * Component that attaches a suffix or unit of measurement trailing user input, ex. 'ETH'. Also - * allows rendering a child component underneath the input to, for example, display conversions of - * the shown suffix. - */ -export default class UnitInput extends PureComponent { - static propTypes = { - children: PropTypes.node, - actionComponent: PropTypes.node, - error: PropTypes.bool, - onBlur: PropTypes.func, - onChange: PropTypes.func, - placeholder: PropTypes.string, - suffix: PropTypes.string, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - } - - static defaultProps = { - placeholder: '0', - } - - constructor (props) { - super(props) - - this.state = { - value: props.value || '', - } - } - - componentDidUpdate (prevProps) { - const { value: prevPropsValue } = prevProps - const { value: propsValue } = this.props - const { value: stateValue } = this.state - - if (prevPropsValue !== propsValue && propsValue !== stateValue) { - this.setState({ value: propsValue }) - } - } - - handleFocus = () => { - this.unitInput.focus() - } - - handleChange = event => { - const { value: userInput } = event.target - let value = userInput - - if (userInput.length && userInput.length > 1) { - value = removeLeadingZeroes(userInput) - } - - this.setState({ value }) - this.props.onChange(value) - } - - handleBlur = event => { - const { onBlur } = this.props - typeof onBlur === 'function' && onBlur(this.state.value) - } - - getInputWidth (value) { - const valueString = String(value) - const valueLength = valueString.length || 1 - const decimalPointDeficit = valueString.match(/\./) ? -0.5 : 0 - return (valueLength + decimalPointDeficit + 0.5) + 'ch' - } - - render () { - const { error, placeholder, suffix, actionComponent, children } = this.props - const { value } = this.state - - return ( - <div - className={classnames('unit-input', { 'unit-input--error': error })} - onClick={this.handleFocus} - > - <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> - {actionComponent} - </div> - ) - } -} diff --git a/ui/app/components/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js b/ui/app/components/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js deleted file mode 100644 index ead584c26..000000000 --- a/ui/app/components/user-preferenced-currency-display/tests/user-preferenced-currency-display.component.test.js +++ /dev/null @@ -1,34 +0,0 @@ -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' - -describe('UserPreferencedCurrencyDisplay Component', () => { - describe('rendering', () => { - it('should render properly', () => { - const wrapper = shallow( - <UserPreferencedCurrencyDisplay /> - ) - - assert.ok(wrapper) - assert.equal(wrapper.find(CurrencyDisplay).length, 1) - }) - - it('should pass all props to the CurrencyDisplay child component', () => { - const wrapper = shallow( - <UserPreferencedCurrencyDisplay - prop1={true} - prop2="test" - prop3={1} - /> - ) - - assert.ok(wrapper) - assert.equal(wrapper.find(CurrencyDisplay).length, 1) - assert.equal(wrapper.find(CurrencyDisplay).props().prop1, true) - assert.equal(wrapper.find(CurrencyDisplay).props().prop2, 'test') - assert.equal(wrapper.find(CurrencyDisplay).props().prop3, 1) - }) - }) -}) diff --git a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.component.js b/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.component.js deleted file mode 100644 index d9f29327d..000000000 --- a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.component.js +++ /dev/null @@ -1,47 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import { PRIMARY, SECONDARY, ETH } from '../../constants/common' -import CurrencyDisplay from '../currency-display' - -export default class UserPreferencedCurrencyDisplay extends PureComponent { - static propTypes = { - className: PropTypes.string, - prefix: PropTypes.string, - 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]), - // Used in container - type: PropTypes.oneOf([PRIMARY, SECONDARY]), - ethNumberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - fiatNumberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - ethPrefix: PropTypes.string, - fiatPrefix: PropTypes.string, - // From container - currency: PropTypes.string, - nativeCurrency: PropTypes.string, - } - - renderEthLogo () { - const { currency, showEthLogo, ethLogoHeight = 12 } = this.props - - return currency === ETH && showEthLogo && ( - <img - src="/images/eth.svg" - height={ethLogoHeight} - /> - ) - } - - render () { - return ( - <CurrencyDisplay - {...this.props} - prefixComponent={this.renderEthLogo()} - /> - ) - } -} diff --git a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.container.js b/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.container.js deleted file mode 100644 index 3c5bd0f21..000000000 --- a/ui/app/components/user-preferenced-currency-display/user-preferenced-currency-display.container.js +++ /dev/null @@ -1,67 +0,0 @@ -import { connect } from 'react-redux' -import UserPreferencedCurrencyDisplay from './user-preferenced-currency-display.component' -import { preferencesSelector, getIsMainnet } from '../../selectors' -import { ETH, PRIMARY, SECONDARY } from '../../constants/common' - -const mapStateToProps = (state, ownProps) => { - const { - useNativeCurrencyAsPrimaryCurrency, - showFiatInTestnets, - } = preferencesSelector(state) - - const isMainnet = getIsMainnet(state) - - return { - useNativeCurrencyAsPrimaryCurrency, - showFiatInTestnets, - isMainnet, - nativeCurrency: state.metamask.nativeCurrency, - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { useNativeCurrencyAsPrimaryCurrency, showFiatInTestnets, isMainnet, nativeCurrency, ...restStateProps } = stateProps - const { - type, - numberOfDecimals: propsNumberOfDecimals, - ethNumberOfDecimals, - fiatNumberOfDecimals, - ethPrefix, - fiatPrefix, - prefix: propsPrefix, - ...restOwnProps - } = ownProps - - let currency, numberOfDecimals, prefix - - if (type === PRIMARY && useNativeCurrencyAsPrimaryCurrency || - type === SECONDARY && !useNativeCurrencyAsPrimaryCurrency) { - // Display ETH - currency = nativeCurrency || ETH - numberOfDecimals = propsNumberOfDecimals || ethNumberOfDecimals || 6 - prefix = propsPrefix || ethPrefix - } else if (type === SECONDARY && useNativeCurrencyAsPrimaryCurrency || - type === PRIMARY && !useNativeCurrencyAsPrimaryCurrency) { - // Display Fiat - numberOfDecimals = propsNumberOfDecimals || fiatNumberOfDecimals || 2 - prefix = propsPrefix || fiatPrefix - } - - if (!isMainnet && !showFiatInTestnets) { - currency = nativeCurrency || ETH - numberOfDecimals = propsNumberOfDecimals || ethNumberOfDecimals || 6 - prefix = propsPrefix || ethPrefix - } - - return { - ...restStateProps, - ...dispatchProps, - ...restOwnProps, - nativeCurrency, - currency, - numberOfDecimals, - prefix, - } -} - -export default connect(mapStateToProps, null, mergeProps)(UserPreferencedCurrencyDisplay) diff --git a/ui/app/components/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js b/ui/app/components/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js deleted file mode 100644 index 710b5d519..000000000 --- a/ui/app/components/user-preferenced-currency-input/tests/user-preferenced-currency-input.component.test.js +++ /dev/null @@ -1,32 +0,0 @@ -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' - -describe('UserPreferencedCurrencyInput Component', () => { - describe('rendering', () => { - it('should render properly', () => { - const wrapper = shallow( - <UserPreferencedCurrencyInput /> - ) - - assert.ok(wrapper) - assert.equal(wrapper.find(CurrencyInput).length, 1) - }) - - it('should render useFiat for CurrencyInput based on preferences.useNativeCurrencyAsPrimaryCurrency', () => { - const wrapper = shallow( - <UserPreferencedCurrencyInput - useNativeCurrencyAsPrimaryCurrency - /> - ) - - assert.ok(wrapper) - assert.equal(wrapper.find(CurrencyInput).length, 1) - assert.equal(wrapper.find(CurrencyInput).props().useFiat, false) - wrapper.setProps({ useNativeCurrencyAsPrimaryCurrency: false }) - assert.equal(wrapper.find(CurrencyInput).props().useFiat, true) - }) - }) -}) diff --git a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.component.js b/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.component.js deleted file mode 100644 index 463e66b80..000000000 --- a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.component.js +++ /dev/null @@ -1,20 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import CurrencyInput from '../currency-input' - -export default class UserPreferencedCurrencyInput extends PureComponent { - static propTypes = { - useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, - } - - render () { - const { useNativeCurrencyAsPrimaryCurrency, ...restProps } = this.props - - return ( - <CurrencyInput - {...restProps} - useFiat={!useNativeCurrencyAsPrimaryCurrency} - /> - ) - } -} diff --git a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.container.js b/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.container.js deleted file mode 100644 index 0b88eb5a7..000000000 --- a/ui/app/components/user-preferenced-currency-input/user-preferenced-currency-input.container.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux' -import UserPreferencedCurrencyInput from './user-preferenced-currency-input.component' -import { preferencesSelector } from '../../selectors' - -const mapStateToProps = state => { - const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) - - return { - useNativeCurrencyAsPrimaryCurrency, - } -} - -export default connect(mapStateToProps)(UserPreferencedCurrencyInput) diff --git a/ui/app/components/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js b/ui/app/components/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js deleted file mode 100644 index d85bddeeb..000000000 --- a/ui/app/components/user-preferenced-token-input/tests/user-preferenced-token-input.component.test.js +++ /dev/null @@ -1,32 +0,0 @@ -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' - -describe('UserPreferencedCurrencyInput Component', () => { - describe('rendering', () => { - it('should render properly', () => { - const wrapper = shallow( - <UserPreferencedTokenInput /> - ) - - assert.ok(wrapper) - assert.equal(wrapper.find(TokenInput).length, 1) - }) - - it('should render showFiat for TokenInput based on preferences.useNativeCurrencyAsPrimaryCurrency', () => { - const wrapper = shallow( - <UserPreferencedTokenInput - useNativeCurrencyAsPrimaryCurrency - /> - ) - - assert.ok(wrapper) - assert.equal(wrapper.find(TokenInput).length, 1) - assert.equal(wrapper.find(TokenInput).props().showFiat, false) - wrapper.setProps({ useNativeCurrencyAsPrimaryCurrency: false }) - assert.equal(wrapper.find(TokenInput).props().showFiat, true) - }) - }) -}) diff --git a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.component.js b/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.component.js deleted file mode 100644 index 8f14231ac..000000000 --- a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.component.js +++ /dev/null @@ -1,20 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import TokenInput from '../token-input' - -export default class UserPreferencedTokenInput extends PureComponent { - static propTypes = { - useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, - } - - render () { - const { useNativeCurrencyAsPrimaryCurrency, ...restProps } = this.props - - return ( - <TokenInput - {...restProps} - showFiat={!useNativeCurrencyAsPrimaryCurrency} - /> - ) - } -} diff --git a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.container.js b/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.container.js deleted file mode 100644 index 3305d4e29..000000000 --- a/ui/app/components/user-preferenced-token-input/user-preferenced-token-input.container.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux' -import UserPreferencedTokenInput from './user-preferenced-token-input.component' -import { preferencesSelector } from '../../selectors' - -const mapStateToProps = state => { - const { useNativeCurrencyAsPrimaryCurrency } = preferencesSelector(state) - - return { - useNativeCurrencyAsPrimaryCurrency, - } -} - -export default connect(mapStateToProps)(UserPreferencedTokenInput) diff --git a/ui/app/components/wallet-view.js b/ui/app/components/wallet-view.js deleted file mode 100644 index db572e5cb..000000000 --- a/ui/app/components/wallet-view.js +++ /dev/null @@ -1,246 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -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 AccountDropdowns = require('./dropdowns/index.js').AccountDropdowns -const Tooltip = require('./tooltip-v2.js').default -const copyToClipboard = require('copy-to-clipboard') -const actions = require('../actions') -import BalanceComponent from './balance' -const TokenList = require('./token-list') -const selectors = require('../selectors') -const { ADD_TOKEN_ROUTE } = require('../routes') - -import AddTokenButton from './add-token-button' - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(WalletView) - -WalletView.contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, -} - -WalletView.defaultProps = { - responsiveDisplayClassname: '', -} - -function mapStateToProps (state) { - - return { - network: state.metamask.network, - sidebarOpen: state.appState.sidebar.isOpen, - identities: state.metamask.identities, - accounts: selectors.getMetaMaskAccounts(state), - keyrings: state.metamask.keyrings, - selectedAddress: selectors.getSelectedAddress(state), - selectedAccount: selectors.getSelectedAccount(state), - selectedTokenAddress: state.metamask.selectedTokenAddress, - } -} - -function mapDispatchToProps (dispatch) { - return { - showSendPage: () => dispatch(actions.showSendPage()), - hideSidebar: () => dispatch(actions.hideSidebar()), - unsetSelectedToken: () => dispatch(actions.setSelectedToken()), - showAccountDetailModal: () => { - dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) - }, - showAddTokenPage: () => dispatch(actions.showAddTokenPage()), - } -} - -inherits(WalletView, Component) -function WalletView () { - Component.call(this) - this.state = { - hasCopied: false, - copyToClipboardPressed: false, - } -} - -WalletView.prototype.renderWalletBalance = function () { - const { - selectedTokenAddress, - selectedAccount, - unsetSelectedToken, - hideSidebar, - sidebarOpen, - } = this.props - - const selectedClass = selectedTokenAddress - ? '' - : 'wallet-balance-wrapper--active' - const className = `flex-column wallet-balance-wrapper ${selectedClass}` - - return h('div', { className }, [ - h('div.wallet-balance', - { - onClick: () => { - unsetSelectedToken() - selectedTokenAddress && sidebarOpen && hideSidebar() - }, - }, - [ - h(BalanceComponent, { - balanceValue: selectedAccount ? selectedAccount.balance : '', - style: {}, - }), - ] - ), - ]) -} - -WalletView.prototype.renderAddToken = function () { - const { - sidebarOpen, - 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() - } - }, - }) -} - -WalletView.prototype.render = function () { - const { - responsiveDisplayClassname, - selectedAddress, - keyrings, - showAccountDetailModal, - hideSidebar, - identities, - network, - } = this.props - // temporary logs + fake extra wallets - - const checksummedAddress = checksumAddress(selectedAddress, network) - - if (!selectedAddress) { - throw new Error('selectedAddress should not be ' + String(selectedAddress)) - } - - const keyring = keyrings.find((kr) => { - return kr.accounts.includes(selectedAddress) - }) - - let label = '' - let type - if (keyring) { - type = keyring.type - if (type !== 'HD Key Tree') { - if (type.toLowerCase().search('hardware') !== -1) { - label = this.context.t('hardware') - } else { - label = this.context.t('imported') - } - } - } - - return h('div.wallet-view.flex-column', { - style: {}, - className: responsiveDisplayClassname, - }, [ - - // TODO: Separate component: wallet account details - h('div.flex-column.wallet-view-account-details', { - style: {}, - }, [ - h('div.wallet-view__sidebar-close', { - onClick: hideSidebar, - }), - - h('div.wallet-view__keyring-label.allcaps', label), - - h('div.flex-column.flex-center.wallet-view__name-container', { - style: { margin: '0 auto' }, - onClick: showAccountDetailModal, - }, [ - h(Identicon, { - diameter: 54, - address: checksummedAddress, - }), - - h('span.account-name', { - style: {}, - }, [ - identities[selectedAddress].name, - ]), - - h('button.btn-clear.wallet-view__details-button.allcaps', this.context.t('details')), - ]), - ]), - - h(Tooltip, { - position: 'bottom', - title: this.state.hasCopied ? this.context.t('copiedExclamation') : this.context.t('copyToClipboard'), - wrapperClassName: 'wallet-view__tooltip', - }, [ - h('button.wallet-view__address', { - className: classnames({ - 'wallet-view__address__pressed': this.state.copyToClipboardPressed, - }), - onClick: () => { - copyToClipboard(checksummedAddress) - this.context.metricsEvent({ - eventOpts: { - category: 'Navigation', - action: 'Home', - name: 'Copied Address', - }, - }) - this.setState({ hasCopied: true }) - setTimeout(() => this.setState({ hasCopied: false }), 3000) - }, - onMouseDown: () => { - this.setState({ copyToClipboardPressed: true }) - }, - onMouseUp: () => { - this.setState({ copyToClipboardPressed: false }) - }, - }, [ - `${checksummedAddress.slice(0, 6)}...${checksummedAddress.slice(-4)}`, - h('i.fa.fa-clipboard', { style: { marginLeft: '8px' } }), - ]), - ]), - - this.renderWalletBalance(), - - h(TokenList), - - this.renderAddToken(), - ]) -} - -// TODO: Extra wallets, for dev testing. Remove when PRing to master. -// const extraWallet = h('div.flex-column.wallet-balance-wrapper', {}, [ -// h('div.wallet-balance', {}, [ -// h(BalanceComponent, { -// balanceValue: selectedAccount.balance, -// style: {}, -// }), -// ]), -// ]) diff --git a/ui/app/conf-tx.js b/ui/app/conf-tx.js deleted file mode 100644 index 34f5466e2..000000000 --- a/ui/app/conf-tx.js +++ /dev/null @@ -1,225 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -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 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 { getMetaMaskAccounts } = require('./selectors') - -module.exports = compose( - withRouter, - connect(mapStateToProps) -)(ConfirmTxScreen) - -function mapStateToProps (state) { - const { metamask } = state - const { - unapprovedMsgCount, - unapprovedPersonalMsgCount, - unapprovedTypedMessagesCount, - } = metamask - - return { - identities: state.metamask.identities, - accounts: getMetaMaskAccounts(state), - selectedAddress: state.metamask.selectedAddress, - unapprovedTxs: state.metamask.unapprovedTxs, - unapprovedMsgs: state.metamask.unapprovedMsgs, - unapprovedPersonalMsgs: state.metamask.unapprovedPersonalMsgs, - unapprovedTypedMessages: state.metamask.unapprovedTypedMessages, - index: state.appState.currentView.context, - warning: state.appState.warning, - network: state.metamask.network, - provider: state.metamask.provider, - conversionRate: state.metamask.conversionRate, - currentCurrency: state.metamask.currentCurrency, - blockGasLimit: state.metamask.currentBlockGasLimit, - computedBalances: state.metamask.computedBalances, - unapprovedMsgCount, - unapprovedPersonalMsgCount, - unapprovedTypedMessagesCount, - send: state.metamask.send, - selectedAddressTxList: state.metamask.selectedAddressTxList, - } -} - -inherits(ConfirmTxScreen, Component) -function ConfirmTxScreen () { - Component.call(this) -} - -ConfirmTxScreen.prototype.getUnapprovedMessagesTotal = function () { - const { - unapprovedMsgCount = 0, - unapprovedPersonalMsgCount = 0, - unapprovedTypedMessagesCount = 0, - } = this.props - - return unapprovedTypedMessagesCount + unapprovedMsgCount + unapprovedPersonalMsgCount -} - -ConfirmTxScreen.prototype.componentDidMount = function () { - const { - unapprovedTxs = {}, - network, - send, - } = this.props - const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network) - - if (unconfTxList.length === 0 && !send.to && this.getUnapprovedMessagesTotal() === 0) { - this.props.history.push(DEFAULT_ROUTE) - } -} - -ConfirmTxScreen.prototype.componentDidUpdate = function (prevProps) { - const { - unapprovedTxs = {}, - network, - selectedAddressTxList, - send, - history, - match: { params: { id: transactionId } = {} }, - } = this.props - - let prevTx - - if (transactionId) { - prevTx = R.find(({ id }) => id + '' === transactionId)(selectedAddressTxList) - } else { - const { index: prevIndex, unapprovedTxs: prevUnapprovedTxs } = prevProps - const prevUnconfTxList = txHelper(prevUnapprovedTxs, {}, {}, {}, network) - const prevTxData = prevUnconfTxList[prevIndex] || {} - prevTx = selectedAddressTxList.find(({ id }) => id === prevTxData.id) || {} - } - - const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network) - - if (prevTx && prevTx.status === 'dropped') { - this.props.dispatch(actions.showModal({ - name: 'TRANSACTION_CONFIRMED', - onSubmit: () => history.push(DEFAULT_ROUTE), - })) - - return - } - - if (unconfTxList.length === 0 && !send.to && this.getUnapprovedMessagesTotal() === 0) { - this.props.history.push(DEFAULT_ROUTE) - } -} - -ConfirmTxScreen.prototype.getTxData = function () { - const { - network, - index, - unapprovedTxs, - unapprovedMsgs, - unapprovedPersonalMsgs, - unapprovedTypedMessages, - match: { params: { id: transactionId } = {} }, - } = this.props - - const unconfTxList = txHelper( - unapprovedTxs, - unapprovedMsgs, - unapprovedPersonalMsgs, - unapprovedTypedMessages, - network - ) - - log.info(`rendering a combined ${unconfTxList.length} unconf msgs & txs`) - - return transactionId - ? R.find(({ id }) => id + '' === transactionId)(unconfTxList) - : unconfTxList[index] -} - -ConfirmTxScreen.prototype.render = function () { - const props = this.props - const { - currentCurrency, - conversionRate, - blockGasLimit, - } = props - - var txData = this.getTxData() || {} - const { msgParams } = txData - log.debug('msgParams detected, rendering pending msg') - - return msgParams - ? h(SignatureRequest, { - // Properties - txData: txData, - key: txData.id, - selectedAddress: props.selectedAddress, - accounts: props.accounts, - identities: props.identities, - conversionRate, - currentCurrency, - blockGasLimit, - // Actions - signMessage: this.signMessage.bind(this, txData), - signPersonalMessage: this.signPersonalMessage.bind(this, txData), - signTypedMessage: this.signTypedMessage.bind(this, txData), - cancelMessage: this.cancelMessage.bind(this, txData), - cancelPersonalMessage: this.cancelPersonalMessage.bind(this, txData), - cancelTypedMessage: this.cancelTypedMessage.bind(this, txData), - }) - : h(Loading) -} - -ConfirmTxScreen.prototype.signMessage = function (msgData, event) { - log.info('conf-tx.js: signing message') - var params = msgData.msgParams - params.metamaskId = msgData.id - this.stopPropagation(event) - return this.props.dispatch(actions.signMsg(params)) -} - -ConfirmTxScreen.prototype.stopPropagation = function (event) { - if (event.stopPropagation) { - event.stopPropagation() - } -} - -ConfirmTxScreen.prototype.signPersonalMessage = function (msgData, event) { - log.info('conf-tx.js: signing personal message') - var params = msgData.msgParams - params.metamaskId = msgData.id - this.stopPropagation(event) - return this.props.dispatch(actions.signPersonalMsg(params)) -} - -ConfirmTxScreen.prototype.signTypedMessage = function (msgData, event) { - log.info('conf-tx.js: signing typed message') - var params = msgData.msgParams - params.metamaskId = msgData.id - this.stopPropagation(event) - return this.props.dispatch(actions.signTypedMsg(params)) -} - -ConfirmTxScreen.prototype.cancelMessage = function (msgData, event) { - log.info('canceling message') - this.stopPropagation(event) - return this.props.dispatch(actions.cancelMsg(msgData)) -} - -ConfirmTxScreen.prototype.cancelPersonalMessage = function (msgData, event) { - log.info('canceling personal message') - this.stopPropagation(event) - return this.props.dispatch(actions.cancelPersonalMsg(msgData)) -} - -ConfirmTxScreen.prototype.cancelTypedMessage = function (msgData, event) { - log.info('canceling typed message') - this.stopPropagation(event) - return this.props.dispatch(actions.cancelTypedMsg(msgData)) -} diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index 7eaf60ce8..f2f37bfa3 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -52,4 +52,4 @@ @import './tooltip.scss'; -@import '../../../components/index'; +@import '../../../components/app/index'; diff --git a/ui/app/ducks/app/app.js b/ui/app/ducks/app/app.js new file mode 100644 index 000000000..acbb5c989 --- /dev/null +++ b/ui/app/ducks/app/app.js @@ -0,0 +1,788 @@ +const extend = require('xtend') +const actions = require('../../store/actions') +const txHelper = require('../../../lib/tx-helper') +const log = require('loglevel') + +module.exports = reduceApp + + +function reduceApp (state, action) { + log.debug('App Reducer got ' + action.type) + // clone and defaults + const selectedAddress = state.metamask.selectedAddress + const hasUnconfActions = checkUnconfActions(state) + let name = 'accounts' + if (selectedAddress) { + name = 'accountDetail' + } + + if (hasUnconfActions) { + log.debug('pending txs detected, defaulting to conf-tx view.') + name = 'confTx' + } + + var defaultView = { + name, + detailView: null, + context: selectedAddress, + } + + // confirm seed words + var seedWords = state.metamask.seedWords + var seedConfView = { + name: 'createVaultComplete', + seedWords, + } + + // default state + var appState = extend({ + shouldClose: false, + menuOpen: false, + modal: { + open: false, + modalState: { + name: null, + props: {}, + }, + previousModalState: { + name: null, + }, + }, + sidebar: { + isOpen: false, + transitionName: '', + type: '', + props: {}, + }, + alertOpen: false, + alertMessage: null, + qrCodeData: null, + networkDropdownOpen: false, + currentView: seedWords ? seedConfView : defaultView, + accountDetail: { + subview: 'transactions', + }, + // Used to render transition direction + transForward: true, + // Used to display loading indicator + isLoading: false, + // Used to display error text + warning: null, + buyView: {}, + isMouseUser: false, + gasIsLoading: false, + networkNonce: null, + defaultHdPaths: { + trezor: `m/44'/60'/0'/0`, + ledger: `m/44'/60'/0'/0/0`, + }, + lastSelectedProvider: null, + }, state.appState) + + switch (action.type) { + // dropdown methods + case actions.NETWORK_DROPDOWN_OPEN: + return extend(appState, { + networkDropdownOpen: true, + }) + + case actions.NETWORK_DROPDOWN_CLOSE: + return extend(appState, { + networkDropdownOpen: false, + }) + + // sidebar methods + case actions.SIDEBAR_OPEN: + return extend(appState, { + sidebar: { + ...action.value, + isOpen: true, + }, + }) + + case actions.SIDEBAR_CLOSE: + return extend(appState, { + sidebar: { + ...appState.sidebar, + isOpen: false, + }, + }) + + // alert methods + case actions.ALERT_OPEN: + return extend(appState, { + alertOpen: true, + alertMessage: action.value, + }) + + case actions.ALERT_CLOSE: + return extend(appState, { + alertOpen: false, + alertMessage: null, + }) + + // qr scanner methods + case actions.QR_CODE_DETECTED: + return extend(appState, { + qrCodeData: action.value, + }) + + + // modal methods: + case actions.MODAL_OPEN: + const { name, ...modalProps } = action.payload + + return extend(appState, { + modal: { + open: true, + modalState: { + name: name, + props: { ...modalProps }, + }, + previousModalState: { ...appState.modal.modalState }, + }, + }) + + case actions.MODAL_CLOSE: + return extend(appState, { + modal: Object.assign( + state.appState.modal, + { open: false }, + { modalState: { name: null, props: {} } }, + { previousModalState: appState.modal.modalState}, + ), + }) + + // transition methods + case actions.TRANSITION_FORWARD: + return extend(appState, { + transForward: true, + }) + + case actions.TRANSITION_BACKWARD: + return extend(appState, { + transForward: false, + }) + + // intialize + + case actions.SHOW_CREATE_VAULT: + return extend(appState, { + currentView: { + name: 'createVault', + }, + transForward: true, + warning: null, + }) + + case actions.SHOW_RESTORE_VAULT: + return extend(appState, { + currentView: { + name: 'restoreVault', + }, + transForward: true, + forgottenPassword: true, + }) + + case actions.FORGOT_PASSWORD: + const newState = extend(appState, { + forgottenPassword: action.value, + }) + + if (action.value) { + newState.currentView = { + name: 'restoreVault', + } + } + + return newState + + case actions.SHOW_INIT_MENU: + return extend(appState, { + currentView: defaultView, + transForward: false, + }) + + case actions.SHOW_CONFIG_PAGE: + return extend(appState, { + currentView: { + name: 'config', + context: appState.currentView.context, + }, + transForward: action.value, + }) + + case actions.SHOW_ADD_TOKEN_PAGE: + return extend(appState, { + currentView: { + name: 'add-token', + context: appState.currentView.context, + }, + transForward: action.value, + }) + + case actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE: + return extend(appState, { + currentView: { + name: 'add-suggested-token', + context: appState.currentView.context, + }, + transForward: action.value, + }) + + case actions.SHOW_IMPORT_PAGE: + return extend(appState, { + currentView: { + name: 'import-menu', + }, + transForward: true, + warning: null, + }) + + case actions.SHOW_NEW_ACCOUNT_PAGE: + return extend(appState, { + currentView: { + name: 'new-account-page', + context: action.formToSelect, + }, + transForward: true, + warning: null, + }) + + case actions.SET_NEW_ACCOUNT_FORM: + return extend(appState, { + currentView: { + name: appState.currentView.name, + context: action.formToSelect, + }, + }) + + case actions.SHOW_INFO_PAGE: + return extend(appState, { + currentView: { + name: 'info', + context: appState.currentView.context, + }, + transForward: true, + }) + + case actions.CREATE_NEW_VAULT_IN_PROGRESS: + return extend(appState, { + currentView: { + name: 'createVault', + inProgress: true, + }, + transForward: true, + isLoading: true, + }) + + case actions.SHOW_NEW_VAULT_SEED: + return extend(appState, { + currentView: { + name: 'createVaultComplete', + seedWords: action.value, + }, + transForward: true, + isLoading: false, + }) + + case actions.NEW_ACCOUNT_SCREEN: + return extend(appState, { + currentView: { + name: 'new-account', + context: appState.currentView.context, + }, + transForward: true, + }) + + case actions.SHOW_SEND_PAGE: + return extend(appState, { + currentView: { + name: 'sendTransaction', + context: appState.currentView.context, + }, + transForward: true, + warning: null, + }) + + case actions.SHOW_SEND_TOKEN_PAGE: + return extend(appState, { + currentView: { + name: 'sendToken', + context: appState.currentView.context, + }, + transForward: true, + warning: null, + }) + + case actions.SHOW_NEW_KEYCHAIN: + return extend(appState, { + currentView: { + name: 'newKeychain', + context: appState.currentView.context, + }, + transForward: true, + }) + + // unlock + + case actions.UNLOCK_METAMASK: + return extend(appState, { + forgottenPassword: appState.forgottenPassword ? !appState.forgottenPassword : null, + detailView: {}, + transForward: true, + isLoading: false, + warning: null, + }) + + case actions.LOCK_METAMASK: + return extend(appState, { + currentView: defaultView, + transForward: false, + warning: null, + }) + + case actions.BACK_TO_INIT_MENU: + return extend(appState, { + warning: null, + transForward: false, + forgottenPassword: true, + currentView: { + name: 'InitMenu', + }, + }) + + case actions.BACK_TO_UNLOCK_VIEW: + return extend(appState, { + warning: null, + transForward: true, + forgottenPassword: false, + currentView: { + name: 'UnlockScreen', + }, + }) + // reveal seed words + + case actions.REVEAL_SEED_CONFIRMATION: + return extend(appState, { + currentView: { + name: 'reveal-seed-conf', + }, + transForward: true, + warning: null, + }) + + // accounts + + case actions.SET_SELECTED_ACCOUNT: + return extend(appState, { + activeAddress: action.value, + }) + + case actions.GO_HOME: + return extend(appState, { + currentView: extend(appState.currentView, { + name: 'accountDetail', + }), + accountDetail: { + subview: 'transactions', + accountExport: 'none', + privateKey: '', + }, + transForward: false, + warning: null, + }) + + case actions.SHOW_ACCOUNT_DETAIL: + return extend(appState, { + forgottenPassword: appState.forgottenPassword ? !appState.forgottenPassword : null, + currentView: { + name: 'accountDetail', + context: action.value, + }, + accountDetail: { + subview: 'transactions', + accountExport: 'none', + privateKey: '', + }, + transForward: false, + }) + + case actions.BACK_TO_ACCOUNT_DETAIL: + return extend(appState, { + currentView: { + name: 'accountDetail', + context: action.value, + }, + accountDetail: { + subview: 'transactions', + accountExport: 'none', + privateKey: '', + }, + transForward: false, + }) + + case actions.SHOW_ACCOUNTS_PAGE: + return extend(appState, { + currentView: { + name: seedWords ? 'createVaultComplete' : 'accounts', + seedWords, + }, + transForward: true, + isLoading: false, + warning: null, + scrollToBottom: false, + forgottenPassword: false, + }) + + case actions.SHOW_NOTICE: + return extend(appState, { + transForward: true, + isLoading: false, + }) + + case actions.REVEAL_ACCOUNT: + return extend(appState, { + scrollToBottom: true, + }) + + case actions.SHOW_CONF_TX_PAGE: + return extend(appState, { + currentView: { + name: 'confTx', + context: action.id ? indexForPending(state, action.id) : 0, + }, + transForward: action.transForward, + warning: null, + isLoading: false, + }) + + case actions.SHOW_CONF_MSG_PAGE: + return extend(appState, { + currentView: { + name: hasUnconfActions ? 'confTx' : 'account-detail', + context: 0, + }, + transForward: true, + warning: null, + isLoading: false, + }) + + case actions.COMPLETED_TX: + log.debug('reducing COMPLETED_TX for tx ' + action.value) + const otherUnconfActions = getUnconfActionList(state) + .filter(tx => tx.id !== action.value) + const hasOtherUnconfActions = otherUnconfActions.length > 0 + + if (hasOtherUnconfActions) { + log.debug('reducer detected txs - rendering confTx view') + return extend(appState, { + transForward: false, + currentView: { + name: 'confTx', + context: 0, + }, + warning: null, + }) + } else { + log.debug('attempting to close popup') + return extend(appState, { + // indicate notification should close + shouldClose: true, + transForward: false, + warning: null, + currentView: { + name: 'accountDetail', + context: state.metamask.selectedAddress, + }, + accountDetail: { + subview: 'transactions', + }, + }) + } + + case actions.NEXT_TX: + return extend(appState, { + transForward: true, + currentView: { + name: 'confTx', + context: ++appState.currentView.context, + warning: null, + }, + }) + + case actions.VIEW_PENDING_TX: + const context = indexForPending(state, action.value) + return extend(appState, { + transForward: true, + currentView: { + name: 'confTx', + context, + warning: null, + }, + }) + + case actions.PREVIOUS_TX: + return extend(appState, { + transForward: false, + currentView: { + name: 'confTx', + context: --appState.currentView.context, + warning: null, + }, + }) + + case actions.TRANSACTION_ERROR: + return extend(appState, { + currentView: { + name: 'confTx', + errorMessage: 'There was a problem submitting this transaction.', + }, + }) + + case actions.UNLOCK_FAILED: + return extend(appState, { + warning: action.value || 'Incorrect password. Try again.', + }) + + case actions.UNLOCK_SUCCEEDED: + return extend(appState, { + warning: '', + }) + + case actions.SET_HARDWARE_WALLET_DEFAULT_HD_PATH: + const { device, path } = action.value + const newDefaults = {...appState.defaultHdPaths} + newDefaults[device] = path + + return extend(appState, { + defaultHdPaths: newDefaults, + }) + + case actions.SHOW_LOADING: + return extend(appState, { + isLoading: true, + loadingMessage: action.value, + }) + + case actions.HIDE_LOADING: + return extend(appState, { + isLoading: false, + }) + + case actions.SHOW_SUB_LOADING_INDICATION: + return extend(appState, { + isSubLoading: true, + }) + + case actions.HIDE_SUB_LOADING_INDICATION: + return extend(appState, { + isSubLoading: false, + }) + case actions.CLEAR_SEED_WORD_CACHE: + return extend(appState, { + transForward: true, + currentView: {}, + isLoading: false, + accountDetail: { + subview: 'transactions', + accountExport: 'none', + privateKey: '', + }, + }) + + case actions.DISPLAY_WARNING: + return extend(appState, { + warning: action.value, + isLoading: false, + }) + + case actions.HIDE_WARNING: + return extend(appState, { + warning: undefined, + }) + + case actions.REQUEST_ACCOUNT_EXPORT: + return extend(appState, { + transForward: true, + currentView: { + name: 'accountDetail', + context: appState.currentView.context, + }, + accountDetail: { + subview: 'export', + accountExport: 'requested', + }, + }) + + case actions.EXPORT_ACCOUNT: + return extend(appState, { + accountDetail: { + subview: 'export', + accountExport: 'completed', + }, + }) + + case actions.SHOW_PRIVATE_KEY: + return extend(appState, { + accountDetail: { + subview: 'export', + accountExport: 'completed', + privateKey: action.value, + }, + }) + + case actions.BUY_ETH_VIEW: + return extend(appState, { + transForward: true, + currentView: { + name: 'buyEth', + context: appState.currentView.name, + }, + identity: state.metamask.identities[action.value], + buyView: { + subview: 'Coinbase', + amount: '15.00', + buyAddress: action.value, + formView: { + coinbase: true, + shapeshift: false, + }, + }, + }) + + case actions.ONBOARDING_BUY_ETH_VIEW: + return extend(appState, { + transForward: true, + currentView: { + name: 'onboardingBuyEth', + context: appState.currentView.name, + }, + identity: state.metamask.identities[action.value], + }) + + case actions.COINBASE_SUBVIEW: + return extend(appState, { + buyView: { + subview: 'Coinbase', + formView: { + coinbase: true, + shapeshift: false, + }, + buyAddress: appState.buyView.buyAddress, + amount: appState.buyView.amount, + }, + }) + + case actions.SHAPESHIFT_SUBVIEW: + return extend(appState, { + buyView: { + subview: 'ShapeShift', + formView: { + coinbase: false, + shapeshift: true, + marketinfo: action.value.marketinfo, + coinOptions: action.value.coinOptions, + }, + buyAddress: action.value.buyAddress || appState.buyView.buyAddress, + amount: appState.buyView.amount || 0, + }, + }) + + case actions.PAIR_UPDATE: + return extend(appState, { + buyView: { + subview: 'ShapeShift', + formView: { + coinbase: false, + shapeshift: true, + marketinfo: action.value.marketinfo, + coinOptions: appState.buyView.formView.coinOptions, + }, + buyAddress: appState.buyView.buyAddress, + amount: appState.buyView.amount, + warning: null, + }, + }) + + case actions.SHOW_QR: + return extend(appState, { + qrRequested: true, + transForward: true, + + Qr: { + message: action.value.message, + data: action.value.data, + }, + }) + + case actions.SHOW_QR_VIEW: + return extend(appState, { + currentView: { + name: 'qr', + context: appState.currentView.context, + }, + transForward: true, + Qr: { + message: action.value.message, + data: action.value.data, + }, + }) + + case actions.SET_MOUSE_USER_STATE: + return extend(appState, { + isMouseUser: action.value, + }) + + case actions.GAS_LOADING_STARTED: + return extend(appState, { + gasIsLoading: true, + }) + + case actions.GAS_LOADING_FINISHED: + return extend(appState, { + gasIsLoading: false, + }) + + case actions.SET_NETWORK_NONCE: + return extend(appState, { + networkNonce: action.value, + }) + + case actions.SET_PREVIOUS_PROVIDER: + if (action.value === 'loading') { + return appState + } + return extend(appState, { + lastSelectedProvider: action.value, + }) + + default: + return appState + } +} + +function checkUnconfActions (state) { + const unconfActionList = getUnconfActionList(state) + const hasUnconfActions = unconfActionList.length > 0 + return hasUnconfActions +} + +function getUnconfActionList (state) { + const { unapprovedTxs, unapprovedMsgs, + unapprovedPersonalMsgs, unapprovedTypedMessages, network } = state.metamask + + const unconfActionList = txHelper(unapprovedTxs, unapprovedMsgs, unapprovedPersonalMsgs, unapprovedTypedMessages, network) + return unconfActionList +} + +function indexForPending (state, txId) { + const unconfTxList = getUnconfActionList(state) + const match = unconfTxList.find((tx) => tx.id === txId) + const index = unconfTxList.indexOf(match) + return index +} + +// function indexForLastPending (state) { +// return getUnconfActionList(state).length +// } diff --git a/ui/app/ducks/confirm-transaction.duck.js b/ui/app/ducks/confirm-transaction.duck.js deleted file mode 100644 index f75ff809a..000000000 --- a/ui/app/ducks/confirm-transaction.duck.js +++ /dev/null @@ -1,420 +0,0 @@ -import { - conversionRateSelector, - currentCurrencySelector, - unconfirmedTransactionsHashSelector, - getNativeCurrency, -} from '../selectors/confirm-transaction' - -import { - getValueFromWeiHex, - getTransactionFee, - getHexGasTotal, - addFiat, - addEth, - increaseLastGasPrice, - hexGreaterThan, -} from '../helpers/confirm-transaction/util' - -import { - getTokenData, - getMethodData, - isSmartContractAddress, - sumHexes, -} from '../helpers/transactions.util' - -import { getSymbolAndDecimals } from '../token-util' -import { conversionUtil } from '../conversion-util' -import { addHexPrefix } from 'ethereumjs-util' - -// Actions -const createActionType = action => `metamask/confirm-transaction/${action}` - -const UPDATE_TX_DATA = createActionType('UPDATE_TX_DATA') -const CLEAR_TX_DATA = createActionType('CLEAR_TX_DATA') -const UPDATE_TOKEN_DATA = createActionType('UPDATE_TOKEN_DATA') -const CLEAR_TOKEN_DATA = createActionType('CLEAR_TOKEN_DATA') -const UPDATE_METHOD_DATA = createActionType('UPDATE_METHOD_DATA') -const CLEAR_METHOD_DATA = createActionType('CLEAR_METHOD_DATA') -const CLEAR_CONFIRM_TRANSACTION = createActionType('CLEAR_CONFIRM_TRANSACTION') -const UPDATE_TRANSACTION_AMOUNTS = createActionType('UPDATE_TRANSACTION_AMOUNTS') -const UPDATE_TRANSACTION_FEES = createActionType('UPDATE_TRANSACTION_FEES') -const UPDATE_TRANSACTION_TOTALS = createActionType('UPDATE_TRANSACTION_TOTALS') -const UPDATE_TOKEN_PROPS = createActionType('UPDATE_TOKEN_PROPS') -const UPDATE_NONCE = createActionType('UPDATE_NONCE') -const UPDATE_TO_SMART_CONTRACT = createActionType('UPDATE_TO_SMART_CONTRACT') -const FETCH_DATA_START = createActionType('FETCH_DATA_START') -const FETCH_DATA_END = createActionType('FETCH_DATA_END') - -// Initial state -const initState = { - txData: {}, - tokenData: {}, - methodData: {}, - tokenProps: { - tokenDecimals: '', - tokenSymbol: '', - }, - fiatTransactionAmount: '', - fiatTransactionFee: '', - fiatTransactionTotal: '', - ethTransactionAmount: '', - ethTransactionFee: '', - ethTransactionTotal: '', - hexTransactionAmount: '', - hexTransactionFee: '', - hexTransactionTotal: '', - nonce: '', - toSmartContract: false, - fetchingData: false, -} - -// Reducer -export default function reducer ({ confirmTransaction: confirmState = initState }, action = {}) { - switch (action.type) { - case UPDATE_TX_DATA: - return { - ...confirmState, - txData: { - ...action.payload, - }, - } - case CLEAR_TX_DATA: - return { - ...confirmState, - txData: {}, - } - case UPDATE_TOKEN_DATA: - return { - ...confirmState, - tokenData: { - ...action.payload, - }, - } - case CLEAR_TOKEN_DATA: - return { - ...confirmState, - tokenData: {}, - } - case UPDATE_METHOD_DATA: - return { - ...confirmState, - methodData: { - ...action.payload, - }, - } - case CLEAR_METHOD_DATA: - return { - ...confirmState, - methodData: {}, - } - case UPDATE_TRANSACTION_AMOUNTS: - const { fiatTransactionAmount, ethTransactionAmount, hexTransactionAmount } = action.payload - return { - ...confirmState, - fiatTransactionAmount: fiatTransactionAmount || confirmState.fiatTransactionAmount, - ethTransactionAmount: ethTransactionAmount || confirmState.ethTransactionAmount, - hexTransactionAmount: hexTransactionAmount || confirmState.hexTransactionAmount, - } - case UPDATE_TRANSACTION_FEES: - const { fiatTransactionFee, ethTransactionFee, hexTransactionFee } = action.payload - return { - ...confirmState, - fiatTransactionFee: fiatTransactionFee || confirmState.fiatTransactionFee, - ethTransactionFee: ethTransactionFee || confirmState.ethTransactionFee, - hexTransactionFee: hexTransactionFee || confirmState.hexTransactionFee, - } - case UPDATE_TRANSACTION_TOTALS: - const { fiatTransactionTotal, ethTransactionTotal, hexTransactionTotal } = action.payload - return { - ...confirmState, - fiatTransactionTotal: fiatTransactionTotal || confirmState.fiatTransactionTotal, - ethTransactionTotal: ethTransactionTotal || confirmState.ethTransactionTotal, - hexTransactionTotal: hexTransactionTotal || confirmState.hexTransactionTotal, - } - case UPDATE_TOKEN_PROPS: - const { tokenSymbol = '', tokenDecimals = '' } = action.payload - return { - ...confirmState, - tokenProps: { - ...confirmState.tokenProps, - tokenSymbol, - tokenDecimals, - }, - } - case UPDATE_NONCE: - return { - ...confirmState, - nonce: action.payload, - } - case UPDATE_TO_SMART_CONTRACT: - return { - ...confirmState, - toSmartContract: action.payload, - } - case FETCH_DATA_START: - return { - ...confirmState, - fetchingData: true, - } - case FETCH_DATA_END: - return { - ...confirmState, - fetchingData: false, - } - case CLEAR_CONFIRM_TRANSACTION: - return initState - default: - return confirmState - } -} - -// Action Creators -export function updateTxData (txData) { - return { - type: UPDATE_TX_DATA, - payload: txData, - } -} - -export function clearTxData () { - return { - type: CLEAR_TX_DATA, - } -} - -export function updateTokenData (tokenData) { - return { - type: UPDATE_TOKEN_DATA, - payload: tokenData, - } -} - -export function clearTokenData () { - return { - type: CLEAR_TOKEN_DATA, - } -} - -export function updateMethodData (methodData) { - return { - type: UPDATE_METHOD_DATA, - payload: methodData, - } -} - -export function clearMethodData () { - return { - type: CLEAR_METHOD_DATA, - } -} - -export function updateTransactionAmounts (amounts) { - return { - type: UPDATE_TRANSACTION_AMOUNTS, - payload: amounts, - } -} - -export function updateTransactionFees (fees) { - return { - type: UPDATE_TRANSACTION_FEES, - payload: fees, - } -} - -export function updateTransactionTotals (totals) { - return { - type: UPDATE_TRANSACTION_TOTALS, - payload: totals, - } -} - -export function updateTokenProps (tokenProps) { - return { - type: UPDATE_TOKEN_PROPS, - payload: tokenProps, - } -} - -export function updateNonce (nonce) { - return { - type: UPDATE_NONCE, - payload: nonce, - } -} - -export function updateToSmartContract (toSmartContract) { - return { - type: UPDATE_TO_SMART_CONTRACT, - payload: toSmartContract, - } -} - -export function setFetchingData (isFetching) { - return { - type: isFetching ? FETCH_DATA_START : FETCH_DATA_END, - } -} - -export function updateGasAndCalculate ({ gasLimit, gasPrice }) { - gasLimit = addHexPrefix(gasLimit) - gasPrice = addHexPrefix(gasPrice) - return (dispatch, getState) => { - const { confirmTransaction: { txData } } = getState() - const newTxData = { - ...txData, - txParams: { - ...txData.txParams, - gas: gasLimit, - gasPrice, - }, - } - - dispatch(updateTxDataAndCalculate(newTxData)) - } -} - -function increaseFromLastGasPrice (txData) { - const { lastGasPrice, txParams: { gasPrice: previousGasPrice } = {} } = txData - - // Set the minimum to a 10% increase from the lastGasPrice. - const minimumGasPrice = increaseLastGasPrice(lastGasPrice) - const gasPriceBelowMinimum = hexGreaterThan(minimumGasPrice, previousGasPrice) - const gasPrice = (!previousGasPrice || gasPriceBelowMinimum) ? minimumGasPrice : previousGasPrice - - return { - ...txData, - txParams: { - ...txData.txParams, - gasPrice, - }, - } -} - -export function updateTxDataAndCalculate (txData) { - return (dispatch, getState) => { - const state = getState() - const currentCurrency = currentCurrencySelector(state) - const conversionRate = conversionRateSelector(state) - const nativeCurrency = getNativeCurrency(state) - - dispatch(updateTxData(txData)) - - const { txParams: { value = '0x0', gas: gasLimit = '0x0', gasPrice = '0x0' } = {} } = txData - - const fiatTransactionAmount = getValueFromWeiHex({ - value, fromCurrency: nativeCurrency, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, - }) - const ethTransactionAmount = getValueFromWeiHex({ - value, fromCurrency: nativeCurrency, toCurrency: nativeCurrency, conversionRate, numberOfDecimals: 6, - }) - - dispatch(updateTransactionAmounts({ - fiatTransactionAmount, - ethTransactionAmount, - hexTransactionAmount: value, - })) - - const hexTransactionFee = getHexGasTotal({ gasLimit, gasPrice }) - - const fiatTransactionFee = getTransactionFee({ - value: hexTransactionFee, - fromCurrency: nativeCurrency, - toCurrency: currentCurrency, - numberOfDecimals: 2, - conversionRate, - }) - const ethTransactionFee = getTransactionFee({ - value: hexTransactionFee, - fromCurrency: nativeCurrency, - toCurrency: nativeCurrency, - numberOfDecimals: 6, - conversionRate, - }) - - dispatch(updateTransactionFees({ fiatTransactionFee, ethTransactionFee, hexTransactionFee })) - - const fiatTransactionTotal = addFiat(fiatTransactionFee, fiatTransactionAmount) - const ethTransactionTotal = addEth(ethTransactionFee, ethTransactionAmount) - const hexTransactionTotal = sumHexes(value, hexTransactionFee) - - dispatch(updateTransactionTotals({ - fiatTransactionTotal, - ethTransactionTotal, - hexTransactionTotal, - })) - } -} - -export function setTransactionToConfirm (transactionId) { - return async (dispatch, getState) => { - const state = getState() - const unconfirmedTransactionsHash = unconfirmedTransactionsHashSelector(state) - const transaction = unconfirmedTransactionsHash[transactionId] - - if (!transaction) { - console.error(`Transaction with id ${transactionId} not found`) - return - } - - if (transaction.txParams) { - const { lastGasPrice } = transaction - const txData = lastGasPrice ? increaseFromLastGasPrice(transaction) : transaction - dispatch(updateTxDataAndCalculate(txData)) - - const { txParams } = transaction - const { to } = txParams - - if (txParams.data) { - const { tokens: existingTokens } = state - const { data, to: tokenAddress } = txParams - - 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(setFetchingData(false)) - } - - const tokenData = getTokenData(data) - dispatch(updateTokenData(tokenData)) - - try { - const tokenSymbolData = await getSymbolAndDecimals(tokenAddress, existingTokens) || {} - const { symbol: tokenSymbol = '', decimals: tokenDecimals = '' } = tokenSymbolData - dispatch(updateTokenProps({ tokenSymbol, tokenDecimals })) - } catch (error) { - dispatch(updateTokenProps({ tokenSymbol: '', tokenDecimals: '' })) - } - } - - if (txParams.nonce) { - const nonce = conversionUtil(txParams.nonce, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - }) - - dispatch(updateNonce(nonce)) - } - } else { - dispatch(updateTxData(transaction)) - } - } -} - -export function clearConfirmTransaction () { - return { - type: CLEAR_CONFIRM_TRANSACTION, - } -} diff --git a/ui/app/ducks/confirm-transaction/confirm-transaction.duck.js b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.js new file mode 100644 index 000000000..4edf8a70c --- /dev/null +++ b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.js @@ -0,0 +1,420 @@ +import { + conversionRateSelector, + currentCurrencySelector, + unconfirmedTransactionsHashSelector, + getNativeCurrency, +} from '../../selectors/confirm-transaction' + +import { + getValueFromWeiHex, + getTransactionFee, + getHexGasTotal, + addFiat, + addEth, + increaseLastGasPrice, + hexGreaterThan, +} from '../../helpers/utils/confirm-tx.util' + +import { + getTokenData, + getMethodData, + isSmartContractAddress, + sumHexes, +} from '../../helpers/utils/transactions.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}` + +const UPDATE_TX_DATA = createActionType('UPDATE_TX_DATA') +const CLEAR_TX_DATA = createActionType('CLEAR_TX_DATA') +const UPDATE_TOKEN_DATA = createActionType('UPDATE_TOKEN_DATA') +const CLEAR_TOKEN_DATA = createActionType('CLEAR_TOKEN_DATA') +const UPDATE_METHOD_DATA = createActionType('UPDATE_METHOD_DATA') +const CLEAR_METHOD_DATA = createActionType('CLEAR_METHOD_DATA') +const CLEAR_CONFIRM_TRANSACTION = createActionType('CLEAR_CONFIRM_TRANSACTION') +const UPDATE_TRANSACTION_AMOUNTS = createActionType('UPDATE_TRANSACTION_AMOUNTS') +const UPDATE_TRANSACTION_FEES = createActionType('UPDATE_TRANSACTION_FEES') +const UPDATE_TRANSACTION_TOTALS = createActionType('UPDATE_TRANSACTION_TOTALS') +const UPDATE_TOKEN_PROPS = createActionType('UPDATE_TOKEN_PROPS') +const UPDATE_NONCE = createActionType('UPDATE_NONCE') +const UPDATE_TO_SMART_CONTRACT = createActionType('UPDATE_TO_SMART_CONTRACT') +const FETCH_DATA_START = createActionType('FETCH_DATA_START') +const FETCH_DATA_END = createActionType('FETCH_DATA_END') + +// Initial state +const initState = { + txData: {}, + tokenData: {}, + methodData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + fiatTransactionAmount: '', + fiatTransactionFee: '', + fiatTransactionTotal: '', + ethTransactionAmount: '', + ethTransactionFee: '', + ethTransactionTotal: '', + hexTransactionAmount: '', + hexTransactionFee: '', + hexTransactionTotal: '', + nonce: '', + toSmartContract: false, + fetchingData: false, +} + +// Reducer +export default function reducer ({ confirmTransaction: confirmState = initState }, action = {}) { + switch (action.type) { + case UPDATE_TX_DATA: + return { + ...confirmState, + txData: { + ...action.payload, + }, + } + case CLEAR_TX_DATA: + return { + ...confirmState, + txData: {}, + } + case UPDATE_TOKEN_DATA: + return { + ...confirmState, + tokenData: { + ...action.payload, + }, + } + case CLEAR_TOKEN_DATA: + return { + ...confirmState, + tokenData: {}, + } + case UPDATE_METHOD_DATA: + return { + ...confirmState, + methodData: { + ...action.payload, + }, + } + case CLEAR_METHOD_DATA: + return { + ...confirmState, + methodData: {}, + } + case UPDATE_TRANSACTION_AMOUNTS: + const { fiatTransactionAmount, ethTransactionAmount, hexTransactionAmount } = action.payload + return { + ...confirmState, + fiatTransactionAmount: fiatTransactionAmount || confirmState.fiatTransactionAmount, + ethTransactionAmount: ethTransactionAmount || confirmState.ethTransactionAmount, + hexTransactionAmount: hexTransactionAmount || confirmState.hexTransactionAmount, + } + case UPDATE_TRANSACTION_FEES: + const { fiatTransactionFee, ethTransactionFee, hexTransactionFee } = action.payload + return { + ...confirmState, + fiatTransactionFee: fiatTransactionFee || confirmState.fiatTransactionFee, + ethTransactionFee: ethTransactionFee || confirmState.ethTransactionFee, + hexTransactionFee: hexTransactionFee || confirmState.hexTransactionFee, + } + case UPDATE_TRANSACTION_TOTALS: + const { fiatTransactionTotal, ethTransactionTotal, hexTransactionTotal } = action.payload + return { + ...confirmState, + fiatTransactionTotal: fiatTransactionTotal || confirmState.fiatTransactionTotal, + ethTransactionTotal: ethTransactionTotal || confirmState.ethTransactionTotal, + hexTransactionTotal: hexTransactionTotal || confirmState.hexTransactionTotal, + } + case UPDATE_TOKEN_PROPS: + const { tokenSymbol = '', tokenDecimals = '' } = action.payload + return { + ...confirmState, + tokenProps: { + ...confirmState.tokenProps, + tokenSymbol, + tokenDecimals, + }, + } + case UPDATE_NONCE: + return { + ...confirmState, + nonce: action.payload, + } + case UPDATE_TO_SMART_CONTRACT: + return { + ...confirmState, + toSmartContract: action.payload, + } + case FETCH_DATA_START: + return { + ...confirmState, + fetchingData: true, + } + case FETCH_DATA_END: + return { + ...confirmState, + fetchingData: false, + } + case CLEAR_CONFIRM_TRANSACTION: + return initState + default: + return confirmState + } +} + +// Action Creators +export function updateTxData (txData) { + return { + type: UPDATE_TX_DATA, + payload: txData, + } +} + +export function clearTxData () { + return { + type: CLEAR_TX_DATA, + } +} + +export function updateTokenData (tokenData) { + return { + type: UPDATE_TOKEN_DATA, + payload: tokenData, + } +} + +export function clearTokenData () { + return { + type: CLEAR_TOKEN_DATA, + } +} + +export function updateMethodData (methodData) { + return { + type: UPDATE_METHOD_DATA, + payload: methodData, + } +} + +export function clearMethodData () { + return { + type: CLEAR_METHOD_DATA, + } +} + +export function updateTransactionAmounts (amounts) { + return { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: amounts, + } +} + +export function updateTransactionFees (fees) { + return { + type: UPDATE_TRANSACTION_FEES, + payload: fees, + } +} + +export function updateTransactionTotals (totals) { + return { + type: UPDATE_TRANSACTION_TOTALS, + payload: totals, + } +} + +export function updateTokenProps (tokenProps) { + return { + type: UPDATE_TOKEN_PROPS, + payload: tokenProps, + } +} + +export function updateNonce (nonce) { + return { + type: UPDATE_NONCE, + payload: nonce, + } +} + +export function updateToSmartContract (toSmartContract) { + return { + type: UPDATE_TO_SMART_CONTRACT, + payload: toSmartContract, + } +} + +export function setFetchingData (isFetching) { + return { + type: isFetching ? FETCH_DATA_START : FETCH_DATA_END, + } +} + +export function updateGasAndCalculate ({ gasLimit, gasPrice }) { + gasLimit = addHexPrefix(gasLimit) + gasPrice = addHexPrefix(gasPrice) + return (dispatch, getState) => { + const { confirmTransaction: { txData } } = getState() + const newTxData = { + ...txData, + txParams: { + ...txData.txParams, + gas: gasLimit, + gasPrice, + }, + } + + dispatch(updateTxDataAndCalculate(newTxData)) + } +} + +function increaseFromLastGasPrice (txData) { + const { lastGasPrice, txParams: { gasPrice: previousGasPrice } = {} } = txData + + // Set the minimum to a 10% increase from the lastGasPrice. + const minimumGasPrice = increaseLastGasPrice(lastGasPrice) + const gasPriceBelowMinimum = hexGreaterThan(minimumGasPrice, previousGasPrice) + const gasPrice = (!previousGasPrice || gasPriceBelowMinimum) ? minimumGasPrice : previousGasPrice + + return { + ...txData, + txParams: { + ...txData.txParams, + gasPrice, + }, + } +} + +export function updateTxDataAndCalculate (txData) { + return (dispatch, getState) => { + const state = getState() + const currentCurrency = currentCurrencySelector(state) + const conversionRate = conversionRateSelector(state) + const nativeCurrency = getNativeCurrency(state) + + dispatch(updateTxData(txData)) + + const { txParams: { value = '0x0', gas: gasLimit = '0x0', gasPrice = '0x0' } = {} } = txData + + const fiatTransactionAmount = getValueFromWeiHex({ + value, fromCurrency: nativeCurrency, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, + }) + const ethTransactionAmount = getValueFromWeiHex({ + value, fromCurrency: nativeCurrency, toCurrency: nativeCurrency, conversionRate, numberOfDecimals: 6, + }) + + dispatch(updateTransactionAmounts({ + fiatTransactionAmount, + ethTransactionAmount, + hexTransactionAmount: value, + })) + + const hexTransactionFee = getHexGasTotal({ gasLimit, gasPrice }) + + const fiatTransactionFee = getTransactionFee({ + value: hexTransactionFee, + fromCurrency: nativeCurrency, + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate, + }) + const ethTransactionFee = getTransactionFee({ + value: hexTransactionFee, + fromCurrency: nativeCurrency, + toCurrency: nativeCurrency, + numberOfDecimals: 6, + conversionRate, + }) + + dispatch(updateTransactionFees({ fiatTransactionFee, ethTransactionFee, hexTransactionFee })) + + const fiatTransactionTotal = addFiat(fiatTransactionFee, fiatTransactionAmount) + const ethTransactionTotal = addEth(ethTransactionFee, ethTransactionAmount) + const hexTransactionTotal = sumHexes(value, hexTransactionFee) + + dispatch(updateTransactionTotals({ + fiatTransactionTotal, + ethTransactionTotal, + hexTransactionTotal, + })) + } +} + +export function setTransactionToConfirm (transactionId) { + return async (dispatch, getState) => { + const state = getState() + const unconfirmedTransactionsHash = unconfirmedTransactionsHashSelector(state) + const transaction = unconfirmedTransactionsHash[transactionId] + + if (!transaction) { + console.error(`Transaction with id ${transactionId} not found`) + return + } + + if (transaction.txParams) { + const { lastGasPrice } = transaction + const txData = lastGasPrice ? increaseFromLastGasPrice(transaction) : transaction + dispatch(updateTxDataAndCalculate(txData)) + + const { txParams } = transaction + const { to } = txParams + + if (txParams.data) { + const { tokens: existingTokens } = state + const { data, to: tokenAddress } = txParams + + 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(setFetchingData(false)) + } + + const tokenData = getTokenData(data) + dispatch(updateTokenData(tokenData)) + + try { + const tokenSymbolData = await getSymbolAndDecimals(tokenAddress, existingTokens) || {} + const { symbol: tokenSymbol = '', decimals: tokenDecimals = '' } = tokenSymbolData + dispatch(updateTokenProps({ tokenSymbol, tokenDecimals })) + } catch (error) { + dispatch(updateTokenProps({ tokenSymbol: '', tokenDecimals: '' })) + } + } + + if (txParams.nonce) { + const nonce = conversionUtil(txParams.nonce, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + + dispatch(updateNonce(nonce)) + } + } else { + dispatch(updateTxData(transaction)) + } + } +} + +export function clearConfirmTransaction () { + return { + type: CLEAR_CONFIRM_TRANSACTION, + } +} diff --git a/ui/app/ducks/confirm-transaction/confirm-transaction.duck.test.js b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.test.js new file mode 100644 index 000000000..483f2f56d --- /dev/null +++ b/ui/app/ducks/confirm-transaction/confirm-transaction.duck.test.js @@ -0,0 +1,685 @@ +import assert from 'assert' +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import sinon from 'sinon' + +import ConfirmTransactionReducer, * as actions from './confirm-transaction.duck.js' + +const initialState = { + txData: {}, + tokenData: {}, + methodData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + fiatTransactionAmount: '', + fiatTransactionFee: '', + fiatTransactionTotal: '', + ethTransactionAmount: '', + ethTransactionFee: '', + ethTransactionTotal: '', + hexTransactionAmount: '', + hexTransactionFee: '', + hexTransactionTotal: '', + nonce: '', + toSmartContract: false, + fetchingData: false, +} + +const UPDATE_TX_DATA = 'metamask/confirm-transaction/UPDATE_TX_DATA' +const CLEAR_TX_DATA = 'metamask/confirm-transaction/CLEAR_TX_DATA' +const UPDATE_TOKEN_DATA = 'metamask/confirm-transaction/UPDATE_TOKEN_DATA' +const CLEAR_TOKEN_DATA = 'metamask/confirm-transaction/CLEAR_TOKEN_DATA' +const UPDATE_METHOD_DATA = 'metamask/confirm-transaction/UPDATE_METHOD_DATA' +const CLEAR_METHOD_DATA = 'metamask/confirm-transaction/CLEAR_METHOD_DATA' +const UPDATE_TRANSACTION_AMOUNTS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS' +const UPDATE_TRANSACTION_FEES = 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES' +const UPDATE_TRANSACTION_TOTALS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS' +const UPDATE_TOKEN_PROPS = 'metamask/confirm-transaction/UPDATE_TOKEN_PROPS' +const UPDATE_NONCE = 'metamask/confirm-transaction/UPDATE_NONCE' +const UPDATE_TO_SMART_CONTRACT = 'metamask/confirm-transaction/UPDATE_TO_SMART_CONTRACT' +const FETCH_DATA_START = 'metamask/confirm-transaction/FETCH_DATA_START' +const FETCH_DATA_END = 'metamask/confirm-transaction/FETCH_DATA_END' +const CLEAR_CONFIRM_TRANSACTION = 'metamask/confirm-transaction/CLEAR_CONFIRM_TRANSACTION' + +describe('Confirm Transaction Duck', () => { + describe('State changes', () => { + const mockState = { + confirmTransaction: { + txData: { + id: 1, + }, + tokenData: { + name: 'abcToken', + }, + methodData: { + name: 'approve', + }, + tokenProps: { + tokenDecimals: '3', + tokenSymbol: 'ABC', + }, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '1.000021', + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '469.27', + hexTransactionAmount: '', + hexTransactionFee: '0x1319718a5000', + hexTransactionTotal: '', + nonce: '0x0', + toSmartContract: false, + fetchingData: false, + }, + } + + it('should initialize state', () => { + assert.deepEqual( + ConfirmTransactionReducer({}), + initialState + ) + }) + + it('should return state unchanged if it does not match a dispatched actions type', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: 'someOtherAction', + value: 'someValue', + }), + { ...mockState.confirmTransaction }, + ) + }) + + it('should set txData when receiving a UPDATE_TX_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TX_DATA, + payload: { + id: 2, + }, + }), + { + ...mockState.confirmTransaction, + txData: { + ...mockState.confirmTransaction.txData, + id: 2, + }, + } + ) + }) + + it('should clear txData when receiving a CLEAR_TX_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_TX_DATA, + }), + { + ...mockState.confirmTransaction, + txData: {}, + } + ) + }) + + it('should set tokenData when receiving a UPDATE_TOKEN_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TOKEN_DATA, + payload: { + name: 'defToken', + }, + }), + { + ...mockState.confirmTransaction, + tokenData: { + ...mockState.confirmTransaction.tokenData, + name: 'defToken', + }, + } + ) + }) + + it('should clear tokenData when receiving a CLEAR_TOKEN_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_TOKEN_DATA, + }), + { + ...mockState.confirmTransaction, + tokenData: {}, + } + ) + }) + + it('should set methodData when receiving a UPDATE_METHOD_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_METHOD_DATA, + payload: { + name: 'transferFrom', + }, + }), + { + ...mockState.confirmTransaction, + methodData: { + ...mockState.confirmTransaction.methodData, + name: 'transferFrom', + }, + } + ) + }) + + it('should clear methodData when receiving a CLEAR_METHOD_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_METHOD_DATA, + }), + { + ...mockState.confirmTransaction, + methodData: {}, + } + ) + }) + + it('should update transaction amounts when receiving an UPDATE_TRANSACTION_AMOUNTS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: { + fiatTransactionAmount: '123.45', + ethTransactionAmount: '.5', + hexTransactionAmount: '0x1', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionAmount: '123.45', + ethTransactionAmount: '.5', + hexTransactionAmount: '0x1', + } + ) + }) + + it('should update transaction fees when receiving an UPDATE_TRANSACTION_FEES action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_FEES, + payload: { + fiatTransactionFee: '123.45', + ethTransactionFee: '.5', + hexTransactionFee: '0x1', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionFee: '123.45', + ethTransactionFee: '.5', + hexTransactionFee: '0x1', + } + ) + }) + + it('should update transaction totals when receiving an UPDATE_TRANSACTION_TOTALS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_TOTALS, + payload: { + fiatTransactionTotal: '123.45', + ethTransactionTotal: '.5', + hexTransactionTotal: '0x1', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionTotal: '123.45', + ethTransactionTotal: '.5', + hexTransactionTotal: '0x1', + } + ) + }) + + it('should update tokenProps when receiving an UPDATE_TOKEN_PROPS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TOKEN_PROPS, + payload: { + tokenSymbol: 'DEF', + tokenDecimals: '1', + }, + }), + { + ...mockState.confirmTransaction, + tokenProps: { + tokenSymbol: 'DEF', + tokenDecimals: '1', + }, + } + ) + }) + + it('should update nonce when receiving an UPDATE_NONCE action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_NONCE, + payload: '0x1', + }), + { + ...mockState.confirmTransaction, + nonce: '0x1', + } + ) + }) + + it('should update nonce when receiving an UPDATE_TO_SMART_CONTRACT action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TO_SMART_CONTRACT, + payload: true, + }), + { + ...mockState.confirmTransaction, + toSmartContract: true, + } + ) + }) + + it('should set fetchingData to true when receiving a FETCH_DATA_START action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: FETCH_DATA_START, + }), + { + ...mockState.confirmTransaction, + fetchingData: true, + } + ) + }) + + it('should set fetchingData to false when receiving a FETCH_DATA_END action', () => { + assert.deepEqual( + ConfirmTransactionReducer({ confirmTransaction: { fetchingData: true } }, { + type: FETCH_DATA_END, + }), + { + fetchingData: false, + } + ) + }) + + it('should clear confirmTransaction when receiving a FETCH_DATA_END action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_CONFIRM_TRANSACTION, + }), + { + ...initialState, + } + ) + }) + }) + + describe('Single actions', () => { + it('should create an action to update txData', () => { + const txData = { test: 123 } + const expectedAction = { + type: UPDATE_TX_DATA, + payload: txData, + } + + assert.deepEqual( + actions.updateTxData(txData), + expectedAction + ) + }) + + it('should create an action to clear txData', () => { + const expectedAction = { + type: CLEAR_TX_DATA, + } + + assert.deepEqual( + actions.clearTxData(), + expectedAction + ) + }) + + it('should create an action to update tokenData', () => { + const tokenData = { test: 123 } + const expectedAction = { + type: UPDATE_TOKEN_DATA, + payload: tokenData, + } + + assert.deepEqual( + actions.updateTokenData(tokenData), + expectedAction + ) + }) + + it('should create an action to clear tokenData', () => { + const expectedAction = { + type: CLEAR_TOKEN_DATA, + } + + assert.deepEqual( + actions.clearTokenData(), + expectedAction + ) + }) + + it('should create an action to update methodData', () => { + const methodData = { test: 123 } + const expectedAction = { + type: UPDATE_METHOD_DATA, + payload: methodData, + } + + assert.deepEqual( + actions.updateMethodData(methodData), + expectedAction + ) + }) + + it('should create an action to clear methodData', () => { + const expectedAction = { + type: CLEAR_METHOD_DATA, + } + + assert.deepEqual( + actions.clearMethodData(), + expectedAction + ) + }) + + it('should create an action to update transaction amounts', () => { + const transactionAmounts = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: transactionAmounts, + } + + assert.deepEqual( + actions.updateTransactionAmounts(transactionAmounts), + expectedAction + ) + }) + + it('should create an action to update transaction fees', () => { + const transactionFees = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_FEES, + payload: transactionFees, + } + + assert.deepEqual( + actions.updateTransactionFees(transactionFees), + expectedAction + ) + }) + + it('should create an action to update transaction totals', () => { + const transactionTotals = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_TOTALS, + payload: transactionTotals, + } + + assert.deepEqual( + actions.updateTransactionTotals(transactionTotals), + expectedAction + ) + }) + + it('should create an action to update tokenProps', () => { + const tokenProps = { + tokenDecimals: '1', + tokenSymbol: 'abc', + } + const expectedAction = { + type: UPDATE_TOKEN_PROPS, + payload: tokenProps, + } + + assert.deepEqual( + actions.updateTokenProps(tokenProps), + expectedAction + ) + }) + + it('should create an action to update nonce', () => { + const nonce = '0x1' + const expectedAction = { + type: UPDATE_NONCE, + payload: nonce, + } + + assert.deepEqual( + actions.updateNonce(nonce), + expectedAction + ) + }) + + it('should create an action to set fetchingData to true', () => { + const expectedAction = { + type: FETCH_DATA_START, + } + + assert.deepEqual( + actions.setFetchingData(true), + expectedAction + ) + }) + + it('should create an action to set fetchingData to false', () => { + const expectedAction = { + type: FETCH_DATA_END, + } + + assert.deepEqual( + actions.setFetchingData(false), + expectedAction + ) + }) + + it('should create an action to clear confirmTransaction', () => { + const expectedAction = { + type: CLEAR_CONFIRM_TRANSACTION, + } + + assert.deepEqual( + actions.clearConfirmTransaction(), + expectedAction + ) + }) + }) + + describe('Thunk actions', done => { + beforeEach(() => { + global.eth = { + getCode: sinon.stub().callsFake( + address => Promise.resolve(address && address.match(/isContract/) ? 'not-0x' : '0x') + ), + } + }) + + afterEach(() => { + global.eth.getCode.resetHistory() + }) + + it('updates txData and gas on an existing transaction in confirmTransaction', () => { + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + }, + confirmTransaction: { + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '1.000021', + fetchingData: false, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + methodData: {}, + nonce: '', + tokenData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + txData: { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + }, + }, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.updateGasAndCalculate({ gasLimit: '0x2', gasPrice: '0x25' })) + + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + }) + + it('updates txData and updates gas values in confirmTransaction', () => { + const txData = { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x33450', + gasPrice: '0x2540be400', + to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', + value: '0xde0b6b3a7640000', + }, + } + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + }, + confirmTransaction: { + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '1.000021', + fetchingData: false, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + methodData: {}, + nonce: '', + tokenData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + txData: { + ...txData, + txParams: { + ...txData.txParams, + }, + }, + }, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.updateTxDataAndCalculate(txData)) + + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + }) + + it('updates confirmTransaction transaction', done => { + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + network: '3', + unapprovedTxs: { + 2603411941761054: { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x33450', + gasPrice: '0x2540be400', + to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', + value: '0xde0b6b3a7640000', + }, + }, + }, + }, + confirmTransaction: {}, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.setTransactionToConfirm(2603411941761054)) + .then(() => { + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + done() + }) + }) + }) +}) diff --git a/ui/app/ducks/gas.duck.js b/ui/app/ducks/gas.duck.js deleted file mode 100644 index 957b00163..000000000 --- a/ui/app/ducks/gas.duck.js +++ /dev/null @@ -1,517 +0,0 @@ -import { clone, uniqBy, flatten } from 'ramda' -import BigNumber from 'bignumber.js' -import { - loadLocalStorageData, - saveLocalStorageData, -} from '../../lib/local-storage-helpers' -import { - decGWEIToHexWEI, -} from '../helpers/conversions.util' - -// 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 { - priceAndTimeEstimatesLastRetrieved, - priceAndTimeEstimates, - } = getState().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/ducks/gas/gas-duck.test.js b/ui/app/ducks/gas/gas-duck.test.js new file mode 100644 index 000000000..4e875e020 --- /dev/null +++ b/ui/app/ducks/gas/gas-duck.test.js @@ -0,0 +1,600 @@ +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 } + ) })) + 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 } + ) })) + 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, + }], + } + ) })) + 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..0a571a78e --- /dev/null +++ b/ui/app/ducks/gas/gas.duck.js @@ -0,0 +1,517 @@ +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' + +// 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 { + priceAndTimeEstimatesLastRetrieved, + priceAndTimeEstimates, + } = getState().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/ducks/index.js b/ui/app/ducks/index.js new file mode 100644 index 000000000..2d33edcfa --- /dev/null +++ b/ui/app/ducks/index.js @@ -0,0 +1,95 @@ +const clone = require('clone') +const extend = require('xtend') +const copyToClipboard = require('copy-to-clipboard') + +// +// Sub-Reducers take in the complete state and return their sub-state +// +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 + +module.exports = rootReducer + +function rootReducer (state, action) { + // clone + state = extend(state) + + if (action.type === 'GLOBAL_FORCE_UPDATE') { + return action.value + } + + // + // MetaMask + // + + state.metamask = reduceMetamask(state, action) + + // + // AppState + // + + state.appState = reduceApp(state, action) + + // + // LocaleMessages + // + + state.localeMessages = reduceLocale(state, action) + + // + // Send + // + + state.send = reduceSend(state, action) + + state.confirmTransaction = reduceConfirmTransaction(state, action) + + state.gas = reduceGas(state, action) + + window.METAMASK_CACHED_LOG_STATE = state + return state +} + +window.getCleanAppState = function () { + const state = clone(window.METAMASK_CACHED_LOG_STATE) + // append additional information + state.version = global.platform.getVersion() + state.browser = window.navigator.userAgent + // ensure seedWords are not included + if (state.metamask) delete state.metamask.seedWords + if (state.appState.currentView) delete state.appState.currentView.seedWords + return state +} + +window.logStateString = function (cb) { + const state = window.getCleanAppState() + global.platform.getPlatformInfo((err, platform) => { + if (err) return cb(err) + state.platform = platform + const stateString = JSON.stringify(state, removeSeedWords, 2) + cb(null, stateString) + }) +} + +window.logState = function (toClipboard) { + return window.logStateString((err, result) => { + if (err) { + console.error(err.message) + } else if (toClipboard) { + copyToClipboard(result) + console.log('State log copied') + } else { + console.log(result) + } + }) +} + +function removeSeedWords (key, value) { + return key === 'seedWords' ? undefined : value +} diff --git a/ui/app/ducks/locale/locale.js b/ui/app/ducks/locale/locale.js new file mode 100644 index 000000000..bb8e1b08e --- /dev/null +++ b/ui/app/ducks/locale/locale.js @@ -0,0 +1,17 @@ +const extend = require('xtend') +const actions = require('../../store/actions') + +module.exports = reduceMetamask + +function reduceMetamask (state, action) { + const localeMessagesState = extend({}, state.localeMessages) + + switch (action.type) { + case actions.SET_LOCALE_MESSAGES: + return extend(localeMessagesState, { + current: action.value, + }) + default: + return localeMessagesState + } +} diff --git a/ui/app/ducks/metamask/metamask.js b/ui/app/ducks/metamask/metamask.js new file mode 100644 index 000000000..864229e83 --- /dev/null +++ b/ui/app/ducks/metamask/metamask.js @@ -0,0 +1,419 @@ +const extend = require('xtend') +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 + +function reduceMetamask (state, action) { + let newState + + // clone + defaults + var metamaskState = extend({ + isInitialized: false, + isUnlocked: false, + isAccountMenuOpen: false, + isPopup: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP, + rpcTarget: 'https://rawtestrpc.metamask.io/', + identities: {}, + unapprovedTxs: {}, + noActiveNotices: true, + nextUnreadNotice: undefined, + frequentRpcList: [], + addressBook: [], + selectedTokenAddress: null, + contractExchangeRates: {}, + tokenExchangeRates: {}, + tokens: [], + pendingTokens: {}, + send: { + gasLimit: null, + gasPrice: null, + gasTotal: null, + tokenBalance: '0x0', + from: '', + to: '', + amount: '0', + memo: '', + errors: {}, + maxModeOn: false, + editingTransactionId: null, + forceGasMin: null, + toNickname: '', + }, + coinOptions: {}, + useBlockie: false, + featureFlags: {}, + networkEndpointType: OLD_UI_NETWORK_TYPE, + isRevealingSeedWords: false, + welcomeScreenSeen: false, + currentLocale: '', + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + showFiatInTestnets: false, + }, + firstTimeFlowType: null, + completedOnboarding: false, + knownMethodData: {}, + participateInMetaMetrics: null, + metaMetricsSendCount: 0, + }, state.metamask) + + switch (action.type) { + + case actions.SHOW_ACCOUNTS_PAGE: + newState = extend(metamaskState, { + isRevealingSeedWords: false, + }) + delete newState.seedWords + return newState + + case actions.SHOW_NOTICE: + return extend(metamaskState, { + noActiveNotices: false, + nextUnreadNotice: action.value, + }) + + case actions.CLEAR_NOTICES: + return extend(metamaskState, { + noActiveNotices: true, + nextUnreadNotice: undefined, + }) + + case actions.UPDATE_METAMASK_STATE: + return extend(metamaskState, action.value) + + case actions.UNLOCK_METAMASK: + return extend(metamaskState, { + isUnlocked: true, + isInitialized: true, + selectedAddress: action.value, + }) + + case actions.LOCK_METAMASK: + return extend(metamaskState, { + isUnlocked: false, + }) + + case actions.SET_RPC_LIST: + return extend(metamaskState, { + frequentRpcList: action.value, + }) + + case actions.SET_RPC_TARGET: + return extend(metamaskState, { + provider: { + type: 'rpc', + rpcTarget: action.value, + }, + }) + + case actions.SET_PROVIDER_TYPE: + return extend(metamaskState, { + provider: { + type: action.value, + }, + }) + + case actions.COMPLETED_TX: + var stringId = String(action.id) + newState = extend(metamaskState, { + unapprovedTxs: {}, + unapprovedMsgs: {}, + }) + for (const id in metamaskState.unapprovedTxs) { + if (id !== stringId) { + newState.unapprovedTxs[id] = metamaskState.unapprovedTxs[id] + } + } + for (const id in metamaskState.unapprovedMsgs) { + if (id !== stringId) { + newState.unapprovedMsgs[id] = metamaskState.unapprovedMsgs[id] + } + } + return newState + + case actions.EDIT_TX: + return extend(metamaskState, { + send: { + ...metamaskState.send, + editingTransactionId: action.value, + }, + }) + + + case actions.SHOW_NEW_VAULT_SEED: + return extend(metamaskState, { + isRevealingSeedWords: true, + seedWords: action.value, + }) + + case actions.CLEAR_SEED_WORD_CACHE: + newState = extend(metamaskState, { + isUnlocked: true, + isInitialized: true, + selectedAddress: action.value, + }) + delete newState.seedWords + return newState + + case actions.SHOW_ACCOUNT_DETAIL: + newState = extend(metamaskState, { + isUnlocked: true, + isInitialized: true, + selectedAddress: action.value, + }) + delete newState.seedWords + return newState + + case actions.SET_SELECTED_TOKEN: + return extend(metamaskState, { + selectedTokenAddress: action.value, + }) + + case actions.SET_ACCOUNT_LABEL: + const account = action.value.account + const name = action.value.label + const id = {} + id[account] = extend(metamaskState.identities[account], { name }) + const identities = extend(metamaskState.identities, id) + return extend(metamaskState, { identities }) + + case actions.SET_CURRENT_FIAT: + return extend(metamaskState, { + currentCurrency: action.value.currentCurrency, + conversionRate: action.value.conversionRate, + conversionDate: action.value.conversionDate, + }) + + case actions.UPDATE_TOKENS: + return extend(metamaskState, { + tokens: action.newTokens, + }) + + // metamask.send + case actions.UPDATE_GAS_LIMIT: + return extend(metamaskState, { + send: { + ...metamaskState.send, + gasLimit: action.value, + }, + }) + + case actions.UPDATE_GAS_PRICE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + gasPrice: action.value, + }, + }) + + case actions.TOGGLE_ACCOUNT_MENU: + return extend(metamaskState, { + isAccountMenuOpen: !metamaskState.isAccountMenuOpen, + }) + + case actions.UPDATE_GAS_TOTAL: + return extend(metamaskState, { + send: { + ...metamaskState.send, + gasTotal: action.value, + }, + }) + + case actions.UPDATE_SEND_TOKEN_BALANCE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + tokenBalance: action.value, + }, + }) + + case actions.UPDATE_SEND_HEX_DATA: + return extend(metamaskState, { + send: { + ...metamaskState.send, + data: action.value, + }, + }) + + case actions.UPDATE_SEND_FROM: + return extend(metamaskState, { + send: { + ...metamaskState.send, + from: action.value, + }, + }) + + case actions.UPDATE_SEND_TO: + return extend(metamaskState, { + send: { + ...metamaskState.send, + to: action.value.to, + toNickname: action.value.nickname, + }, + }) + + case actions.UPDATE_SEND_AMOUNT: + return extend(metamaskState, { + send: { + ...metamaskState.send, + amount: action.value, + }, + }) + + case actions.UPDATE_SEND_MEMO: + return extend(metamaskState, { + send: { + ...metamaskState.send, + memo: action.value, + }, + }) + + case actions.UPDATE_MAX_MODE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + maxModeOn: action.value, + }, + }) + + case actions.UPDATE_SEND: + return extend(metamaskState, { + send: { + ...metamaskState.send, + ...action.value, + }, + }) + + case actions.CLEAR_SEND: + return extend(metamaskState, { + send: { + gasLimit: null, + gasPrice: null, + gasTotal: null, + tokenBalance: null, + from: '', + to: '', + amount: '0x0', + memo: '', + errors: {}, + maxModeOn: false, + editingTransactionId: null, + forceGasMin: null, + toNickname: '', + }, + }) + + case actions.UPDATE_TRANSACTION_PARAMS: + const { id: txId, value } = action + let { selectedAddressTxList } = metamaskState + selectedAddressTxList = selectedAddressTxList.map(tx => { + if (tx.id === txId) { + tx.txParams = value + } + return tx + }) + + return extend(metamaskState, { + selectedAddressTxList, + }) + + case actions.PAIR_UPDATE: + const { value: { marketinfo: pairMarketInfo } } = action + return extend(metamaskState, { + tokenExchangeRates: { + ...metamaskState.tokenExchangeRates, + [pairMarketInfo.pair]: pairMarketInfo, + }, + }) + + case actions.SHAPESHIFT_SUBVIEW: + const { value: { marketinfo: ssMarketInfo, coinOptions } } = action + return extend(metamaskState, { + tokenExchangeRates: { + ...metamaskState.tokenExchangeRates, + [ssMarketInfo.pair]: ssMarketInfo, + }, + 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, + }) + + case actions.UPDATE_FEATURE_FLAGS: + return extend(metamaskState, { + featureFlags: action.value, + }) + + case actions.UPDATE_NETWORK_ENDPOINT_TYPE: + return extend(metamaskState, { + networkEndpointType: action.value, + }) + + case actions.CLOSE_WELCOME_SCREEN: + return extend(metamaskState, { + welcomeScreenSeen: true, + }) + + case actions.SET_CURRENT_LOCALE: + return extend(metamaskState, { + currentLocale: action.value, + }) + + case actions.SET_PENDING_TOKENS: + return extend(metamaskState, { + pendingTokens: { ...action.payload }, + }) + + case actions.CLEAR_PENDING_TOKENS: { + return extend(metamaskState, { + pendingTokens: {}, + }) + } + + case actions.UPDATE_PREFERENCES: { + return extend(metamaskState, { + 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, + }) + } + + default: + return metamaskState + + } +} diff --git a/ui/app/ducks/mock-gas-estimate-data.js b/ui/app/ducks/mock-gas-estimate-data.js deleted file mode 100644 index f2943df7c..000000000 --- a/ui/app/ducks/mock-gas-estimate-data.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - mockGasEstimateData: [{'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 6.2827225131, 'pct_remaining5m': 0.0, 'sum': 6.7965923077, 'tx_atabove': 3969.0, 'hashpower_accepting': 15.8974358974, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 1.0, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 7.1623036649, 'pct_remaining5m': 0.0, 'sum': 6.7841307692, 'tx_atabove': 3969.0, 'hashpower_accepting': 16.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 1.3, 'pct_mined_5m': 0.0, 'total_seen_5m': 8.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 7.3403141361, 'pct_remaining5m': 0.0, 'sum': 6.7841307692, 'tx_atabove': 3969.0, 'hashpower_accepting': 16.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 1.4, 'pct_mined_5m': 0.0, 'total_seen_5m': 13.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 1.0, 'hashpower_accepting2': 7.3926701571, 'pct_remaining5m': 0.0, 'sum': 6.7841307692, 'tx_atabove': 3969.0, 'hashpower_accepting': 16.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 1.5, 'pct_mined_5m': 0.0, 'total_seen_5m': 40.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 356.0, 'hashpower_accepting2': 7.5706806283, 'pct_remaining5m': 25.0, 'sum': 6.7769307692, 'tx_atabove': 3957.0, 'hashpower_accepting': 16.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': 33.0, 'int2': 6.9238, 'pct_remaining30m': 9.0, 'gasprice': 1.6, 'pct_mined_5m': 0.0, 'total_seen_5m': 346.0, 'pct_mined_30m': 6.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 1048.5, 'hashpower_accepting2': 7.6020942408, 'pct_remaining5m': 95.0, 'sum': 6.0111923077, 'tx_atabove': 2930.0, 'hashpower_accepting': 22.5641025641, 'hpa_coef2': -0.067, 'total_seen_30m': 11.0, 'int2': 6.9238, 'pct_remaining30m': 100.0, 'gasprice': 2.0, 'pct_mined_5m': 0.0, 'total_seen_5m': 23.0, 'pct_mined_30m': 0.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 56.0, 'hashpower_accepting2': 7.6020942408, 'pct_remaining5m': 99.0, 'sum': 5.2227923077, 'tx_atabove': 1616.0, 'hashpower_accepting': 22.5641025641, 'hpa_coef2': -0.067, 'total_seen_30m': 66.0, 'int2': 6.9238, 'pct_remaining30m': 100.0, 'gasprice': 2.1, 'pct_mined_5m': 0.0, 'total_seen_5m': 131.0, 'pct_mined_30m': 0.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 41.0, 'hashpower_accepting2': 7.6020942408, 'pct_remaining5m': 100.0, 'sum': 4.8633923077, 'tx_atabove': 1017.0, 'hashpower_accepting': 22.5641025641, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 2.8, 'pct_mined_5m': 0.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 31.0, 'hashpower_accepting2': 7.612565445, 'pct_remaining5m': 100.0, 'sum': 4.8615923077, 'tx_atabove': 1014.0, 'hashpower_accepting': 22.5641025641, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 100.0, 'gasprice': 2.9, 'pct_mined_5m': 0.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 0.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 147.0, 'hashpower_accepting2': 12.3246073298, 'pct_remaining5m': 50.0, 'sum': 4.6965923077, 'tx_atabove': 1009.0, 'hashpower_accepting': 29.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 6.0, 'int2': 6.9238, 'pct_remaining30m': 50.0, 'gasprice': 3.0, 'pct_mined_5m': 50.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 50.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 73.0, 'hashpower_accepting2': 12.3246073298, 'pct_remaining5m': 50.0, 'sum': 4.6437923077, 'tx_atabove': 921.0, 'hashpower_accepting': 29.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 100.0, 'gasprice': 3.1, 'pct_mined_5m': 0.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 0.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 93.0, 'hashpower_accepting2': 12.3246073298, 'pct_remaining5m': 100.0, 'sum': 4.6413923077, 'tx_atabove': 917.0, 'hashpower_accepting': 29.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 11.0, 'int2': 6.9238, 'pct_remaining30m': 100.0, 'gasprice': 3.2, 'pct_mined_5m': 0.0, 'total_seen_5m': 8.0, 'pct_mined_30m': 0.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 44.5, 'hashpower_accepting2': 12.3246073298, 'pct_remaining5m': 100.0, 'sum': 4.6107923077, 'tx_atabove': 866.0, 'hashpower_accepting': 29.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 50.0, 'gasprice': 3.3, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 0.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 256.68, 'avgdiff': 0, 'expectedWait': 1000.0, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 11.0, 'hashpower_accepting2': 12.3664921466, 'pct_remaining5m': 100.0, 'sum': 4.5893307692, 'tx_atabove': 851.0, 'hashpower_accepting': 29.7435897436, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 3.4, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 25.27, 'avgdiff': 0, 'expectedWait': 98.4285367101, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 31.0, 'hashpower_accepting2': 12.4712041885, 'pct_remaining5m': 100.0, 'sum': 4.5887307692, 'tx_atabove': 850.0, 'hashpower_accepting': 29.7435897436, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 3.5, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 25.25, 'avgdiff': 0, 'expectedWait': 98.3694973017, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 823.0, 'hashpower_accepting2': 12.4921465969, 'pct_remaining5m': 0.0, 'sum': 4.5428076923, 'tx_atabove': 815.0, 'hashpower_accepting': 30.7692307692, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 3.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 24.12, 'avgdiff': 0, 'expectedWait': 93.9542246928, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 35.0, 'hashpower_accepting2': 12.7539267016, 'pct_remaining5m': 10.0, 'sum': 4.5279461538, 'tx_atabove': 811.0, 'hashpower_accepting': 31.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 3.9, 'pct_mined_5m': 80.0, 'total_seen_5m': 10.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 23.76, 'avgdiff': 0, 'expectedWait': 92.5682447753, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 30.0, 'hashpower_accepting2': 15.4869109948, 'pct_remaining5m': 92.0, 'sum': 4.3896692308, 'tx_atabove': 809.0, 'hashpower_accepting': 36.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 16.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 4.0, 'pct_mined_5m': 5.0, 'total_seen_5m': 124.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 20.69, 'avgdiff': 0, 'expectedWait': 80.613750022, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 24.0, 'hashpower_accepting2': 16.7853403141, 'pct_remaining5m': 93.0, 'sum': 4.2840692308, 'tx_atabove': 633.0, 'hashpower_accepting': 36.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 20.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 4.1, 'pct_mined_5m': 6.0, 'total_seen_5m': 165.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 18.62, 'avgdiff': 0, 'expectedWait': 72.5350019424, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 126.0, 'hashpower_accepting2': 16.9005235602, 'pct_remaining5m': 50.0, 'sum': 4.1260846154, 'tx_atabove': 432.0, 'hashpower_accepting': 38.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 4.3, 'pct_mined_5m': 0.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 15.9, 'avgdiff': 0, 'expectedWait': 61.9349484316, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 33.0, 'hashpower_accepting2': 17.1204188482, 'pct_remaining5m': 100.0, 'sum': 4.1140846154, 'tx_atabove': 412.0, 'hashpower_accepting': 38.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 4.4, 'pct_mined_5m': 0.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 15.71, 'avgdiff': 0, 'expectedWait': 61.1961705828, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 24.0, 'hashpower_accepting2': 17.2460732984, 'pct_remaining5m': 100.0, 'sum': 4.1092846154, 'tx_atabove': 404.0, 'hashpower_accepting': 38.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 4.6, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 15.63, 'avgdiff': 0, 'expectedWait': 60.9031328173, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 19.0, 'hashpower_accepting2': 17.4659685864, 'pct_remaining5m': 0.0, 'sum': 4.0570384615, 'tx_atabove': 400.0, 'hashpower_accepting': 40.5128205128, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 4.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 10.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 14.84, 'avgdiff': 0, 'expectedWait': 57.8028719141, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 3.0, 'hashpower_accepting2': 17.612565445, 'pct_remaining5m': 0.0, 'sum': 4.0403769231, 'tx_atabove': 393.0, 'hashpower_accepting': 41.0256410256, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 4.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 14.59, 'avgdiff': 0, 'expectedWait': 56.8477660026, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 31.0, 'hashpower_accepting2': 17.6439790576, 'pct_remaining5m': 100.0, 'sum': 4.0397769231, 'tx_atabove': 392.0, 'hashpower_accepting': 41.0256410256, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 4.9, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 14.58, 'avgdiff': 0, 'expectedWait': 56.8136675736, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 3.0, 'hashpower_accepting2': 20.502617801, 'pct_remaining5m': 3.0, 'sum': 2.2680615385, 'tx_atabove': 390.0, 'hashpower_accepting': 46.1538461538, 'hpa_coef2': -0.067, 'total_seen_30m': 43.0, 'int2': 6.9238, 'pct_remaining30m': 2.0, 'gasprice': 5.0, 'pct_mined_5m': 93.0, 'total_seen_5m': 66.0, 'pct_mined_30m': 97.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.48, 'avgdiff': 1, 'expectedWait': 9.660655842, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 20.5863874346, 'pct_remaining5m': 0.0, 'sum': 2.2308615385, 'tx_atabove': 328.0, 'hashpower_accepting': 46.1538461538, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 5.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.39, 'avgdiff': 1, 'expectedWait': 9.3078817242, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 20.7015706806, 'pct_remaining5m': 0.0, 'sum': 2.216, 'tx_atabove': 324.0, 'hashpower_accepting': 46.6666666667, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 5.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.35, 'avgdiff': 1, 'expectedWait': 9.170575103, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 20.8481675393, 'pct_remaining5m': 0.0, 'sum': 2.1910769231, 'tx_atabove': 324.0, 'hashpower_accepting': 47.6923076923, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 5.3, 'pct_mined_5m': 100.0, 'total_seen_5m': 5.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.3, 'avgdiff': 1, 'expectedWait': 8.9448408351, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 21.0261780105, 'pct_remaining5m': 0.0, 'sum': 2.1898769231, 'tx_atabove': 322.0, 'hashpower_accepting': 47.6923076923, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 5.4, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.29, 'avgdiff': 1, 'expectedWait': 8.9341134638, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 21.2041884817, 'pct_remaining5m': 0.0, 'sum': 2.1518923077, 'tx_atabove': 321.0, 'hashpower_accepting': 49.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 5.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 7.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.21, 'avgdiff': 1, 'expectedWait': 8.6011189709, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 21.277486911, 'pct_remaining5m': 0.0, 'sum': 2.1512923077, 'tx_atabove': 320.0, 'hashpower_accepting': 49.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 5.6, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.21, 'avgdiff': 1, 'expectedWait': 8.5959598474, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 21.3926701571, 'pct_remaining5m': 0.0, 'sum': 2.1494923077, 'tx_atabove': 317.0, 'hashpower_accepting': 49.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 5.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.2, 'avgdiff': 1, 'expectedWait': 8.5805010368, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 21.4136125654, 'pct_remaining5m': 0.0, 'sum': 2.1494923077, 'tx_atabove': 317.0, 'hashpower_accepting': 49.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 5.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 2.2, 'avgdiff': 1, 'expectedWait': 8.5805010368, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 0.0, 'hashpower_accepting2': 25.5497382199, 'pct_remaining5m': 0.0, 'sum': 2.0124153846, 'tx_atabove': 317.0, 'hashpower_accepting': 54.8717948718, 'hpa_coef2': -0.067, 'total_seen_30m': 44.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 118.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.92, 'avgdiff': 1, 'expectedWait': 7.4813659176, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 25.5916230366, 'pct_remaining5m': 0.0, 'sum': 2.0010153846, 'tx_atabove': 298.0, 'hashpower_accepting': 54.8717948718, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.9, 'avgdiff': 1, 'expectedWait': 7.3965626432, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 25.7068062827, 'pct_remaining5m': 0.0, 'sum': 2.0004153846, 'tx_atabove': 297.0, 'hashpower_accepting': 54.8717948718, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.9, 'avgdiff': 1, 'expectedWait': 7.3921260367, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 25.8848167539, 'pct_remaining5m': 0.0, 'sum': 1.9986153846, 'tx_atabove': 294.0, 'hashpower_accepting': 54.8717948718, 'hpa_coef2': -0.067, 'total_seen_30m': 6.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.3, 'pct_mined_5m': 100.0, 'total_seen_5m': 5.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.89, 'avgdiff': 1, 'expectedWait': 7.3788321779, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 25.8952879581, 'pct_remaining5m': 0.0, 'sum': 1.9986153846, 'tx_atabove': 294.0, 'hashpower_accepting': 54.8717948718, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 6.4, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.89, 'avgdiff': 1, 'expectedWait': 7.3788321779, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 25.9685863874, 'pct_remaining5m': 0.0, 'sum': 1.9986153846, 'tx_atabove': 294.0, 'hashpower_accepting': 54.8717948718, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.89, 'avgdiff': 1, 'expectedWait': 7.3788321779, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 26.3769633508, 'pct_remaining5m': 0.0, 'sum': 1.9612307692, 'tx_atabove': 294.0, 'hashpower_accepting': 56.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 6.6, 'pct_mined_5m': 96.0, 'total_seen_5m': 29.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.82, 'avgdiff': 1, 'expectedWait': 7.1080700777, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 26.7120418848, 'pct_remaining5m': 0.0, 'sum': 1.9421692308, 'tx_atabove': 283.0, 'hashpower_accepting': 56.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 5.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.79, 'avgdiff': 1, 'expectedWait': 6.9738624916, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 26.9109947644, 'pct_remaining5m': 0.0, 'sum': 1.9421692308, 'tx_atabove': 283.0, 'hashpower_accepting': 56.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 9.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.79, 'avgdiff': 1, 'expectedWait': 6.9738624916, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 27.109947644, 'pct_remaining5m': 0.0, 'sum': 1.9415692308, 'tx_atabove': 282.0, 'hashpower_accepting': 56.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 5.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 6.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.79, 'avgdiff': 1, 'expectedWait': 6.9696794292, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 3.0, 'hashpower_accepting2': 29.2356020942, 'pct_remaining5m': 0.0, 'sum': 1.8294153846, 'tx_atabove': 282.0, 'hashpower_accepting': 61.5384615385, 'hpa_coef2': -0.067, 'total_seen_30m': 60.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 7.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 50.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.6, 'avgdiff': 1, 'expectedWait': 6.2302432976, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 0.0, 'hashpower_accepting2': 29.780104712, 'pct_remaining5m': 0.0, 'sum': 1.8115538462, 'tx_atabove': 273.0, 'hashpower_accepting': 62.0512820513, 'hpa_coef2': -0.067, 'total_seen_30m': 6.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 7.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 18.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.57, 'avgdiff': 1, 'expectedWait': 6.1199495079, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 29.8848167539, 'pct_remaining5m': 0.0, 'sum': 1.8109538462, 'tx_atabove': 272.0, 'hashpower_accepting': 62.0512820513, 'hpa_coef2': -0.067, 'total_seen_30m': 4.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 7.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.57, 'avgdiff': 1, 'expectedWait': 6.1162786396, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 29.9476439791, 'pct_remaining5m': 0.0, 'sum': 1.8103538462, 'tx_atabove': 271.0, 'hashpower_accepting': 62.0512820513, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 7.3, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.57, 'avgdiff': 1, 'expectedWait': 6.1126099731, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 30.0209424084, 'pct_remaining5m': null, 'sum': 1.8085538462, 'tx_atabove': 268.0, 'hashpower_accepting': 62.0512820513, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 7.4, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.57, 'avgdiff': 1, 'expectedWait': 6.1016171717, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 30.1151832461, 'pct_remaining5m': 0.0, 'sum': 1.8085538462, 'tx_atabove': 268.0, 'hashpower_accepting': 62.0512820513, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 7.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.57, 'avgdiff': 1, 'expectedWait': 6.1016171717, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 30.2827225131, 'pct_remaining5m': 0.0, 'sum': 1.8085538462, 'tx_atabove': 268.0, 'hashpower_accepting': 62.0512820513, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 7.6, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.57, 'avgdiff': 1, 'expectedWait': 6.1016171717, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 30.8586387435, 'pct_remaining5m': 0.0, 'sum': 1.7681692308, 'tx_atabove': 263.0, 'hashpower_accepting': 63.5897435897, 'hpa_coef2': -0.067, 'total_seen_30m': 12.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 7.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.5, 'avgdiff': 1, 'expectedWait': 5.8601150164, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 30.9528795812, 'pct_remaining5m': 0.0, 'sum': 1.7681692308, 'tx_atabove': 263.0, 'hashpower_accepting': 63.5897435897, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 7.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.5, 'avgdiff': 1, 'expectedWait': 5.8601150164, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 31.3089005236, 'pct_remaining5m': 0.0, 'sum': 1.7432461538, 'tx_atabove': 263.0, 'hashpower_accepting': 64.6153846154, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 7.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.47, 'avgdiff': 1, 'expectedWait': 5.7158679264, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 33.2670157068, 'pct_remaining5m': 0.0, 'sum': 1.6928, 'tx_atabove': 262.0, 'hashpower_accepting': 66.6666666667, 'hpa_coef2': -0.067, 'total_seen_30m': 65.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 8.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 38.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.39, 'avgdiff': 1, 'expectedWait': 5.4346765153, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 33.4869109948, 'pct_remaining5m': 0.0, 'sum': 1.6624769231, 'tx_atabove': 253.0, 'hashpower_accepting': 67.6923076923, 'hpa_coef2': -0.067, 'total_seen_30m': 5.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 8.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.35, 'avgdiff': 1, 'expectedWait': 5.2723538995, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 34.2617801047, 'pct_remaining5m': null, 'sum': 1.6494153846, 'tx_atabove': 252.0, 'hashpower_accepting': 68.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 8.2, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.34, 'avgdiff': 1, 'expectedWait': 5.2039366363, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 34.2827225131, 'pct_remaining5m': null, 'sum': 1.6494153846, 'tx_atabove': 252.0, 'hashpower_accepting': 68.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 8.3, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.34, 'avgdiff': 1, 'expectedWait': 5.2039366363, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 35.2356020942, 'pct_remaining5m': 0.0, 'sum': 1.6244923077, 'tx_atabove': 252.0, 'hashpower_accepting': 69.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 33.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 8.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.3, 'avgdiff': 1, 'expectedWait': 5.0758414173, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 35.2984293194, 'pct_remaining5m': null, 'sum': 1.6244923077, 'tx_atabove': 252.0, 'hashpower_accepting': 69.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 8.6, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.3, 'avgdiff': 1, 'expectedWait': 5.0758414173, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 35.3612565445, 'pct_remaining5m': null, 'sum': 1.6244923077, 'tx_atabove': 252.0, 'hashpower_accepting': 69.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 8.8, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.3, 'avgdiff': 1, 'expectedWait': 5.0758414173, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 37.6335078534, 'pct_remaining5m': 0.0, 'sum': 1.5372615385, 'tx_atabove': 252.0, 'hashpower_accepting': 72.8205128205, 'hpa_coef2': -0.067, 'total_seen_30m': 35.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 91.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.19, 'avgdiff': 1, 'expectedWait': 4.6518339443, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 0.0, 'hashpower_accepting2': 38.0209424084, 'pct_remaining5m': 0.0, 'sum': 1.5336615385, 'tx_atabove': 246.0, 'hashpower_accepting': 72.8205128205, 'hpa_coef2': -0.067, 'total_seen_30m': 14.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 7.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.19, 'avgdiff': 1, 'expectedWait': 4.6351174498, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 38.0732984293, 'pct_remaining5m': 0.0, 'sum': 1.5206, 'tx_atabove': 245.0, 'hashpower_accepting': 73.3333333333, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.17, 'avgdiff': 1, 'expectedWait': 4.5749693534, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 38.1151832461, 'pct_remaining5m': 0.0, 'sum': 1.5206, 'tx_atabove': 245.0, 'hashpower_accepting': 73.3333333333, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 9.3, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.17, 'avgdiff': 1, 'expectedWait': 4.5749693534, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 38.1465968586, 'pct_remaining5m': null, 'sum': 1.5206, 'tx_atabove': 245.0, 'hashpower_accepting': 73.3333333333, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.4, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.17, 'avgdiff': 1, 'expectedWait': 4.5749693534, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 38.2722513089, 'pct_remaining5m': null, 'sum': 1.52, 'tx_atabove': 244.0, 'hashpower_accepting': 73.3333333333, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.5, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.17, 'avgdiff': 1, 'expectedWait': 4.5722251951, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 38.6701570681, 'pct_remaining5m': null, 'sum': 1.5194, 'tx_atabove': 243.0, 'hashpower_accepting': 73.3333333333, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.6, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.17, 'avgdiff': 1, 'expectedWait': 4.5694826829, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 38.6910994764, 'pct_remaining5m': 0.0, 'sum': 1.5188, 'tx_atabove': 242.0, 'hashpower_accepting': 73.3333333333, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 9.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.17, 'avgdiff': 1, 'expectedWait': 4.5667418156, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 39.3403141361, 'pct_remaining5m': null, 'sum': 1.4979384615, 'tx_atabove': 228.0, 'hashpower_accepting': 73.8461538462, 'hpa_coef2': -0.067, 'total_seen_30m': 11.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.8, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.15, 'avgdiff': 1, 'expectedWait': 4.4724594129, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 39.7277486911, 'pct_remaining5m': 0.0, 'sum': 1.4979384615, 'tx_atabove': 228.0, 'hashpower_accepting': 73.8461538462, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 9.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 1.15, 'avgdiff': 1, 'expectedWait': 4.4724594129, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 0.0, 'hashpower_accepting2': 44.2827225131, 'pct_remaining5m': 0.0, 'sum': 1.3472, 'tx_atabove': 226.0, 'hashpower_accepting': 80.0, 'hpa_coef2': -0.067, 'total_seen_30m': 126.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.0, 'pct_mined_5m': 98.0, 'total_seen_5m': 113.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.99, 'avgdiff': 1, 'expectedWait': 3.8466398462, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 44.4293193717, 'pct_remaining5m': 0.0, 'sum': 1.3448, 'tx_atabove': 222.0, 'hashpower_accepting': 80.0, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 5.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.99, 'avgdiff': 1, 'expectedWait': 3.8374189801, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 44.4921465969, 'pct_remaining5m': 0.0, 'sum': 1.3448, 'tx_atabove': 222.0, 'hashpower_accepting': 80.0, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.99, 'avgdiff': 1, 'expectedWait': 3.8374189801, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 44.502617801, 'pct_remaining5m': null, 'sum': 1.3448, 'tx_atabove': 222.0, 'hashpower_accepting': 80.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.3, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.99, 'avgdiff': 1, 'expectedWait': 3.8374189801, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 49.4240837696, 'pct_remaining5m': 0.0, 'sum': 1.2077230769, 'tx_atabove': 222.0, 'hashpower_accepting': 85.641025641, 'hpa_coef2': -0.067, 'total_seen_30m': 169.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 95.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.86, 'avgdiff': 1, 'expectedWait': 3.3458577122, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 49.4659685864, 'pct_remaining5m': 0.0, 'sum': 1.2077230769, 'tx_atabove': 222.0, 'hashpower_accepting': 85.641025641, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.6, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.86, 'avgdiff': 1, 'expectedWait': 3.3458577122, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 49.4869109948, 'pct_remaining5m': 0.0, 'sum': 1.2077230769, 'tx_atabove': 222.0, 'hashpower_accepting': 85.641025641, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 10.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.86, 'avgdiff': 1, 'expectedWait': 3.3458577122, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 49.6858638743, 'pct_remaining5m': 0.0, 'sum': 1.2077230769, 'tx_atabove': 222.0, 'hashpower_accepting': 85.641025641, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 10.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.86, 'avgdiff': 1, 'expectedWait': 3.3458577122, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 1.0, 'hashpower_accepting2': 50.0628272251, 'pct_remaining5m': 0.0, 'sum': 1.2071230769, 'tx_atabove': 221.0, 'hashpower_accepting': 85.641025641, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 10.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 11.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.86, 'avgdiff': 1, 'expectedWait': 3.3438507997, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 56.1884816754, 'pct_remaining5m': 0.0, 'sum': 1.1358153846, 'tx_atabove': 206.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 144.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 11.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 178.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.8, 'avgdiff': 1, 'expectedWait': 3.1137113805, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 56.7434554974, 'pct_remaining5m': 0.0, 'sum': 1.1280153846, 'tx_atabove': 193.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 11.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 9.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.79, 'avgdiff': 1, 'expectedWait': 3.089518905, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 56.7748691099, 'pct_remaining5m': null, 'sum': 1.1280153846, 'tx_atabove': 193.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 11.2, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.79, 'avgdiff': 1, 'expectedWait': 3.089518905, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 57.109947644, 'pct_remaining5m': 0.0, 'sum': 1.1280153846, 'tx_atabove': 193.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 14.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 11.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.79, 'avgdiff': 1, 'expectedWait': 3.089518905, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 58.2931937173, 'pct_remaining5m': 0.0, 'sum': 1.1280153846, 'tx_atabove': 193.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 10.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 11.6, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.79, 'avgdiff': 1, 'expectedWait': 3.089518905, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 58.3141361257, 'pct_remaining5m': null, 'sum': 1.1280153846, 'tx_atabove': 193.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 11.7, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.79, 'avgdiff': 1, 'expectedWait': 3.089518905, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 58.3769633508, 'pct_remaining5m': 0.0, 'sum': 1.1280153846, 'tx_atabove': 193.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 11.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.79, 'avgdiff': 1, 'expectedWait': 3.089518905, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 58.4293193717, 'pct_remaining5m': 0.0, 'sum': 1.1280153846, 'tx_atabove': 193.0, 'hashpower_accepting': 88.2051282051, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 11.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.79, 'avgdiff': 1, 'expectedWait': 3.089518905, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 60.7958115183, 'pct_remaining5m': 0.0, 'sum': 1.1030923077, 'tx_atabove': 193.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 50.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 12.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 87.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0134702079, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 61.1413612565, 'pct_remaining5m': 0.0, 'sum': 1.1018923077, 'tx_atabove': 191.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 15.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 12.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 8.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0098562125, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 61.1727748691, 'pct_remaining5m': 0.0, 'sum': 1.1018923077, 'tx_atabove': 191.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 12.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0098562125, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 61.1832460733, 'pct_remaining5m': 0.0, 'sum': 1.1018923077, 'tx_atabove': 191.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 12.3, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0098562125, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 61.1937172775, 'pct_remaining5m': 0.0, 'sum': 1.1018923077, 'tx_atabove': 191.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 12.4, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0098562125, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 62.1989528796, 'pct_remaining5m': 0.0, 'sum': 1.1018923077, 'tx_atabove': 191.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 10.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 12.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0098562125, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 62.3246073298, 'pct_remaining5m': 0.0, 'sum': 1.1006923077, 'tx_atabove': 189.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 12.6, 'pct_mined_5m': 100.0, 'total_seen_5m': 9.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0062465513, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 62.3560209424, 'pct_remaining5m': 0.0, 'sum': 1.1000923077, 'tx_atabove': 188.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 12.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0044433444, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 62.7434554974, 'pct_remaining5m': 0.0, 'sum': 1.1000923077, 'tx_atabove': 188.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 11.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 12.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 9.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0044433444, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 62.7643979058, 'pct_remaining5m': 0.0, 'sum': 1.1000923077, 'tx_atabove': 188.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 12.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0044433444, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 65.5183246073, 'pct_remaining5m': 0.0, 'sum': 1.0988923077, 'tx_atabove': 186.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 43.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 13.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 22.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0008401747, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 65.612565445, 'pct_remaining5m': 0.0, 'sum': 1.0988923077, 'tx_atabove': 186.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 13.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 3.0008401747, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 65.7277486911, 'pct_remaining5m': 0.0, 'sum': 1.0982923077, 'tx_atabove': 185.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 13.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 2.9990402106, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 65.7382198953, 'pct_remaining5m': null, 'sum': 1.0982923077, 'tx_atabove': 185.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 13.4, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 2.9990402106, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 67.1832460733, 'pct_remaining5m': 0.0, 'sum': 1.0982923077, 'tx_atabove': 185.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 18.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 13.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 11.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 2.9990402106, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 67.2041884817, 'pct_remaining5m': 0.0, 'sum': 1.0982923077, 'tx_atabove': 185.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 13.6, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 2.9990402106, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 67.3717277487, 'pct_remaining5m': null, 'sum': 1.0982923077, 'tx_atabove': 185.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 13.7, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 2.9990402106, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 67.3926701571, 'pct_remaining5m': 0.0, 'sum': 1.0982923077, 'tx_atabove': 185.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 13.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 2.9990402106, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 67.4136125654, 'pct_remaining5m': 0.0, 'sum': 1.0982923077, 'tx_atabove': 185.0, 'hashpower_accepting': 89.2307692308, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 13.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.77, 'avgdiff': 1, 'expectedWait': 2.9990402106, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 1.0, 'hashpower_accepting2': 69.8219895288, 'pct_remaining5m': 0.0, 'sum': 1.0727692308, 'tx_atabove': 184.0, 'hashpower_accepting': 90.2564102564, 'hpa_coef2': -0.067, 'total_seen_30m': 25.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 14.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 59.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.75, 'avgdiff': 1, 'expectedWait': 2.9234640474, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 69.8743455497, 'pct_remaining5m': 0.0, 'sum': 1.0691692308, 'tx_atabove': 178.0, 'hashpower_accepting': 90.2564102564, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 14.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.75, 'avgdiff': 1, 'expectedWait': 2.9129584982, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 69.9057591623, 'pct_remaining5m': 0.0, 'sum': 1.0691692308, 'tx_atabove': 178.0, 'hashpower_accepting': 90.2564102564, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 14.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.75, 'avgdiff': 1, 'expectedWait': 2.9129584982, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 69.9581151832, 'pct_remaining5m': 0.0, 'sum': 1.0691692308, 'tx_atabove': 178.0, 'hashpower_accepting': 90.2564102564, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 14.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.75, 'avgdiff': 1, 'expectedWait': 2.9129584982, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 70.0104712042, 'pct_remaining5m': null, 'sum': 1.0691692308, 'tx_atabove': 178.0, 'hashpower_accepting': 90.2564102564, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 14.6, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.75, 'avgdiff': 1, 'expectedWait': 2.9129584982, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 70.1047120419, 'pct_remaining5m': 0.0, 'sum': 1.0691692308, 'tx_atabove': 178.0, 'hashpower_accepting': 90.2564102564, 'hpa_coef2': -0.067, 'total_seen_30m': 7.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 14.7, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.75, 'avgdiff': 1, 'expectedWait': 2.9129584982, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 72.7434554974, 'pct_remaining5m': 0.0, 'sum': 1.0442461538, 'tx_atabove': 178.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 83.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 15.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 59.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.73, 'avgdiff': 1, 'expectedWait': 2.8412558463, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 72.7853403141, 'pct_remaining5m': 0.0, 'sum': 1.0400461538, 'tx_atabove': 171.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 15.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.73, 'avgdiff': 1, 'expectedWait': 2.8293475966, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 72.8062827225, 'pct_remaining5m': null, 'sum': 1.0400461538, 'tx_atabove': 171.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 15.4, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.73, 'avgdiff': 1, 'expectedWait': 2.8293475966, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 72.8586387435, 'pct_remaining5m': 0.0, 'sum': 1.0400461538, 'tx_atabove': 171.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 15.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.73, 'avgdiff': 1, 'expectedWait': 2.8293475966, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 73.3298429319, 'pct_remaining5m': 0.0, 'sum': 1.0400461538, 'tx_atabove': 171.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 16.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 15.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 10.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.73, 'avgdiff': 1, 'expectedWait': 2.8293475966, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 73.3403141361, 'pct_remaining5m': 0.0, 'sum': 1.0400461538, 'tx_atabove': 171.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 15.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.73, 'avgdiff': 1, 'expectedWait': 2.8293475966, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 74.4397905759, 'pct_remaining5m': 0.0, 'sum': 1.0400461538, 'tx_atabove': 171.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 36.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 16.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 28.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.73, 'avgdiff': 1, 'expectedWait': 2.8293475966, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 74.4502617801, 'pct_remaining5m': 0.0, 'sum': 1.0340461538, 'tx_atabove': 161.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 16.1, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8124223376, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 74.4607329843, 'pct_remaining5m': 0.0, 'sum': 1.0340461538, 'tx_atabove': 161.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 16.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8124223376, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 74.502617801, 'pct_remaining5m': 0.0, 'sum': 1.0340461538, 'tx_atabove': 161.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 16.3, 'pct_mined_5m': 50.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8124223376, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 74.6806282723, 'pct_remaining5m': 0.0, 'sum': 1.0334461538, 'tx_atabove': 160.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 16.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 9.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8107353903, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 74.7120418848, 'pct_remaining5m': 0.0, 'sum': 1.0334461538, 'tx_atabove': 160.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 16.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8107353903, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 74.7434554974, 'pct_remaining5m': null, 'sum': 1.0334461538, 'tx_atabove': 160.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 16.8, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8107353903, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.0785340314, 'pct_remaining5m': 0.0, 'sum': 1.0334461538, 'tx_atabove': 160.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 17.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 17.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 5.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8107353903, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.1204188482, 'pct_remaining5m': 0.0, 'sum': 1.0334461538, 'tx_atabove': 160.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 17.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8107353903, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.1413612565, 'pct_remaining5m': 0.0, 'sum': 1.0334461538, 'tx_atabove': 160.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 17.7, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8107353903, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.1518324607, 'pct_remaining5m': null, 'sum': 1.0334461538, 'tx_atabove': 160.0, 'hashpower_accepting': 91.2820512821, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 17.8, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.72, 'avgdiff': 1, 'expectedWait': 2.8107353903, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.4764397906, 'pct_remaining5m': 0.0, 'sum': 1.0209846154, 'tx_atabove': 160.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': 14.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 18.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7759266389, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.5183246073, 'pct_remaining5m': 0.0, 'sum': 1.0209846154, 'tx_atabove': 160.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 18.1, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7759266389, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.5287958115, 'pct_remaining5m': null, 'sum': 1.0209846154, 'tx_atabove': 160.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 18.3, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7759266389, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.5497382199, 'pct_remaining5m': 0.0, 'sum': 1.0209846154, 'tx_atabove': 160.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 18.4, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7759266389, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.5811518325, 'pct_remaining5m': null, 'sum': 1.0209846154, 'tx_atabove': 160.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 18.9, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7759266389, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.6230366492, 'pct_remaining5m': 0.0, 'sum': 1.0209846154, 'tx_atabove': 160.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 19.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7759266389, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.6753926702, 'pct_remaining5m': 0.0, 'sum': 1.0203846154, 'tx_atabove': 159.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 19.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7742615825, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.7068062827, 'pct_remaining5m': 0.0, 'sum': 1.0197846154, 'tx_atabove': 158.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 19.7, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7725975248, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 75.7172774869, 'pct_remaining5m': 0.0, 'sum': 1.0197846154, 'tx_atabove': 158.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 19.8, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7725975248, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 76.1570680628, 'pct_remaining5m': 0.0, 'sum': 1.0197846154, 'tx_atabove': 158.0, 'hashpower_accepting': 91.7948717949, 'hpa_coef2': -0.067, 'total_seen_30m': 16.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 19.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 12.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.71, 'avgdiff': 1, 'expectedWait': 2.7725975248, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 0.0, 'hashpower_accepting2': 80.335078534, 'pct_remaining5m': 0.0, 'sum': 0.9076307692, 'tx_atabove': 158.0, 'hashpower_accepting': 96.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': 109.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 20.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 132.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.64, 'avgdiff': 1, 'expectedWait': 2.4784435671, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 80.3455497382, 'pct_remaining5m': 0.0, 'sum': 0.8446307692, 'tx_atabove': 53.0, 'hashpower_accepting': 96.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 20.2, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.6, 'avgdiff': 1, 'expectedWait': 2.3271184122, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 80.3769633508, 'pct_remaining5m': 0.0, 'sum': 0.8446307692, 'tx_atabove': 53.0, 'hashpower_accepting': 96.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 20.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.6, 'avgdiff': 1, 'expectedWait': 2.3271184122, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 80.3979057592, 'pct_remaining5m': 0.0, 'sum': 0.8446307692, 'tx_atabove': 53.0, 'hashpower_accepting': 96.4102564103, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 20.9, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.6, 'avgdiff': 1, 'expectedWait': 2.3271184122, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 82.6596858639, 'pct_remaining5m': 0.0, 'sum': 0.8321692308, 'tx_atabove': 53.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 37.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 21.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 30.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2982988774, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 2.0, 'hashpower_accepting2': 82.8586387435, 'pct_remaining5m': null, 'sum': 0.8303692308, 'tx_atabove': 50.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 21.1, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2941656605, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 82.9005235602, 'pct_remaining5m': 0.0, 'sum': 0.8297692308, 'tx_atabove': 49.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 21.3, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2927895739, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 82.9633507853, 'pct_remaining5m': 0.0, 'sum': 0.8297692308, 'tx_atabove': 49.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 21.6, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2927895739, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 82.9738219895, 'pct_remaining5m': null, 'sum': 0.8297692308, 'tx_atabove': 49.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 21.7, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2927895739, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 82.9947643979, 'pct_remaining5m': null, 'sum': 0.8297692308, 'tx_atabove': 49.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 21.9, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2927895739, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 1.0, 'hashpower_accepting2': 83.2041884817, 'pct_remaining5m': 0.0, 'sum': 0.8297692308, 'tx_atabove': 49.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 4.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 22.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 7.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2927895739, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 83.2565445026, 'pct_remaining5m': null, 'sum': 0.8291692308, 'tx_atabove': 48.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 22.6, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2914143128, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 83.2670157068, 'pct_remaining5m': null, 'sum': 0.8291692308, 'tx_atabove': 48.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 22.8, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2914143128, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 83.9895287958, 'pct_remaining5m': 0.0, 'sum': 0.8291692308, 'tx_atabove': 48.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 28.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 23.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 15.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2914143128, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.0523560209, 'pct_remaining5m': null, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 23.1, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.0628272251, 'pct_remaining5m': 0.0, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 23.4, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.0837696335, 'pct_remaining5m': null, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 23.5, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.1570680628, 'pct_remaining5m': 0.0, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 24.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.167539267, 'pct_remaining5m': null, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 24.2, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.1780104712, 'pct_remaining5m': null, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 24.3, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.1884816754, 'pct_remaining5m': 0.0, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 24.5, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.3664921466, 'pct_remaining5m': 0.0, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 5.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 25.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.502617801, 'pct_remaining5m': 0.0, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 26.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.5340314136, 'pct_remaining5m': null, 'sum': 0.8285692308, 'tx_atabove': 47.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 27.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2900398766, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.7120418848, 'pct_remaining5m': 0.0, 'sum': 0.8279692308, 'tx_atabove': 46.0, 'hashpower_accepting': 96.9230769231, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 28.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.59, 'avgdiff': 1, 'expectedWait': 2.2886662648, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 84.9633507853, 'pct_remaining5m': 0.0, 'sum': 0.8155076923, 'tx_atabove': 46.0, 'hashpower_accepting': 97.4358974359, 'hpa_coef2': -0.067, 'total_seen_30m': 6.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 29.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 9.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.58, 'avgdiff': 1, 'expectedWait': 2.2603229297, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 85.664921466, 'pct_remaining5m': 0.0, 'sum': 0.8143076923, 'tx_atabove': 44.0, 'hashpower_accepting': 97.4358974359, 'hpa_coef2': -0.067, 'total_seen_30m': 17.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 30.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 26.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.58, 'avgdiff': 1, 'expectedWait': 2.2576121689, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 85.7696335079, 'pct_remaining5m': 0.0, 'sum': 0.8143076923, 'tx_atabove': 44.0, 'hashpower_accepting': 97.4358974359, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 31.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.58, 'avgdiff': 1, 'expectedWait': 2.2576121689, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.0628272251, 'pct_remaining5m': 0.0, 'sum': 0.7893846154, 'tx_atabove': 44.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 14.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 32.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.57, 'avgdiff': 1, 'expectedWait': 2.2020409071, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.2617801047, 'pct_remaining5m': 0.0, 'sum': 0.7887846154, 'tx_atabove': 43.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 33.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 4.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.2007200789, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.3560209424, 'pct_remaining5m': 0.0, 'sum': 0.7887846154, 'tx_atabove': 43.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 34.0, 'pct_mined_5m': 50.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.2007200789, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.5235602094, 'pct_remaining5m': 0.0, 'sum': 0.7887846154, 'tx_atabove': 43.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 7.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 35.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.2007200789, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.5654450262, 'pct_remaining5m': 0.0, 'sum': 0.7881846154, 'tx_atabove': 42.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 36.0, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.1994000429, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.5863874346, 'pct_remaining5m': null, 'sum': 0.7881846154, 'tx_atabove': 42.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 37.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.1994000429, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.6178010471, 'pct_remaining5m': 0.0, 'sum': 0.7881846154, 'tx_atabove': 42.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 38.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.1994000429, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 86.6701570681, 'pct_remaining5m': null, 'sum': 0.7881846154, 'tx_atabove': 42.0, 'hashpower_accepting': 98.4615384615, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 39.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.1994000429, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 87.3612565445, 'pct_remaining5m': 0.0, 'sum': 0.7757230769, 'tx_atabove': 42.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 13.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 40.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 19.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.1721621998, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 89.8115183246, 'pct_remaining5m': 0.0, 'sum': 0.7751230769, 'tx_atabove': 41.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 65.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 41.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 84.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.56, 'avgdiff': 1, 'expectedWait': 2.1708592934, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 90.3141361257, 'pct_remaining5m': 0.0, 'sum': 0.7709230769, 'tx_atabove': 34.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 13.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 42.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 16.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1617608046, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 90.3455497382, 'pct_remaining5m': 0.0, 'sum': 0.7697230769, 'tx_atabove': 32.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 43.0, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1591682475, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 90.3769633508, 'pct_remaining5m': 0.0, 'sum': 0.7697230769, 'tx_atabove': 32.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 44.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1591682475, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 92.0314136126, 'pct_remaining5m': 0.0, 'sum': 0.7697230769, 'tx_atabove': 32.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 19.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 45.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 28.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1591682475, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 92.0628272251, 'pct_remaining5m': 0.0, 'sum': 0.7691230769, 'tx_atabove': 31.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 46.0, 'pct_mined_5m': 0.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1578731351, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 92.1047120419, 'pct_remaining5m': 0.0, 'sum': 0.7691230769, 'tx_atabove': 31.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 47.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1578731351, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 92.1570680628, 'pct_remaining5m': 0.0, 'sum': 0.7685230769, 'tx_atabove': 30.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 48.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1565787996, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 92.1884816754, 'pct_remaining5m': 0.0, 'sum': 0.7685230769, 'tx_atabove': 30.0, 'hashpower_accepting': 98.9743589744, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 49.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.55, 'avgdiff': 1, 'expectedWait': 2.1565787996, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': 0.0, 'hashpower_accepting2': 95.5287958115, 'pct_remaining5m': 0.0, 'sum': 0.7436, 'tx_atabove': 30.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 87.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 50.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 54.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.54, 'avgdiff': 1, 'expectedWait': 2.1034944803, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 95.7172774869, 'pct_remaining5m': 0.0, 'sum': 0.734, 'tx_atabove': 14.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 51.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 9.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0833975529, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 95.9057591623, 'pct_remaining5m': 0.0, 'sum': 0.734, 'tx_atabove': 14.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 52.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 5.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0833975529, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 95.9371727749, 'pct_remaining5m': 0.0, 'sum': 0.7316, 'tx_atabove': 10.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 54.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0784033942, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 95.9895287958, 'pct_remaining5m': null, 'sum': 0.7316, 'tx_atabove': 10.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 55.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0784033942, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 96.0418848168, 'pct_remaining5m': 0.0, 'sum': 0.7316, 'tx_atabove': 10.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 57.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0784033942, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 96.0523560209, 'pct_remaining5m': null, 'sum': 0.7316, 'tx_atabove': 10.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 58.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0784033942, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 96.0628272251, 'pct_remaining5m': 0.0, 'sum': 0.7316, 'tx_atabove': 10.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 59.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0784033942, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 96.4083769634, 'pct_remaining5m': 0.0, 'sum': 0.7316, 'tx_atabove': 10.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 5.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 60.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 13.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0784033942, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 97.445026178, 'pct_remaining5m': 0.0, 'sum': 0.7298, 'tx_atabove': 7.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 21.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 61.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 14.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0746656331, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.1570680628, 'pct_remaining5m': 0.0, 'sum': 0.7292, 'tx_atabove': 6.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 12.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 63.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 15.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.073421207, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.3979057592, 'pct_remaining5m': 0.0, 'sum': 0.7286, 'tx_atabove': 5.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 7.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 64.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0721775275, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.4293193717, 'pct_remaining5m': 0.0, 'sum': 0.7286, 'tx_atabove': 5.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 65.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0721775275, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.6492146597, 'pct_remaining5m': 0.0, 'sum': 0.7286, 'tx_atabove': 5.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 6.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 66.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0721775275, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.6701570681, 'pct_remaining5m': 0.0, 'sum': 0.7286, 'tx_atabove': 5.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 67.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0721775275, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.6806282723, 'pct_remaining5m': null, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 68.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.7120418848, 'pct_remaining5m': 0.0, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 70.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.7958115183, 'pct_remaining5m': 0.0, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 71.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 98.8376963351, 'pct_remaining5m': 0.0, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 77.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.0261780105, 'pct_remaining5m': 0.0, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 5.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 80.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 9.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.0471204188, 'pct_remaining5m': null, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 81.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.057591623, 'pct_remaining5m': 0.0, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 85.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.0680628272, 'pct_remaining5m': null, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 86.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.1937172775, 'pct_remaining5m': 0.0, 'sum': 0.728, 'tx_atabove': 4.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 88.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0709345939, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.3717277487, 'pct_remaining5m': 0.0, 'sum': 0.7268, 'tx_atabove': 2.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 90.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 3.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0684509628, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.4031413613, 'pct_remaining5m': null, 'sum': 0.7268, 'tx_atabove': 2.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 91.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0684509628, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.4136125654, 'pct_remaining5m': null, 'sum': 0.7268, 'tx_atabove': 2.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 94.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0684509628, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.4240837696, 'pct_remaining5m': null, 'sum': 0.7268, 'tx_atabove': 2.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 98.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0684509628, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.5287958115, 'pct_remaining5m': 0.0, 'sum': 0.7268, 'tx_atabove': 2.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 3.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 99.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0684509628, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.780104712, 'pct_remaining5m': 0.0, 'sum': 0.7268, 'tx_atabove': 2.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 6.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 100.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 6.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0684509628, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.7905759162, 'pct_remaining5m': 0.0, 'sum': 0.7262, 'tx_atabove': 1.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 101.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0672102645, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.8848167539, 'pct_remaining5m': 0.0, 'sum': 0.7262, 'tx_atabove': 1.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 1.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 102.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0672102645, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.8952879581, 'pct_remaining5m': 0.0, 'sum': 0.7262, 'tx_atabove': 1.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 120.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0672102645, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.9371727749, 'pct_remaining5m': 0.0, 'sum': 0.7262, 'tx_atabove': 1.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 122.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 2.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0672102645, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 99.9790575916, 'pct_remaining5m': null, 'sum': 0.7256, 'tx_atabove': 0.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': 2.0, 'int2': 6.9238, 'pct_remaining30m': 0.0, 'gasprice': 134.0, 'pct_mined_5m': null, 'total_seen_5m': null, 'pct_mined_30m': 100.0, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0659703104, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}, {'intercept': 4.8015, 'age': null, 'hashpower_accepting2': 100.0, 'pct_remaining5m': 0.0, 'sum': 0.7256, 'tx_atabove': 0.0, 'hashpower_accepting': 100.0, 'hpa_coef2': -0.067, 'total_seen_30m': null, 'int2': 6.9238, 'pct_remaining30m': null, 'gasprice': 180.0, 'pct_mined_5m': 100.0, 'total_seen_5m': 1.0, 'pct_mined_30m': null, 'tx_atabove_coef': 0.0006, 'average': 500, 'safelow': 500, 'nomine': 330, 'expectedTime': 0.53, 'avgdiff': 1, 'expectedWait': 2.0659703104, 'avgdiff_coef': -1.6459, 'hpa_coef': -0.0243}], -} diff --git a/ui/app/ducks/send/send-duck.test.js b/ui/app/ducks/send/send-duck.test.js new file mode 100644 index 000000000..92c8dffd8 --- /dev/null +++ b/ui/app/ducks/send/send-duck.test.js @@ -0,0 +1,186 @@ +import assert from 'assert' + +import SendReducer, { + openToDropdown, + closeToDropdown, + updateSendErrors, + showGasButtonGroup, + hideGasButtonGroup, + updateSendWarnings, +} from './send.duck.js' + +describe('Send Duck', () => { + const mockState = { + send: { + mockProp: 123, + }, + } + const initState = { + 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', () => { + assert.deepEqual( + SendReducer({}), + initState + ) + }) + + it('should return state unchanged if it does not match a dispatched actions type', () => { + assert.deepEqual( + SendReducer(mockState, { + type: 'someOtherAction', + value: 'someValue', + }), + Object.assign({}, mockState.send) + ) + }) + + it('should set fromDropdownOpen to true when receiving a OPEN_FROM_DROPDOWN action', () => { + assert.deepEqual( + SendReducer(mockState, { + type: OPEN_FROM_DROPDOWN, + }), + Object.assign({fromDropdownOpen: true}, mockState.send) + ) + }) + + it('should return a new object (and not just modify the existing state object)', () => { + assert.deepEqual(SendReducer(mockState), mockState.send) + assert.notEqual(SendReducer(mockState), mockState.send) + }) + + it('should set fromDropdownOpen to false when receiving a CLOSE_FROM_DROPDOWN action', () => { + assert.deepEqual( + SendReducer(mockState, { + type: CLOSE_FROM_DROPDOWN, + }), + Object.assign({fromDropdownOpen: false}, mockState.send) + ) + }) + + it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => { + assert.deepEqual( + SendReducer(mockState, { + type: OPEN_TO_DROPDOWN, + }), + Object.assign({toDropdownOpen: true}, mockState.send) + ) + }) + + it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => { + assert.deepEqual( + SendReducer(mockState, { + type: CLOSE_TO_DROPDOWN, + }), + Object.assign({toDropdownOpen: false}, mockState.send) + ) + }) + + 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: { + errors: { + someError: false, + }, + }, + }) + assert.deepEqual( + SendReducer(modifiedMockState, { + type: UPDATE_SEND_ERRORS, + value: { someOtherError: true }, + }), + Object.assign({}, modifiedMockState.send, { + errors: { + someError: false, + someOtherError: true, + }, + }) + ) + }) + + it('should return the initial state in response to a RESET_SEND_STATE action', () => { + assert.deepEqual( + SendReducer(mockState, { + type: RESET_SEND_STATE, + }), + Object.assign({}, initState) + ) + }) + }) + + describe('openToDropdown', () => { + assert.deepEqual( + openToDropdown(), + { type: OPEN_TO_DROPDOWN } + ) + }) + + describe('closeToDropdown', () => { + assert.deepEqual( + closeToDropdown(), + { type: CLOSE_TO_DROPDOWN } + ) + }) + + describe('showGasButtonGroup', () => { + assert.deepEqual( + showGasButtonGroup(), + { type: SHOW_GAS_BUTTON_GROUP } + ) + }) + + describe('hideGasButtonGroup', () => { + assert.deepEqual( + hideGasButtonGroup(), + { type: HIDE_GAS_BUTTON_GROUP } + ) + }) + + describe('updateSendErrors', () => { + assert.deepEqual( + updateSendErrors('mockErrorObject'), + { type: UPDATE_SEND_ERRORS, value: 'mockErrorObject' } + ) + }) + + 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 90e92140b..90e92140b 100644 --- a/ui/app/ducks/send.duck.js +++ b/ui/app/ducks/send/send.duck.js diff --git a/ui/app/ducks/tests/confirm-transaction.duck.test.js b/ui/app/ducks/tests/confirm-transaction.duck.test.js deleted file mode 100644 index eceacd0bd..000000000 --- a/ui/app/ducks/tests/confirm-transaction.duck.test.js +++ /dev/null @@ -1,685 +0,0 @@ -import assert from 'assert' -import configureMockStore from 'redux-mock-store' -import thunk from 'redux-thunk' -import sinon from 'sinon' - -import ConfirmTransactionReducer, * as actions from '../confirm-transaction.duck.js' - -const initialState = { - txData: {}, - tokenData: {}, - methodData: {}, - tokenProps: { - tokenDecimals: '', - tokenSymbol: '', - }, - fiatTransactionAmount: '', - fiatTransactionFee: '', - fiatTransactionTotal: '', - ethTransactionAmount: '', - ethTransactionFee: '', - ethTransactionTotal: '', - hexTransactionAmount: '', - hexTransactionFee: '', - hexTransactionTotal: '', - nonce: '', - toSmartContract: false, - fetchingData: false, -} - -const UPDATE_TX_DATA = 'metamask/confirm-transaction/UPDATE_TX_DATA' -const CLEAR_TX_DATA = 'metamask/confirm-transaction/CLEAR_TX_DATA' -const UPDATE_TOKEN_DATA = 'metamask/confirm-transaction/UPDATE_TOKEN_DATA' -const CLEAR_TOKEN_DATA = 'metamask/confirm-transaction/CLEAR_TOKEN_DATA' -const UPDATE_METHOD_DATA = 'metamask/confirm-transaction/UPDATE_METHOD_DATA' -const CLEAR_METHOD_DATA = 'metamask/confirm-transaction/CLEAR_METHOD_DATA' -const UPDATE_TRANSACTION_AMOUNTS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS' -const UPDATE_TRANSACTION_FEES = 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES' -const UPDATE_TRANSACTION_TOTALS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS' -const UPDATE_TOKEN_PROPS = 'metamask/confirm-transaction/UPDATE_TOKEN_PROPS' -const UPDATE_NONCE = 'metamask/confirm-transaction/UPDATE_NONCE' -const UPDATE_TO_SMART_CONTRACT = 'metamask/confirm-transaction/UPDATE_TO_SMART_CONTRACT' -const FETCH_DATA_START = 'metamask/confirm-transaction/FETCH_DATA_START' -const FETCH_DATA_END = 'metamask/confirm-transaction/FETCH_DATA_END' -const CLEAR_CONFIRM_TRANSACTION = 'metamask/confirm-transaction/CLEAR_CONFIRM_TRANSACTION' - -describe('Confirm Transaction Duck', () => { - describe('State changes', () => { - const mockState = { - confirmTransaction: { - txData: { - id: 1, - }, - tokenData: { - name: 'abcToken', - }, - methodData: { - name: 'approve', - }, - tokenProps: { - tokenDecimals: '3', - tokenSymbol: 'ABC', - }, - fiatTransactionAmount: '469.26', - fiatTransactionFee: '0.01', - fiatTransactionTotal: '1.000021', - ethTransactionAmount: '1', - ethTransactionFee: '0.000021', - ethTransactionTotal: '469.27', - hexTransactionAmount: '', - hexTransactionFee: '0x1319718a5000', - hexTransactionTotal: '', - nonce: '0x0', - toSmartContract: false, - fetchingData: false, - }, - } - - it('should initialize state', () => { - assert.deepEqual( - ConfirmTransactionReducer({}), - initialState - ) - }) - - it('should return state unchanged if it does not match a dispatched actions type', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: 'someOtherAction', - value: 'someValue', - }), - { ...mockState.confirmTransaction }, - ) - }) - - it('should set txData when receiving a UPDATE_TX_DATA action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_TX_DATA, - payload: { - id: 2, - }, - }), - { - ...mockState.confirmTransaction, - txData: { - ...mockState.confirmTransaction.txData, - id: 2, - }, - } - ) - }) - - it('should clear txData when receiving a CLEAR_TX_DATA action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: CLEAR_TX_DATA, - }), - { - ...mockState.confirmTransaction, - txData: {}, - } - ) - }) - - it('should set tokenData when receiving a UPDATE_TOKEN_DATA action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_TOKEN_DATA, - payload: { - name: 'defToken', - }, - }), - { - ...mockState.confirmTransaction, - tokenData: { - ...mockState.confirmTransaction.tokenData, - name: 'defToken', - }, - } - ) - }) - - it('should clear tokenData when receiving a CLEAR_TOKEN_DATA action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: CLEAR_TOKEN_DATA, - }), - { - ...mockState.confirmTransaction, - tokenData: {}, - } - ) - }) - - it('should set methodData when receiving a UPDATE_METHOD_DATA action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_METHOD_DATA, - payload: { - name: 'transferFrom', - }, - }), - { - ...mockState.confirmTransaction, - methodData: { - ...mockState.confirmTransaction.methodData, - name: 'transferFrom', - }, - } - ) - }) - - it('should clear methodData when receiving a CLEAR_METHOD_DATA action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: CLEAR_METHOD_DATA, - }), - { - ...mockState.confirmTransaction, - methodData: {}, - } - ) - }) - - it('should update transaction amounts when receiving an UPDATE_TRANSACTION_AMOUNTS action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_TRANSACTION_AMOUNTS, - payload: { - fiatTransactionAmount: '123.45', - ethTransactionAmount: '.5', - hexTransactionAmount: '0x1', - }, - }), - { - ...mockState.confirmTransaction, - fiatTransactionAmount: '123.45', - ethTransactionAmount: '.5', - hexTransactionAmount: '0x1', - } - ) - }) - - it('should update transaction fees when receiving an UPDATE_TRANSACTION_FEES action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_TRANSACTION_FEES, - payload: { - fiatTransactionFee: '123.45', - ethTransactionFee: '.5', - hexTransactionFee: '0x1', - }, - }), - { - ...mockState.confirmTransaction, - fiatTransactionFee: '123.45', - ethTransactionFee: '.5', - hexTransactionFee: '0x1', - } - ) - }) - - it('should update transaction totals when receiving an UPDATE_TRANSACTION_TOTALS action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_TRANSACTION_TOTALS, - payload: { - fiatTransactionTotal: '123.45', - ethTransactionTotal: '.5', - hexTransactionTotal: '0x1', - }, - }), - { - ...mockState.confirmTransaction, - fiatTransactionTotal: '123.45', - ethTransactionTotal: '.5', - hexTransactionTotal: '0x1', - } - ) - }) - - it('should update tokenProps when receiving an UPDATE_TOKEN_PROPS action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_TOKEN_PROPS, - payload: { - tokenSymbol: 'DEF', - tokenDecimals: '1', - }, - }), - { - ...mockState.confirmTransaction, - tokenProps: { - tokenSymbol: 'DEF', - tokenDecimals: '1', - }, - } - ) - }) - - it('should update nonce when receiving an UPDATE_NONCE action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_NONCE, - payload: '0x1', - }), - { - ...mockState.confirmTransaction, - nonce: '0x1', - } - ) - }) - - it('should update nonce when receiving an UPDATE_TO_SMART_CONTRACT action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: UPDATE_TO_SMART_CONTRACT, - payload: true, - }), - { - ...mockState.confirmTransaction, - toSmartContract: true, - } - ) - }) - - it('should set fetchingData to true when receiving a FETCH_DATA_START action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: FETCH_DATA_START, - }), - { - ...mockState.confirmTransaction, - fetchingData: true, - } - ) - }) - - it('should set fetchingData to false when receiving a FETCH_DATA_END action', () => { - assert.deepEqual( - ConfirmTransactionReducer({ confirmTransaction: { fetchingData: true } }, { - type: FETCH_DATA_END, - }), - { - fetchingData: false, - } - ) - }) - - it('should clear confirmTransaction when receiving a FETCH_DATA_END action', () => { - assert.deepEqual( - ConfirmTransactionReducer(mockState, { - type: CLEAR_CONFIRM_TRANSACTION, - }), - { - ...initialState, - } - ) - }) - }) - - describe('Single actions', () => { - it('should create an action to update txData', () => { - const txData = { test: 123 } - const expectedAction = { - type: UPDATE_TX_DATA, - payload: txData, - } - - assert.deepEqual( - actions.updateTxData(txData), - expectedAction - ) - }) - - it('should create an action to clear txData', () => { - const expectedAction = { - type: CLEAR_TX_DATA, - } - - assert.deepEqual( - actions.clearTxData(), - expectedAction - ) - }) - - it('should create an action to update tokenData', () => { - const tokenData = { test: 123 } - const expectedAction = { - type: UPDATE_TOKEN_DATA, - payload: tokenData, - } - - assert.deepEqual( - actions.updateTokenData(tokenData), - expectedAction - ) - }) - - it('should create an action to clear tokenData', () => { - const expectedAction = { - type: CLEAR_TOKEN_DATA, - } - - assert.deepEqual( - actions.clearTokenData(), - expectedAction - ) - }) - - it('should create an action to update methodData', () => { - const methodData = { test: 123 } - const expectedAction = { - type: UPDATE_METHOD_DATA, - payload: methodData, - } - - assert.deepEqual( - actions.updateMethodData(methodData), - expectedAction - ) - }) - - it('should create an action to clear methodData', () => { - const expectedAction = { - type: CLEAR_METHOD_DATA, - } - - assert.deepEqual( - actions.clearMethodData(), - expectedAction - ) - }) - - it('should create an action to update transaction amounts', () => { - const transactionAmounts = { test: 123 } - const expectedAction = { - type: UPDATE_TRANSACTION_AMOUNTS, - payload: transactionAmounts, - } - - assert.deepEqual( - actions.updateTransactionAmounts(transactionAmounts), - expectedAction - ) - }) - - it('should create an action to update transaction fees', () => { - const transactionFees = { test: 123 } - const expectedAction = { - type: UPDATE_TRANSACTION_FEES, - payload: transactionFees, - } - - assert.deepEqual( - actions.updateTransactionFees(transactionFees), - expectedAction - ) - }) - - it('should create an action to update transaction totals', () => { - const transactionTotals = { test: 123 } - const expectedAction = { - type: UPDATE_TRANSACTION_TOTALS, - payload: transactionTotals, - } - - assert.deepEqual( - actions.updateTransactionTotals(transactionTotals), - expectedAction - ) - }) - - it('should create an action to update tokenProps', () => { - const tokenProps = { - tokenDecimals: '1', - tokenSymbol: 'abc', - } - const expectedAction = { - type: UPDATE_TOKEN_PROPS, - payload: tokenProps, - } - - assert.deepEqual( - actions.updateTokenProps(tokenProps), - expectedAction - ) - }) - - it('should create an action to update nonce', () => { - const nonce = '0x1' - const expectedAction = { - type: UPDATE_NONCE, - payload: nonce, - } - - assert.deepEqual( - actions.updateNonce(nonce), - expectedAction - ) - }) - - it('should create an action to set fetchingData to true', () => { - const expectedAction = { - type: FETCH_DATA_START, - } - - assert.deepEqual( - actions.setFetchingData(true), - expectedAction - ) - }) - - it('should create an action to set fetchingData to false', () => { - const expectedAction = { - type: FETCH_DATA_END, - } - - assert.deepEqual( - actions.setFetchingData(false), - expectedAction - ) - }) - - it('should create an action to clear confirmTransaction', () => { - const expectedAction = { - type: CLEAR_CONFIRM_TRANSACTION, - } - - assert.deepEqual( - actions.clearConfirmTransaction(), - expectedAction - ) - }) - }) - - describe('Thunk actions', done => { - beforeEach(() => { - global.eth = { - getCode: sinon.stub().callsFake( - address => Promise.resolve(address && address.match(/isContract/) ? 'not-0x' : '0x') - ), - } - }) - - afterEach(() => { - global.eth.getCode.resetHistory() - }) - - it('updates txData and gas on an existing transaction in confirmTransaction', () => { - const mockState = { - metamask: { - conversionRate: 468.58, - currentCurrency: 'usd', - }, - confirmTransaction: { - ethTransactionAmount: '1', - ethTransactionFee: '0.000021', - ethTransactionTotal: '1.000021', - fetchingData: false, - fiatTransactionAmount: '469.26', - fiatTransactionFee: '0.01', - fiatTransactionTotal: '469.27', - hexGasTotal: '0x1319718a5000', - methodData: {}, - nonce: '', - tokenData: {}, - tokenProps: { - tokenDecimals: '', - tokenSymbol: '', - }, - txData: { - estimatedGas: '0x5208', - gasLimitSpecified: false, - gasPriceSpecified: false, - history: [], - id: 2603411941761054, - loadingDefaults: false, - metamaskNetworkId: '3', - origin: 'faucet.metamask.io', - simpleSend: true, - status: 'unapproved', - time: 1530838113716, - }, - }, - } - - const middlewares = [thunk] - const mockStore = configureMockStore(middlewares) - const store = mockStore(mockState) - const expectedActions = [ - 'metamask/confirm-transaction/UPDATE_TX_DATA', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', - ] - - store.dispatch(actions.updateGasAndCalculate({ gasLimit: '0x2', gasPrice: '0x25' })) - - const storeActions = store.getActions() - assert.equal(storeActions.length, expectedActions.length) - storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) - }) - - it('updates txData and updates gas values in confirmTransaction', () => { - const txData = { - estimatedGas: '0x5208', - gasLimitSpecified: false, - gasPriceSpecified: false, - history: [], - id: 2603411941761054, - loadingDefaults: false, - metamaskNetworkId: '3', - origin: 'faucet.metamask.io', - simpleSend: true, - status: 'unapproved', - time: 1530838113716, - txParams: { - from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', - gas: '0x33450', - gasPrice: '0x2540be400', - to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', - value: '0xde0b6b3a7640000', - }, - } - const mockState = { - metamask: { - conversionRate: 468.58, - currentCurrency: 'usd', - }, - confirmTransaction: { - ethTransactionAmount: '1', - ethTransactionFee: '0.000021', - ethTransactionTotal: '1.000021', - fetchingData: false, - fiatTransactionAmount: '469.26', - fiatTransactionFee: '0.01', - fiatTransactionTotal: '469.27', - hexGasTotal: '0x1319718a5000', - methodData: {}, - nonce: '', - tokenData: {}, - tokenProps: { - tokenDecimals: '', - tokenSymbol: '', - }, - txData: { - ...txData, - txParams: { - ...txData.txParams, - }, - }, - }, - } - - const middlewares = [thunk] - const mockStore = configureMockStore(middlewares) - const store = mockStore(mockState) - const expectedActions = [ - 'metamask/confirm-transaction/UPDATE_TX_DATA', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', - ] - - store.dispatch(actions.updateTxDataAndCalculate(txData)) - - const storeActions = store.getActions() - assert.equal(storeActions.length, expectedActions.length) - storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) - }) - - it('updates confirmTransaction transaction', done => { - const mockState = { - metamask: { - conversionRate: 468.58, - currentCurrency: 'usd', - network: '3', - unapprovedTxs: { - 2603411941761054: { - estimatedGas: '0x5208', - gasLimitSpecified: false, - gasPriceSpecified: false, - history: [], - id: 2603411941761054, - loadingDefaults: false, - metamaskNetworkId: '3', - origin: 'faucet.metamask.io', - simpleSend: true, - status: 'unapproved', - time: 1530838113716, - txParams: { - from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', - gas: '0x33450', - gasPrice: '0x2540be400', - to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', - value: '0xde0b6b3a7640000', - }, - }, - }, - }, - confirmTransaction: {}, - } - - const middlewares = [thunk] - const mockStore = configureMockStore(middlewares) - const store = mockStore(mockState) - const expectedActions = [ - 'metamask/confirm-transaction/UPDATE_TX_DATA', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', - 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', - ] - - store.dispatch(actions.setTransactionToConfirm(2603411941761054)) - .then(() => { - const storeActions = store.getActions() - assert.equal(storeActions.length, expectedActions.length) - - storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) - done() - }) - }) - }) -}) diff --git a/ui/app/ducks/tests/gas-duck.test.js b/ui/app/ducks/tests/gas-duck.test.js deleted file mode 100644 index cd963aed4..000000000 --- a/ui/app/ducks/tests/gas-duck.test.js +++ /dev/null @@ -1,600 +0,0 @@ -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 } - ) })) - 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 } - ) })) - 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, - }], - } - ) })) - 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/tests/send-duck.test.js b/ui/app/ducks/tests/send-duck.test.js deleted file mode 100644 index 43f51c631..000000000 --- a/ui/app/ducks/tests/send-duck.test.js +++ /dev/null @@ -1,186 +0,0 @@ -import assert from 'assert' - -import SendReducer, { - openToDropdown, - closeToDropdown, - updateSendErrors, - showGasButtonGroup, - hideGasButtonGroup, - updateSendWarnings, -} from '../send.duck.js' - -describe('Send Duck', () => { - const mockState = { - send: { - mockProp: 123, - }, - } - const initState = { - 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', () => { - assert.deepEqual( - SendReducer({}), - initState - ) - }) - - it('should return state unchanged if it does not match a dispatched actions type', () => { - assert.deepEqual( - SendReducer(mockState, { - type: 'someOtherAction', - value: 'someValue', - }), - Object.assign({}, mockState.send) - ) - }) - - it('should set fromDropdownOpen to true when receiving a OPEN_FROM_DROPDOWN action', () => { - assert.deepEqual( - SendReducer(mockState, { - type: OPEN_FROM_DROPDOWN, - }), - Object.assign({fromDropdownOpen: true}, mockState.send) - ) - }) - - it('should return a new object (and not just modify the existing state object)', () => { - assert.deepEqual(SendReducer(mockState), mockState.send) - assert.notEqual(SendReducer(mockState), mockState.send) - }) - - it('should set fromDropdownOpen to false when receiving a CLOSE_FROM_DROPDOWN action', () => { - assert.deepEqual( - SendReducer(mockState, { - type: CLOSE_FROM_DROPDOWN, - }), - Object.assign({fromDropdownOpen: false}, mockState.send) - ) - }) - - it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => { - assert.deepEqual( - SendReducer(mockState, { - type: OPEN_TO_DROPDOWN, - }), - Object.assign({toDropdownOpen: true}, mockState.send) - ) - }) - - it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => { - assert.deepEqual( - SendReducer(mockState, { - type: CLOSE_TO_DROPDOWN, - }), - Object.assign({toDropdownOpen: false}, mockState.send) - ) - }) - - 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: { - errors: { - someError: false, - }, - }, - }) - assert.deepEqual( - SendReducer(modifiedMockState, { - type: UPDATE_SEND_ERRORS, - value: { someOtherError: true }, - }), - Object.assign({}, modifiedMockState.send, { - errors: { - someError: false, - someOtherError: true, - }, - }) - ) - }) - - it('should return the initial state in response to a RESET_SEND_STATE action', () => { - assert.deepEqual( - SendReducer(mockState, { - type: RESET_SEND_STATE, - }), - Object.assign({}, initState) - ) - }) - }) - - describe('openToDropdown', () => { - assert.deepEqual( - openToDropdown(), - { type: OPEN_TO_DROPDOWN } - ) - }) - - describe('closeToDropdown', () => { - assert.deepEqual( - closeToDropdown(), - { type: CLOSE_TO_DROPDOWN } - ) - }) - - describe('showGasButtonGroup', () => { - assert.deepEqual( - showGasButtonGroup(), - { type: SHOW_GAS_BUTTON_GROUP } - ) - }) - - describe('hideGasButtonGroup', () => { - assert.deepEqual( - hideGasButtonGroup(), - { type: HIDE_GAS_BUTTON_GROUP } - ) - }) - - describe('updateSendErrors', () => { - assert.deepEqual( - updateSendErrors('mockErrorObject'), - { type: UPDATE_SEND_ERRORS, value: 'mockErrorObject' } - ) - }) - - describe('updateSendWarnings', () => { - assert.deepEqual( - updateSendWarnings('mockWarningObject'), - { type: UPDATE_SEND_WARNINGS, value: 'mockWarningObject' } - ) - }) - -}) diff --git a/ui/app/helpers/confirm-transaction/util.js b/ui/app/helpers/confirm-transaction/util.js deleted file mode 100644 index 0451824e8..000000000 --- a/ui/app/helpers/confirm-transaction/util.js +++ /dev/null @@ -1,131 +0,0 @@ -import currencyFormatter from 'currency-formatter' -import currencies from 'currency-formatter/currencies' -import ethUtil from 'ethereumjs-util' -import BigNumber from 'bignumber.js' - -import { - conversionUtil, - addCurrencies, - multiplyCurrencies, - conversionGreaterThan, -} from '../../conversion-util' - -import { unconfirmedTransactionsCountSelector } from '../../selectors/confirm-transaction' - -export function increaseLastGasPrice (lastGasPrice) { - return ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { - multiplicandBase: 16, - multiplierBase: 10, - toNumericBase: 'hex', - })) -} - -export function hexGreaterThan (a, b) { - return conversionGreaterThan( - { value: a, fromNumericBase: 'hex' }, - { value: b, fromNumericBase: 'hex' }, - ) -} - -export function getHexGasTotal ({ gasLimit, gasPrice }) { - return ethUtil.addHexPrefix(multiplyCurrencies(gasLimit, gasPrice, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, - })) -} - -export function addEth (...args) { - return args.reduce((acc, base) => { - return addCurrencies(acc, base, { - toNumericBase: 'dec', - numberOfDecimals: 6, - }) - }) -} - -export function addFiat (...args) { - return args.reduce((acc, base) => { - return addCurrencies(acc, base, { - toNumericBase: 'dec', - numberOfDecimals: 2, - }) - }) -} - -export function getValueFromWeiHex ({ - value, - fromCurrency = 'ETH', - toCurrency, - conversionRate, - numberOfDecimals, - toDenomination, -}) { - return conversionUtil(value, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency, - toCurrency, - numberOfDecimals, - fromDenomination: 'WEI', - toDenomination, - conversionRate, - }) -} - -export function getTransactionFee ({ - value, - fromCurrency = 'ETH', - toCurrency, - conversionRate, - numberOfDecimals, -}) { - return conversionUtil(value, { - fromNumericBase: 'BN', - toNumericBase: 'dec', - fromDenomination: 'WEI', - fromCurrency, - toCurrency, - numberOfDecimals, - conversionRate, - }) -} - -export function formatCurrency (value, currencyCode) { - const upperCaseCurrencyCode = currencyCode.toUpperCase() - - return currencies.find(currency => currency.code === upperCaseCurrencyCode) - ? currencyFormatter.format(Number(value), { code: upperCaseCurrencyCode, style: 'currency' }) - : value -} - -export function convertTokenToFiat ({ - value, - fromCurrency = 'ETH', - toCurrency, - conversionRate, - contractExchangeRate, -}) { - const totalExchangeRate = conversionRate * contractExchangeRate - - return conversionUtil(value, { - fromNumericBase: 'dec', - toNumericBase: 'dec', - fromCurrency, - toCurrency, - numberOfDecimals: 2, - conversionRate: totalExchangeRate, - }) -} - -export function hasUnconfirmedTransactions (state) { - return unconfirmedTransactionsCountSelector(state) > 0 -} - -export function roundExponential (value) { - const PRECISION = 4 - const bigNumberValue = new BigNumber(String(value)) - - // In JS, numbers with exponentials greater than 20 get displayed as an exponential. - return bigNumberValue.e > 20 ? Number(bigNumberValue.toPrecision(PRECISION)) : value -} diff --git a/ui/app/helpers/confirm-transaction/util.test.js b/ui/app/helpers/confirm-transaction/util.test.js deleted file mode 100644 index 4c1a3e16b..000000000 --- a/ui/app/helpers/confirm-transaction/util.test.js +++ /dev/null @@ -1,137 +0,0 @@ -import * as utils from './util' -import assert from 'assert' - -describe('Confirm Transaction utils', () => { - describe('increaseLastGasPrice', () => { - it('should increase the gasPrice by 10%', () => { - const increasedGasPrice = utils.increaseLastGasPrice('0xa') - assert.equal(increasedGasPrice, '0xb') - }) - - it('should prefix the result with 0x', () => { - const increasedGasPrice = utils.increaseLastGasPrice('a') - assert.equal(increasedGasPrice, '0xb') - }) - }) - - describe('hexGreaterThan', () => { - it('should return true if the first value is greater than the second value', () => { - assert.equal( - utils.hexGreaterThan('0xb', '0xa'), - true - ) - }) - - it('should return false if the first value is less than the second value', () => { - assert.equal( - utils.hexGreaterThan('0xa', '0xb'), - false - ) - }) - - it('should return false if the first value is equal to the second value', () => { - assert.equal( - utils.hexGreaterThan('0xa', '0xa'), - false - ) - }) - - it('should correctly compare prefixed and non-prefixed hex values', () => { - assert.equal( - utils.hexGreaterThan('0xb', 'a'), - true - ) - }) - }) - - describe('getHexGasTotal', () => { - it('should multiply the hex gasLimit and hex gasPrice values together', () => { - assert.equal( - utils.getHexGasTotal({ gasLimit: '0x5208', gasPrice: '0x3b9aca00' }), - '0x1319718a5000' - ) - }) - - it('should prefix the result with 0x', () => { - assert.equal( - utils.getHexGasTotal({ gasLimit: '5208', gasPrice: '3b9aca00' }), - '0x1319718a5000' - ) - }) - }) - - describe('addEth', () => { - it('should add two values together rounding to 6 decimal places', () => { - assert.equal( - utils.addEth('0.12345678', '0'), - '0.123457' - ) - }) - - it('should add any number of values together rounding to 6 decimal places', () => { - assert.equal( - utils.addEth('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), - '0.123457' - ) - }) - }) - - describe('addFiat', () => { - it('should add two values together rounding to 2 decimal places', () => { - assert.equal( - utils.addFiat('0.12345678', '0'), - '0.12' - ) - }) - - it('should add any number of values together rounding to 2 decimal places', () => { - assert.equal( - utils.addFiat('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), - '0.12' - ) - }) - }) - - describe('getValueFromWeiHex', () => { - it('should get the transaction amount in ETH', () => { - const ethTransactionAmount = utils.getValueFromWeiHex({ - value: '0xde0b6b3a7640000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, - }) - - assert.equal(ethTransactionAmount, '1') - }) - - it('should get the transaction amount in fiat', () => { - const fiatTransactionAmount = utils.getValueFromWeiHex({ - value: '0xde0b6b3a7640000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, - }) - - assert.equal(fiatTransactionAmount, '468.58') - }) - }) - - describe('getTransactionFee', () => { - it('should get the transaction fee in ETH', () => { - const ethTransactionFee = utils.getTransactionFee({ - value: '0x1319718a5000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, - }) - - assert.equal(ethTransactionFee, '0.000021') - }) - - it('should get the transaction fee in fiat', () => { - const fiatTransactionFee = utils.getTransactionFee({ - value: '0x1319718a5000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, - }) - - assert.equal(fiatTransactionFee, '0.01') - }) - }) - - describe('formatCurrency', () => { - it('should format USD values', () => { - const value = utils.formatCurrency('123.45', 'usd') - assert.equal(value, '$123.45') - }) - }) -}) diff --git a/ui/app/constants/common.js b/ui/app/helpers/constants/common.js index c6e566b8b..c6e566b8b 100644 --- a/ui/app/constants/common.js +++ b/ui/app/helpers/constants/common.js 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 932dfa7df..932dfa7df 100644 --- a/ui/app/routes.js +++ b/ui/app/helpers/constants/routes.js diff --git a/ui/app/constants/transactions.js b/ui/app/helpers/constants/transactions.js index d0a819b9b..d0a819b9b 100644 --- a/ui/app/constants/transactions.js +++ b/ui/app/helpers/constants/transactions.js diff --git a/ui/app/helpers/conversions.util.js b/ui/app/helpers/conversions.util.js deleted file mode 100644 index 065d67e8e..000000000 --- a/ui/app/helpers/conversions.util.js +++ /dev/null @@ -1,122 +0,0 @@ -import ethUtil from 'ethereumjs-util' -import { ETH, GWEI, WEI } from '../constants/common' -import { conversionUtil, addCurrencies } from '../conversion-util' - -export function bnToHex (inputBn) { - return ethUtil.addHexPrefix(inputBn.toString(16)) -} - -export function hexToDecimal (hexValue) { - return conversionUtil(hexValue, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - }) -} - -export function decimalToHex (decimal) { - return conversionUtil(decimal, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }) -} - -export function getEthConversionFromWeiHex ({ value, fromCurrency = ETH, conversionRate, numberOfDecimals = 6 }) { - const denominations = [fromCurrency, GWEI, WEI] - - let nonZeroDenomination - - for (let i = 0; i < denominations.length; i++) { - const convertedValue = getValueFromWeiHex({ - value, - conversionRate, - fromCurrency, - toCurrency: fromCurrency, - numberOfDecimals, - toDenomination: denominations[i], - }) - - if (convertedValue !== '0' || i === denominations.length - 1) { - nonZeroDenomination = `${convertedValue} ${denominations[i]}` - break - } - } - - return nonZeroDenomination -} - -export function getValueFromWeiHex ({ - value, - fromCurrency = ETH, - toCurrency, - conversionRate, - numberOfDecimals, - toDenomination, -}) { - return conversionUtil(value, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency, - toCurrency, - numberOfDecimals, - fromDenomination: WEI, - toDenomination, - conversionRate, - }) -} - -export function getWeiHexFromDecimalValue ({ - value, - fromCurrency, - conversionRate, - fromDenomination, - invertConversionRate, -}) { - return conversionUtil(value, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - toCurrency: ETH, - fromCurrency, - conversionRate, - invertConversionRate, - fromDenomination, - 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/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/higher-order-components/authenticated/authenticated.container.js b/ui/app/helpers/higher-order-components/authenticated/authenticated.container.js index 6124b0fcd..6124b0fcd 100644 --- a/ui/app/higher-order-components/authenticated/authenticated.container.js +++ b/ui/app/helpers/higher-order-components/authenticated/authenticated.container.js diff --git a/ui/app/higher-order-components/authenticated/index.js b/ui/app/helpers/higher-order-components/authenticated/index.js index 05632ed21..05632ed21 100644 --- a/ui/app/higher-order-components/authenticated/index.js +++ b/ui/app/helpers/higher-order-components/authenticated/index.js diff --git a/ui/app/helpers/higher-order-components/i18n-provider.js b/ui/app/helpers/higher-order-components/i18n-provider.js new file mode 100644 index 000000000..0e34e17e0 --- /dev/null +++ b/ui/app/helpers/higher-order-components/i18n-provider.js @@ -0,0 +1,55 @@ +const { Component } = require('react') +const connect = require('react-redux').connect +const PropTypes = require('prop-types') +const { withRouter } = require('react-router-dom') +const { compose } = require('recompose') +const t = require('../utils/i18n-helper').getMessage + +class I18nProvider extends Component { + tOrDefault = (key, defaultValue, ...args) => { + const { localeMessages: { current, en } = {} } = this.props + return t(current, key, ...args) || t(en, key, ...args) || defaultValue + } + + getChildContext () { + const { localeMessages } = this.props + const { current, en } = localeMessages + return { + t (key, ...args) { + return t(current, key, ...args) || t(en, key, ...args) || `[${key}]` + }, + tOrDefault: this.tOrDefault, + tOrKey (key, ...args) { + return this.tOrDefault(key, key, ...args) + }, + } + } + + render () { + return this.props.children + } +} + +I18nProvider.propTypes = { + localeMessages: PropTypes.object, + children: PropTypes.object, +} + +I18nProvider.childContextTypes = { + t: PropTypes.func, + tOrDefault: PropTypes.func, + tOrKey: PropTypes.func, +} + +const mapStateToProps = state => { + const { localeMessages } = state + return { + localeMessages, + } +} + +module.exports = compose( + withRouter, + connect(mapStateToProps) +)(I18nProvider) + diff --git a/ui/app/higher-order-components/initialized/index.js b/ui/app/helpers/higher-order-components/initialized/index.js index 863fcb389..863fcb389 100644 --- a/ui/app/higher-order-components/initialized/index.js +++ b/ui/app/helpers/higher-order-components/initialized/index.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/higher-order-components/initialized/initialized.container.js b/ui/app/helpers/higher-order-components/initialized/initialized.container.js index 0e7f72bcb..0e7f72bcb 100644 --- a/ui/app/higher-order-components/initialized/initialized.container.js +++ b/ui/app/helpers/higher-order-components/initialized/initialized.container.js 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/helpers/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 new file mode 100644 index 000000000..efa9ad0a2 --- /dev/null +++ b/ui/app/helpers/higher-order-components/with-method-data/with-method-data.component.js @@ -0,0 +1,65 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +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 = { + methodData: {}, + done: false, + error: null, + } + + componentDidMount () { + this.fetchMethodData() + } + + async fetchMethodData () { + const { transaction, knownMethodData, addKnownMethodData } = this.props + const { txParams: { data = '' } = {} } = transaction + + if (data) { + try { + 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 }) + } + } else { + this.setState({ done: true }) + } + } + + render () { + const { methodData, done, error } = this.state + + return ( + <WrappedComponent + { ...this.props } + methodData={{ data: methodData, done, 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/helpers/higher-order-components/with-modal-props/with-modal-props.js b/ui/app/helpers/higher-order-components/with-modal-props/with-modal-props.js new file mode 100644 index 000000000..aac6b5a61 --- /dev/null +++ b/ui/app/helpers/higher-order-components/with-modal-props/with-modal-props.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux' +import { hideModal } from '../../../store/actions' + +const mapStateToProps = state => { + const { appState } = state + const { props: modalProps } = appState.modal.modalState + + return { + ...modalProps, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + } +} + +export default function withModalProps (Component) { + return connect(mapStateToProps, mapDispatchToProps)(Component) +} 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/tests/common.util.test.js b/ui/app/helpers/tests/common.util.test.js deleted file mode 100644 index a52b91a10..000000000 --- a/ui/app/helpers/tests/common.util.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import * as utils from '../common.util' -import assert from 'assert' - -describe('Common utils', () => { - describe('camelCaseToCapitalize', () => { - it('should return a capitalized string from a camel-cased string', () => { - const tests = [ - { - test: undefined, - expected: '', - }, - { - test: '', - expected: '', - }, - { - test: 'thisIsATest', - expected: 'This Is A Test', - }, - ] - - tests.forEach(({ test, expected }) => { - assert.equal(utils.camelCaseToCapitalize(test), expected) - }) - }) - }) -}) diff --git a/ui/app/helpers/tests/transactions.util.test.js b/ui/app/helpers/tests/transactions.util.test.js deleted file mode 100644 index 838522e35..000000000 --- a/ui/app/helpers/tests/transactions.util.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import * as utils from '../transactions.util' -import assert from 'assert' - -describe('Transactions utils', () => { - describe('getTokenData', () => { - it('should return token data', () => { - const tokenData = utils.getTokenData('0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20') - assert.ok(tokenData) - const { name, params } = tokenData - assert.equal(name, 'transfer') - const [to, value] = params - assert.equal(to.name, '_to') - assert.equal(to.type, 'address') - assert.equal(value.name, '_value') - assert.equal(value.type, 'uint256') - }) - - it('should not throw errors when called without arguments', () => { - assert.doesNotThrow(() => utils.getTokenData()) - }) - }) - - describe('getStatusKey', () => { - it('should return the correct status', () => { - const tests = [ - { - transaction: { - status: 'confirmed', - txReceipt: { - status: '0x0', - }, - }, - expected: 'failed', - }, - { - transaction: { - status: 'confirmed', - txReceipt: { - status: '0x1', - }, - }, - expected: 'confirmed', - }, - { - transaction: { - status: 'pending', - }, - expected: 'pending', - }, - ] - - tests.forEach(({ transaction, expected }) => { - assert.equal(utils.getStatusKey(transaction), expected) - }) - }) - }) -}) diff --git a/ui/app/helpers/transactions.util.js b/ui/app/helpers/transactions.util.js deleted file mode 100644 index d5b7f4958..000000000 --- a/ui/app/helpers/transactions.util.js +++ /dev/null @@ -1,179 +0,0 @@ -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, - TOKEN_METHOD_APPROVE, - TOKEN_METHOD_TRANSFER_FROM, - SEND_ETHER_ACTION_KEY, - DEPLOY_CONTRACT_ACTION_KEY, - APPROVE_ACTION_KEY, - SEND_TOKEN_ACTION_KEY, - TRANSFER_FROM_ACTION_KEY, - SIGNATURE_REQUEST_KEY, - CONTRACT_INTERACTION_KEY, - CANCEL_ATTEMPT_ACTION_KEY, -} from '../constants/transactions' - -import { addCurrencies } from '../conversion-util' - -abiDecoder.addABI(abi) - -export function getTokenData (data = '') { - return abiDecoder.decodeMethod(data) -} - -const registry = new MethodRegistry({ provider: global.ethereumProvider }) - -/** - * Attempts to return the method data from the MethodRegistry library, if the method exists in the - * registry. Otherwise, returns an empty object. - * @param {string} data - The hex data (@code txParams.data) of a transaction - * @returns {Object} - */ -export async function getMethodData (data = '') { - const prefixedData = ethUtil.addHexPrefix(data) - const fourBytePrefix = prefixedData.slice(0, 10) - const sig = await registry.lookup(fourBytePrefix) - - if (!sig) { - return {} - } - - const parsedResult = registry.parse(sig) - - return { - name: parsedResult.name, - params: parsedResult.args, - } -} - -export function isConfirmDeployContract (txData = {}) { - const { txParams = {} } = txData - return !txParams.to -} - -/** - * 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 - * @returns {string|undefined} - */ -export async function getTransactionActionKey (transaction, methodData) { - const { txParams: { data, to } = {}, msgParams, type } = transaction - - if (type === 'cancel') { - return CANCEL_ATTEMPT_ACTION_KEY - } - - if (msgParams) { - return SIGNATURE_REQUEST_KEY - } - - if (isConfirmDeployContract(transaction)) { - return DEPLOY_CONTRACT_ACTION_KEY - } - - if (data) { - const toSmartContract = await isSmartContractAddress(to) - - if (!toSmartContract) { - return SEND_ETHER_ACTION_KEY - } - - const { name } = methodData - const methodName = name && name.toLowerCase() - - if (!methodName) { - return CONTRACT_INTERACTION_KEY - } - - switch (methodName) { - case TOKEN_METHOD_TRANSFER: - return SEND_TOKEN_ACTION_KEY - case TOKEN_METHOD_APPROVE: - return APPROVE_ACTION_KEY - case TOKEN_METHOD_TRANSFER_FROM: - return TRANSFER_FROM_ACTION_KEY - default: - return undefined - } - } else { - return SEND_ETHER_ACTION_KEY - } -} - -export function getLatestSubmittedTxWithNonce (transactions = [], nonce = '0x0') { - if (!transactions.length) { - return {} - } - - return transactions.reduce((acc, current) => { - const { submittedTime, txParams: { nonce: currentNonce } = {} } = current - - if (currentNonce === nonce) { - return acc.submittedTime - ? submittedTime > acc.submittedTime ? current : acc - : current - } else { - return acc - } - }, {}) -} - -export async function isSmartContractAddress (address) { - const code = await global.eth.getCode(address) - // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' - const codeIsEmpty = !code || code === '0x' || code === '0x0' - return !codeIsEmpty -} - -export function sumHexes (...args) { - const total = args.reduce((acc, base) => { - return addCurrencies(acc, base, { - toNumericBase: 'hex', - }) - }) - - return ethUtil.addHexPrefix(total) -} - -/** - * Returns a status key for a transaction. Requires parsing the txMeta.txReceipt on top of - * txMeta.status because txMeta.status does not reflect on-chain errors. - * @param {Object} transaction - The txMeta object of a transaction. - * @param {Object} transaction.txReceipt - The transaction receipt. - * @returns {string} - */ -export function getStatusKey (transaction) { - const { txReceipt: { status: receiptStatus } = {}, type, status } = transaction - - // There was an on-chain failure - 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/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/utils/common.util.test.js b/ui/app/helpers/utils/common.util.test.js new file mode 100644 index 000000000..6259f4a89 --- /dev/null +++ b/ui/app/helpers/utils/common.util.test.js @@ -0,0 +1,27 @@ +import * as utils from './common.util' +import assert from 'assert' + +describe('Common utils', () => { + describe('camelCaseToCapitalize', () => { + it('should return a capitalized string from a camel-cased string', () => { + const tests = [ + { + test: undefined, + expected: '', + }, + { + test: '', + expected: '', + }, + { + test: 'thisIsATest', + expected: 'This Is A Test', + }, + ] + + tests.forEach(({ test, expected }) => { + assert.equal(utils.camelCaseToCapitalize(test), expected) + }) + }) + }) +}) diff --git a/ui/app/helpers/utils/confirm-tx.util.js b/ui/app/helpers/utils/confirm-tx.util.js new file mode 100644 index 000000000..f843db118 --- /dev/null +++ b/ui/app/helpers/utils/confirm-tx.util.js @@ -0,0 +1,131 @@ +import currencyFormatter from 'currency-formatter' +import currencies from 'currency-formatter/currencies' +import ethUtil from 'ethereumjs-util' +import BigNumber from 'bignumber.js' + +import { + conversionUtil, + addCurrencies, + multiplyCurrencies, + conversionGreaterThan, +} from './conversion-util' + +import { unconfirmedTransactionsCountSelector } from '../../selectors/confirm-transaction' + +export function increaseLastGasPrice (lastGasPrice) { + return ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { + multiplicandBase: 16, + multiplierBase: 10, + toNumericBase: 'hex', + })) +} + +export function hexGreaterThan (a, b) { + return conversionGreaterThan( + { value: a, fromNumericBase: 'hex' }, + { value: b, fromNumericBase: 'hex' }, + ) +} + +export function getHexGasTotal ({ gasLimit, gasPrice }) { + return ethUtil.addHexPrefix(multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + })) +} + +export function addEth (...args) { + return args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'dec', + numberOfDecimals: 6, + }) + }) +} + +export function addFiat (...args) { + return args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'dec', + numberOfDecimals: 2, + }) + }) +} + +export function getValueFromWeiHex ({ + value, + fromCurrency = 'ETH', + toCurrency, + conversionRate, + numberOfDecimals, + toDenomination, +}) { + return conversionUtil(value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency, + toCurrency, + numberOfDecimals, + fromDenomination: 'WEI', + toDenomination, + conversionRate, + }) +} + +export function getTransactionFee ({ + value, + fromCurrency = 'ETH', + toCurrency, + conversionRate, + numberOfDecimals, +}) { + return conversionUtil(value, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency, + toCurrency, + numberOfDecimals, + conversionRate, + }) +} + +export function formatCurrency (value, currencyCode) { + const upperCaseCurrencyCode = currencyCode.toUpperCase() + + return currencies.find(currency => currency.code === upperCaseCurrencyCode) + ? currencyFormatter.format(Number(value), { code: upperCaseCurrencyCode, style: 'currency' }) + : value +} + +export function convertTokenToFiat ({ + value, + fromCurrency = 'ETH', + toCurrency, + conversionRate, + contractExchangeRate, +}) { + const totalExchangeRate = conversionRate * contractExchangeRate + + return conversionUtil(value, { + fromNumericBase: 'dec', + toNumericBase: 'dec', + fromCurrency, + toCurrency, + numberOfDecimals: 2, + conversionRate: totalExchangeRate, + }) +} + +export function hasUnconfirmedTransactions (state) { + return unconfirmedTransactionsCountSelector(state) > 0 +} + +export function roundExponential (value) { + const PRECISION = 4 + const bigNumberValue = new BigNumber(String(value)) + + // In JS, numbers with exponentials greater than 20 get displayed as an exponential. + return bigNumberValue.e > 20 ? Number(bigNumberValue.toPrecision(PRECISION)) : value +} diff --git a/ui/app/helpers/utils/confirm-tx.util.test.js b/ui/app/helpers/utils/confirm-tx.util.test.js new file mode 100644 index 000000000..e818601ca --- /dev/null +++ b/ui/app/helpers/utils/confirm-tx.util.test.js @@ -0,0 +1,137 @@ +import * as utils from './confirm-tx.util' +import assert from 'assert' + +describe('Confirm Transaction utils', () => { + describe('increaseLastGasPrice', () => { + it('should increase the gasPrice by 10%', () => { + const increasedGasPrice = utils.increaseLastGasPrice('0xa') + assert.equal(increasedGasPrice, '0xb') + }) + + it('should prefix the result with 0x', () => { + const increasedGasPrice = utils.increaseLastGasPrice('a') + assert.equal(increasedGasPrice, '0xb') + }) + }) + + describe('hexGreaterThan', () => { + it('should return true if the first value is greater than the second value', () => { + assert.equal( + utils.hexGreaterThan('0xb', '0xa'), + true + ) + }) + + it('should return false if the first value is less than the second value', () => { + assert.equal( + utils.hexGreaterThan('0xa', '0xb'), + false + ) + }) + + it('should return false if the first value is equal to the second value', () => { + assert.equal( + utils.hexGreaterThan('0xa', '0xa'), + false + ) + }) + + it('should correctly compare prefixed and non-prefixed hex values', () => { + assert.equal( + utils.hexGreaterThan('0xb', 'a'), + true + ) + }) + }) + + describe('getHexGasTotal', () => { + it('should multiply the hex gasLimit and hex gasPrice values together', () => { + assert.equal( + utils.getHexGasTotal({ gasLimit: '0x5208', gasPrice: '0x3b9aca00' }), + '0x1319718a5000' + ) + }) + + it('should prefix the result with 0x', () => { + assert.equal( + utils.getHexGasTotal({ gasLimit: '5208', gasPrice: '3b9aca00' }), + '0x1319718a5000' + ) + }) + }) + + describe('addEth', () => { + it('should add two values together rounding to 6 decimal places', () => { + assert.equal( + utils.addEth('0.12345678', '0'), + '0.123457' + ) + }) + + it('should add any number of values together rounding to 6 decimal places', () => { + assert.equal( + utils.addEth('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), + '0.123457' + ) + }) + }) + + describe('addFiat', () => { + it('should add two values together rounding to 2 decimal places', () => { + assert.equal( + utils.addFiat('0.12345678', '0'), + '0.12' + ) + }) + + it('should add any number of values together rounding to 2 decimal places', () => { + assert.equal( + utils.addFiat('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), + '0.12' + ) + }) + }) + + describe('getValueFromWeiHex', () => { + it('should get the transaction amount in ETH', () => { + const ethTransactionAmount = utils.getValueFromWeiHex({ + value: '0xde0b6b3a7640000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, + }) + + assert.equal(ethTransactionAmount, '1') + }) + + it('should get the transaction amount in fiat', () => { + const fiatTransactionAmount = utils.getValueFromWeiHex({ + value: '0xde0b6b3a7640000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, + }) + + assert.equal(fiatTransactionAmount, '468.58') + }) + }) + + describe('getTransactionFee', () => { + it('should get the transaction fee in ETH', () => { + const ethTransactionFee = utils.getTransactionFee({ + value: '0x1319718a5000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, + }) + + assert.equal(ethTransactionFee, '0.000021') + }) + + it('should get the transaction fee in fiat', () => { + const fiatTransactionFee = utils.getTransactionFee({ + value: '0x1319718a5000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, + }) + + assert.equal(fiatTransactionFee, '0.01') + }) + }) + + describe('formatCurrency', () => { + it('should format USD values', () => { + const value = utils.formatCurrency('123.45', 'usd') + assert.equal(value, '$123.45') + }) + }) +}) diff --git a/ui/app/conversion-util.js b/ui/app/helpers/utils/conversion-util.js index 8cc531773..8cc531773 100644 --- a/ui/app/conversion-util.js +++ b/ui/app/helpers/utils/conversion-util.js 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/utils/conversions.util.js b/ui/app/helpers/utils/conversions.util.js new file mode 100644 index 000000000..b4ec50626 --- /dev/null +++ b/ui/app/helpers/utils/conversions.util.js @@ -0,0 +1,122 @@ +import ethUtil from 'ethereumjs-util' +import { ETH, GWEI, WEI } from '../constants/common' +import { conversionUtil, addCurrencies } from './conversion-util' + +export function bnToHex (inputBn) { + return ethUtil.addHexPrefix(inputBn.toString(16)) +} + +export function hexToDecimal (hexValue) { + return conversionUtil(hexValue, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) +} + +export function decimalToHex (decimal) { + return conversionUtil(decimal, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) +} + +export function getEthConversionFromWeiHex ({ value, fromCurrency = ETH, conversionRate, numberOfDecimals = 6 }) { + const denominations = [fromCurrency, GWEI, WEI] + + let nonZeroDenomination + + for (let i = 0; i < denominations.length; i++) { + const convertedValue = getValueFromWeiHex({ + value, + conversionRate, + fromCurrency, + toCurrency: fromCurrency, + numberOfDecimals, + toDenomination: denominations[i], + }) + + if (convertedValue !== '0' || i === denominations.length - 1) { + nonZeroDenomination = `${convertedValue} ${denominations[i]}` + break + } + } + + return nonZeroDenomination +} + +export function getValueFromWeiHex ({ + value, + fromCurrency = ETH, + toCurrency, + conversionRate, + numberOfDecimals, + toDenomination, +}) { + return conversionUtil(value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency, + toCurrency, + numberOfDecimals, + fromDenomination: WEI, + toDenomination, + conversionRate, + }) +} + +export function getWeiHexFromDecimalValue ({ + value, + fromCurrency, + conversionRate, + fromDenomination, + invertConversionRate, +}) { + return conversionUtil(value, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + toCurrency: ETH, + fromCurrency, + conversionRate, + invertConversionRate, + fromDenomination, + 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/formatters.js b/ui/app/helpers/utils/formatters.js index 106a2520d..106a2520d 100644 --- a/ui/app/helpers/formatters.js +++ b/ui/app/helpers/utils/formatters.js diff --git a/ui/i18n-helper.js b/ui/app/helpers/utils/i18n-helper.js index db07049e1..db07049e1 100644 --- a/ui/i18n-helper.js +++ b/ui/app/helpers/utils/i18n-helper.js diff --git a/ui/app/metametrics/metametrics.util.js b/ui/app/helpers/utils/metametrics.util.js index 01984bd5e..01984bd5e 100644 --- a/ui/app/metametrics/metametrics.util.js +++ b/ui/app/helpers/utils/metametrics.util.js diff --git a/ui/app/token-util.js b/ui/app/helpers/utils/token-util.js index 35a19a69f..35a19a69f 100644 --- a/ui/app/token-util.js +++ b/ui/app/helpers/utils/token-util.js diff --git a/ui/app/helpers/utils/transactions.util.js b/ui/app/helpers/utils/transactions.util.js new file mode 100644 index 000000000..edf2e24f6 --- /dev/null +++ b/ui/app/helpers/utils/transactions.util.js @@ -0,0 +1,179 @@ +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, + TOKEN_METHOD_APPROVE, + TOKEN_METHOD_TRANSFER_FROM, + SEND_ETHER_ACTION_KEY, + DEPLOY_CONTRACT_ACTION_KEY, + APPROVE_ACTION_KEY, + SEND_TOKEN_ACTION_KEY, + TRANSFER_FROM_ACTION_KEY, + SIGNATURE_REQUEST_KEY, + CONTRACT_INTERACTION_KEY, + CANCEL_ATTEMPT_ACTION_KEY, +} from '../constants/transactions' + +import { addCurrencies } from './conversion-util' + +abiDecoder.addABI(abi) + +export function getTokenData (data = '') { + return abiDecoder.decodeMethod(data) +} + +const registry = new MethodRegistry({ provider: global.ethereumProvider }) + +/** + * Attempts to return the method data from the MethodRegistry library, if the method exists in the + * registry. Otherwise, returns an empty object. + * @param {string} data - The hex data (@code txParams.data) of a transaction + * @returns {Object} + */ +export async function getMethodData (data = '') { + const prefixedData = ethUtil.addHexPrefix(data) + const fourBytePrefix = prefixedData.slice(0, 10) + const sig = await registry.lookup(fourBytePrefix) + + if (!sig) { + return {} + } + + const parsedResult = registry.parse(sig) + + return { + name: parsedResult.name, + params: parsedResult.args, + } +} + +export function isConfirmDeployContract (txData = {}) { + const { txParams = {} } = txData + return !txParams.to +} + +/** + * 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 + * @returns {string|undefined} + */ +export async function getTransactionActionKey (transaction, methodData) { + const { txParams: { data, to } = {}, msgParams, type } = transaction + + if (type === 'cancel') { + return CANCEL_ATTEMPT_ACTION_KEY + } + + if (msgParams) { + return SIGNATURE_REQUEST_KEY + } + + if (isConfirmDeployContract(transaction)) { + return DEPLOY_CONTRACT_ACTION_KEY + } + + if (data) { + const toSmartContract = await isSmartContractAddress(to) + + if (!toSmartContract) { + return SEND_ETHER_ACTION_KEY + } + + const { name } = methodData + const methodName = name && name.toLowerCase() + + if (!methodName) { + return CONTRACT_INTERACTION_KEY + } + + switch (methodName) { + case TOKEN_METHOD_TRANSFER: + return SEND_TOKEN_ACTION_KEY + case TOKEN_METHOD_APPROVE: + return APPROVE_ACTION_KEY + case TOKEN_METHOD_TRANSFER_FROM: + return TRANSFER_FROM_ACTION_KEY + default: + return undefined + } + } else { + return SEND_ETHER_ACTION_KEY + } +} + +export function getLatestSubmittedTxWithNonce (transactions = [], nonce = '0x0') { + if (!transactions.length) { + return {} + } + + return transactions.reduce((acc, current) => { + const { submittedTime, txParams: { nonce: currentNonce } = {} } = current + + if (currentNonce === nonce) { + return acc.submittedTime + ? submittedTime > acc.submittedTime ? current : acc + : current + } else { + return acc + } + }, {}) +} + +export async function isSmartContractAddress (address) { + const code = await global.eth.getCode(address) + // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' + const codeIsEmpty = !code || code === '0x' || code === '0x0' + return !codeIsEmpty +} + +export function sumHexes (...args) { + const total = args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'hex', + }) + }) + + return ethUtil.addHexPrefix(total) +} + +/** + * Returns a status key for a transaction. Requires parsing the txMeta.txReceipt on top of + * txMeta.status because txMeta.status does not reflect on-chain errors. + * @param {Object} transaction - The txMeta object of a transaction. + * @param {Object} transaction.txReceipt - The transaction receipt. + * @returns {string} + */ +export function getStatusKey (transaction) { + const { txReceipt: { status: receiptStatus } = {}, type, status } = transaction + + // There was an on-chain failure + 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/utils/transactions.util.test.js b/ui/app/helpers/utils/transactions.util.test.js new file mode 100644 index 000000000..4a8ca5c9d --- /dev/null +++ b/ui/app/helpers/utils/transactions.util.test.js @@ -0,0 +1,57 @@ +import * as utils from './transactions.util' +import assert from 'assert' + +describe('Transactions utils', () => { + describe('getTokenData', () => { + it('should return token data', () => { + const tokenData = utils.getTokenData('0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20') + assert.ok(tokenData) + const { name, params } = tokenData + assert.equal(name, 'transfer') + const [to, value] = params + assert.equal(to.name, '_to') + assert.equal(to.type, 'address') + assert.equal(value.name, '_value') + assert.equal(value.type, 'uint256') + }) + + it('should not throw errors when called without arguments', () => { + assert.doesNotThrow(() => utils.getTokenData()) + }) + }) + + describe('getStatusKey', () => { + it('should return the correct status', () => { + const tests = [ + { + transaction: { + status: 'confirmed', + txReceipt: { + status: '0x0', + }, + }, + expected: 'failed', + }, + { + transaction: { + status: 'confirmed', + txReceipt: { + status: '0x1', + }, + }, + expected: 'confirmed', + }, + { + transaction: { + status: 'pending', + }, + expected: 'pending', + }, + ] + + tests.forEach(({ transaction, expected }) => { + assert.equal(utils.getStatusKey(transaction), expected) + }) + }) + }) +}) diff --git a/ui/app/helpers/utils/util.js b/ui/app/helpers/utils/util.js new file mode 100644 index 000000000..c50d7cbe5 --- /dev/null +++ b/ui/app/helpers/utils/util.js @@ -0,0 +1,326 @@ +const abi = require('human-standard-token-abi') +const ethUtil = require('ethereumjs-util') +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, format = 'M/d/y \'at\' T') { + return DateTime.fromMillis(date).toFormat(format) +} + +var valueTable = { + wei: '1000000000000000000', + kwei: '1000000000000000', + mwei: '1000000000000', + gwei: '1000000000', + szabo: '1000000', + finney: '1000', + ether: '1', + kether: '0.001', + mether: '0.000001', + gether: '0.000000001', + tether: '0.000000000001', +} +var bnTable = {} +for (var currency in valueTable) { + bnTable[currency] = new ethUtil.BN(valueTable[currency], 10) +} + +module.exports = { + valuesFor: valuesFor, + addressSummary: addressSummary, + miniAddressSummary: miniAddressSummary, + isAllOneCase: isAllOneCase, + isValidAddress: isValidAddress, + isValidENSAddress, + numericBalance: numericBalance, + parseBalance: parseBalance, + formatBalance: formatBalance, + generateBalanceObject: generateBalanceObject, + dataSize: dataSize, + readableDate: readableDate, + normalizeToWei: normalizeToWei, + normalizeEthStringToWei: normalizeEthStringToWei, + normalizeNumberToWei: normalizeNumberToWei, + valueTable: valueTable, + bnTable: bnTable, + isHex: isHex, + formatDate, + bnMultiplyByFraction, + getTxFeeBn, + shortenBalance, + getContractAtAddress, + exportAsFile: exportAsFile, + isInvalidChecksumAddress, + allNull, + 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) { + if (!obj) return [] + return Object.keys(obj) + .map(function (key) { return obj[key] }) +} + +function addressSummary (address, firstSegLength = 10, lastSegLength = 4, includeHex = true) { + if (!address) return '' + let checked = checksumAddress(address) + if (!includeHex) { + checked = ethUtil.stripHexPrefix(checked) + } + return checked ? checked.slice(0, firstSegLength) + '...' + checked.slice(checked.length - lastSegLength) : '...' +} + +function miniAddressSummary (address) { + if (!address) return '' + var checked = checksumAddress(address) + return checked ? checked.slice(0, 4) + '...' + checked.slice(-4) : '...' +} + +function isValidAddress (address, network) { + var prefixed = ethUtil.addHexPrefix(address) + if (address === '0x0000000000000000000000000000000000000000') return false + return (isAllOneCase(prefixed) && ethUtil.isValidAddress(prefixed)) || ethUtil.isValidChecksumAddress(prefixed) +} + +function isValidENSAddress (address) { + return address.match(/^.{7,}\.(eth|test)$/) +} + +function isInvalidChecksumAddress (address) { + var prefixed = ethUtil.addHexPrefix(address) + if (address === '0x0000000000000000000000000000000000000000') return false + return !isAllOneCase(prefixed) && !ethUtil.isValidChecksumAddress(prefixed) && ethUtil.isValidAddress(prefixed) +} + +function isAllOneCase (address) { + if (!address) return true + var lower = address.toLowerCase() + var upper = address.toUpperCase() + return address === lower || address === upper +} + +// Takes wei Hex, returns wei BN, even if input is null +function numericBalance (balance) { + if (!balance) return new ethUtil.BN(0, 16) + var stripped = ethUtil.stripHexPrefix(balance) + return new ethUtil.BN(stripped, 16) +} + +// Takes hex, returns [beforeDecimal, afterDecimal] +function parseBalance (balance) { + var beforeDecimal, afterDecimal + const wei = numericBalance(balance) + var weiString = wei.toString() + const trailingZeros = /0+$/ + + beforeDecimal = weiString.length > 18 ? weiString.slice(0, weiString.length - 18) : '0' + afterDecimal = ('000000000000000000' + wei).slice(-18).replace(trailingZeros, '') + if (afterDecimal === '') { afterDecimal = '0' } + return [beforeDecimal, afterDecimal] +} + +// Takes wei hex, returns an object with three properties. +// Its "formatted" property is what we generally use to render values. +function formatBalance (balance, decimalsToKeep, needsParse = true, ticker = 'ETH') { + var parsed = needsParse ? parseBalance(balance) : balance.split('.') + var beforeDecimal = parsed[0] + var afterDecimal = parsed[1] + var formatted = 'None' + if (decimalsToKeep === undefined) { + if (beforeDecimal === '0') { + if (afterDecimal !== '0') { + var sigFigs = afterDecimal.match(/^0*(.{2})/) // default: grabs 2 most significant digits + if (sigFigs) { afterDecimal = sigFigs[0] } + formatted = '0.' + afterDecimal + ` ${ticker}` + } + } else { + formatted = beforeDecimal + '.' + afterDecimal.slice(0, 3) + ` ${ticker}` + } + } else { + afterDecimal += Array(decimalsToKeep).join('0') + formatted = beforeDecimal + '.' + afterDecimal.slice(0, decimalsToKeep) + ` ${ticker}` + } + return formatted +} + + +function generateBalanceObject (formattedBalance, decimalsToKeep = 1) { + var balance = formattedBalance.split(' ')[0] + var label = formattedBalance.split(' ')[1] + var beforeDecimal = balance.split('.')[0] + var afterDecimal = balance.split('.')[1] + var shortBalance = shortenBalance(balance, decimalsToKeep) + + if (beforeDecimal === '0' && afterDecimal.substr(0, 5) === '00000') { + // eslint-disable-next-line eqeqeq + if (afterDecimal == 0) { + balance = '0' + } else { + balance = '<1.0e-5' + } + } else if (beforeDecimal !== '0') { + balance = `${beforeDecimal}.${afterDecimal.slice(0, decimalsToKeep)}` + } + + return { balance, label, shortBalance } +} + +function shortenBalance (balance, decimalsToKeep = 1) { + var truncatedValue + var convertedBalance = parseFloat(balance) + if (convertedBalance > 1000000) { + truncatedValue = (balance / 1000000).toFixed(decimalsToKeep) + return `${truncatedValue}m` + } else if (convertedBalance > 1000) { + truncatedValue = (balance / 1000).toFixed(decimalsToKeep) + return `${truncatedValue}k` + } else if (convertedBalance === 0) { + return '0' + } else if (convertedBalance < 0.001) { + return '<0.001' + } else if (convertedBalance < 1) { + var stringBalance = convertedBalance.toString() + if (stringBalance.split('.')[1].length > 3) { + return convertedBalance.toFixed(3) + } else { + return stringBalance + } + } else { + return convertedBalance.toFixed(decimalsToKeep) + } +} + +function dataSize (data) { + var size = data ? ethUtil.stripHexPrefix(data).length : 0 + return size + ' bytes' +} + +// Takes a BN and an ethereum currency name, +// returns a BN in wei +function normalizeToWei (amount, currency) { + try { + return amount.mul(bnTable.wei).div(bnTable[currency]) + } catch (e) {} + return amount +} + +function normalizeEthStringToWei (str) { + const parts = str.split('.') + let eth = new ethUtil.BN(parts[0], 10).mul(bnTable.wei) + if (parts[1]) { + var decimal = parts[1] + while (decimal.length < 18) { + decimal += '0' + } + if (decimal.length > 18) { + decimal = decimal.slice(0, 18) + } + const decimalBN = new ethUtil.BN(decimal, 10) + eth = eth.add(decimalBN) + } + return eth +} + +var multiple = new ethUtil.BN('10000', 10) +function normalizeNumberToWei (n, currency) { + var enlarged = n * 10000 + var amount = new ethUtil.BN(String(enlarged), 10) + return normalizeToWei(amount, currency).div(multiple) +} + +function readableDate (ms) { + var date = new Date(ms) + var month = date.getMonth() + var day = date.getDate() + var year = date.getFullYear() + var hours = date.getHours() + var minutes = '0' + date.getMinutes() + var seconds = '0' + date.getSeconds() + + var dateStr = `${month}/${day}/${year}` + var time = `${hours}:${minutes.substr(-2)}:${seconds.substr(-2)}` + return `${dateStr} ${time}` +} + +function isHex (str) { + return Boolean(str.match(/^(0x)?[0-9a-fA-F]+$/)) +} + +function bnMultiplyByFraction (targetBN, numerator, denominator) { + const numBN = new ethUtil.BN(numerator) + const denomBN = new ethUtil.BN(denominator) + return targetBN.mul(numBN).div(denomBN) +} + +function getTxFeeBn (gas, gasPrice = MIN_GAS_PRICE_BN.toString(16), blockGasLimit) { + const gasBn = hexToBn(gas) + const gasPriceBn = hexToBn(gasPrice) + const txFeeBn = gasBn.mul(gasPriceBn) + + return txFeeBn.toString(16) +} + +function getContractAtAddress (tokenAddress) { + return global.eth.contract(abi).at(tokenAddress) +} + +function exportAsFile (filename, data, type = 'text/csv') { + // source: https://stackoverflow.com/a/33542499 by Ludovic Feltz + const blob = new Blob([data], {type}) + if (window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveBlob(blob, filename) + } else { + const elem = window.document.createElement('a') + elem.target = '_blank' + elem.href = window.URL.createObjectURL(blob) + elem.download = filename + document.body.appendChild(elem) + elem.click() + document.body.removeChild(elem) + } +} + +function allNull (obj) { + return Object.entries(obj).every(([key, value]) => value === null) +} + +function getTokenAddressFromTokenObject (token) { + return Object.values(token)[0].address.toLowerCase() +} + +/** + * Safely checksumms a potentially-null address + * + * @param {String} [address] - address to checksum + * @param {String} [network] - network id + * @returns {String} - checksummed address + * + */ +function checksumAddress (address, network) { + const checksummed = address ? ethUtil.toChecksumAddress(address) : '' + return checksummed +} + +function addressSlicer (address = '') { + if (address.length < 11) { + return address + } + + return `${address.slice(0, 6)}...${address.slice(-4)}` +} diff --git a/ui/app/higher-order-components/authenticated/authenticated.component.js b/ui/app/higher-order-components/authenticated/authenticated.component.js deleted file mode 100644 index 7b64d4895..000000000 --- a/ui/app/higher-order-components/authenticated/authenticated.component.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Redirect, Route } from 'react-router-dom' -import { UNLOCK_ROUTE, INITIALIZE_ROUTE } from '../../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/higher-order-components/initialized/initialized.component.js b/ui/app/higher-order-components/initialized/initialized.component.js deleted file mode 100644 index 0736ceff4..000000000 --- a/ui/app/higher-order-components/initialized/initialized.component.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Redirect, Route } from 'react-router-dom' -import { INITIALIZE_ROUTE } from '../../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/higher-order-components/with-method-data/with-method-data.component.js b/ui/app/higher-order-components/with-method-data/with-method-data.component.js deleted file mode 100644 index 08b9083e1..000000000 --- a/ui/app/higher-order-components/with-method-data/with-method-data.component.js +++ /dev/null @@ -1,65 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import { getMethodData, getFourBytePrefix } from '../../helpers/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 = { - methodData: {}, - done: false, - error: null, - } - - componentDidMount () { - this.fetchMethodData() - } - - async fetchMethodData () { - const { transaction, knownMethodData, addKnownMethodData } = this.props - const { txParams: { data = '' } = {} } = transaction - - if (data) { - try { - 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 }) - } - } else { - this.setState({ done: true }) - } - } - - render () { - const { methodData, done, error } = this.state - - return ( - <WrappedComponent - { ...this.props } - methodData={{ data: methodData, done, error }} - /> - ) - } - } -} diff --git a/ui/app/higher-order-components/with-modal-props/with-modal-props.js b/ui/app/higher-order-components/with-modal-props/with-modal-props.js deleted file mode 100644 index 02f3855af..000000000 --- a/ui/app/higher-order-components/with-modal-props/with-modal-props.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux' -import { hideModal } from '../../actions' - -const mapStateToProps = state => { - const { appState } = state - const { props: modalProps } = appState.modal.modalState - - return { - ...modalProps, - } -} - -const mapDispatchToProps = dispatch => { - return { - hideModal: () => dispatch(hideModal()), - } -} - -export default function withModalProps (Component) { - return connect(mapStateToProps, mapDispatchToProps)(Component) -} diff --git a/ui/app/i18n-provider.js b/ui/app/i18n-provider.js deleted file mode 100644 index 3419474c4..000000000 --- a/ui/app/i18n-provider.js +++ /dev/null @@ -1,55 +0,0 @@ -const { Component } = require('react') -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 - -class I18nProvider extends Component { - tOrDefault = (key, defaultValue, ...args) => { - const { localeMessages: { current, en } = {} } = this.props - return t(current, key, ...args) || t(en, key, ...args) || defaultValue - } - - getChildContext () { - const { localeMessages } = this.props - const { current, en } = localeMessages - return { - t (key, ...args) { - return t(current, key, ...args) || t(en, key, ...args) || `[${key}]` - }, - tOrDefault: this.tOrDefault, - tOrKey (key, ...args) { - return this.tOrDefault(key, key, ...args) - }, - } - } - - render () { - return this.props.children - } -} - -I18nProvider.propTypes = { - localeMessages: PropTypes.object, - children: PropTypes.object, -} - -I18nProvider.childContextTypes = { - t: PropTypes.func, - tOrDefault: PropTypes.func, - tOrKey: PropTypes.func, -} - -const mapStateToProps = state => { - const { localeMessages } = state - return { - localeMessages, - } -} - -module.exports = compose( - withRouter, - connect(mapStateToProps) -)(I18nProvider) - 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/metametrics/metametrics.provider.js b/ui/app/metametrics/metametrics.provider.js deleted file mode 100644 index 5ff0294e5..000000000 --- a/ui/app/metametrics/metametrics.provider.js +++ /dev/null @@ -1,106 +0,0 @@ -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' -import { - txDataSelector, -} from '../selectors/confirm-transaction' -import { getEnvironmentType } from '../../../app/scripts/lib/util' -import { - sendMetaMetricsEvent, - sendCountIsTrackable, -} from './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/pages/add-token/add-token.component.js b/ui/app/pages/add-token/add-token.component.js new file mode 100644 index 000000000..40c1ff7fd --- /dev/null +++ b/ui/app/pages/add-token/add-token.component.js @@ -0,0 +1,335 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ethUtil from 'ethereumjs-util' +import { checkExistingAddresses } from './util' +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 '../../components/ui/page-container' +import { Tabs, Tab } from '../../components/ui/tabs' + +const emptyAddr = '0x0000000000000000000000000000000000000000' +const SEARCH_TAB = 'SEARCH' +const CUSTOM_TOKEN_TAB = 'CUSTOM_TOKEN' + +class AddToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + setPendingTokens: PropTypes.func, + pendingTokens: PropTypes.object, + clearPendingTokens: PropTypes.func, + tokens: PropTypes.array, + identities: PropTypes.object, + } + + constructor (props) { + super(props) + + this.state = { + customAddress: '', + customSymbol: '', + customDecimals: 0, + searchResults: [], + selectedTokens: {}, + tokenSelectorError: null, + customAddressError: null, + customSymbolError: null, + customDecimalsError: null, + autoFilled: false, + displayedTab: SEARCH_TAB, + forceEditSymbol: false, + } + } + + componentDidMount () { + this.tokenInfoGetter = tokenInfoGetter() + const { pendingTokens = {} } = this.props + const pendingTokenKeys = Object.keys(pendingTokens) + + if (pendingTokenKeys.length > 0) { + let selectedTokens = {} + let customToken = {} + + pendingTokenKeys.forEach(tokenAddress => { + const token = pendingTokens[tokenAddress] + const { isCustom } = token + + if (isCustom) { + customToken = { ...token } + } else { + selectedTokens = { ...selectedTokens, [tokenAddress]: { ...token } } + } + }) + + const { + address: customAddress = '', + symbol: customSymbol = '', + decimals: customDecimals = 0, + } = customToken + + const displayedTab = Object.keys(selectedTokens).length > 0 ? SEARCH_TAB : CUSTOM_TOKEN_TAB + this.setState({ selectedTokens, customAddress, customSymbol, customDecimals, displayedTab }) + } + } + + handleToggleToken (token) { + const { address } = token + const { selectedTokens = {} } = this.state + const selectedTokensCopy = { ...selectedTokens } + + if (address in selectedTokensCopy) { + delete selectedTokensCopy[address] + } else { + selectedTokensCopy[address] = token + } + + this.setState({ + selectedTokens: selectedTokensCopy, + tokenSelectorError: null, + }) + } + + hasError () { + const { + tokenSelectorError, + customAddressError, + customSymbolError, + customDecimalsError, + } = this.state + + return tokenSelectorError || customAddressError || customSymbolError || customDecimalsError + } + + hasSelected () { + const { customAddress = '', selectedTokens = {} } = this.state + return customAddress || Object.keys(selectedTokens).length > 0 + } + + handleNext () { + if (this.hasError()) { + return + } + + if (!this.hasSelected()) { + this.setState({ tokenSelectorError: this.context.t('mustSelectOne') }) + return + } + + const { setPendingTokens, history } = this.props + const { + customAddress: address, + customSymbol: symbol, + customDecimals: decimals, + selectedTokens, + } = this.state + + const customToken = { + address, + symbol, + decimals, + } + + setPendingTokens({ customToken, selectedTokens }) + history.push(CONFIRM_ADD_TOKEN_ROUTE) + } + + async attemptToAutoFillTokenParams (address) { + const { symbol = '', decimals = 0 } = await this.tokenInfoGetter(address) + + const autoFilled = Boolean(symbol && decimals) + this.setState({ autoFilled }) + this.handleCustomSymbolChange(symbol || '') + this.handleCustomDecimalsChange(decimals) + } + + handleCustomAddressChange (value) { + const customAddress = value.trim() + this.setState({ + customAddress, + customAddressError: null, + tokenSelectorError: null, + autoFilled: false, + }) + + const isValidAddress = ethUtil.isValidAddress(customAddress) + const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() + + switch (true) { + case !isValidAddress: + this.setState({ + customAddressError: this.context.t('invalidAddress'), + customSymbol: '', + customDecimals: 0, + customSymbolError: null, + customDecimalsError: null, + }) + + break + case Boolean(this.props.identities[standardAddress]): + this.setState({ + customAddressError: this.context.t('personalAddressDetected'), + }) + + break + case checkExistingAddresses(customAddress, this.props.tokens): + this.setState({ + customAddressError: this.context.t('tokenAlreadyAdded'), + }) + + break + default: + if (customAddress !== emptyAddr) { + this.attemptToAutoFillTokenParams(customAddress) + } + } + } + + handleCustomSymbolChange (value) { + const customSymbol = value.trim() + const symbolLength = customSymbol.length + let customSymbolError = null + + if (symbolLength <= 0 || symbolLength >= 12) { + customSymbolError = this.context.t('symbolBetweenZeroTwelve') + } + + this.setState({ customSymbol, customSymbolError }) + } + + handleCustomDecimalsChange (value) { + const customDecimals = value.trim() + const validDecimals = customDecimals !== null && + customDecimals !== '' && + customDecimals >= 0 && + customDecimals <= 36 + let customDecimalsError = null + + if (!validDecimals) { + customDecimalsError = this.context.t('decimalsMustZerotoTen') + } + + this.setState({ customDecimals, customDecimalsError }) + } + + renderCustomTokenForm () { + const { + customAddress, + customSymbol, + customDecimals, + customAddressError, + customSymbolError, + customDecimalsError, + autoFilled, + forceEditSymbol, + } = this.state + + return ( + <div className="add-token__custom-token-form"> + <TextField + id="custom-address" + label={this.context.t('tokenContractAddress')} + type="text" + value={customAddress} + onChange={e => this.handleCustomAddressChange(e.target.value)} + error={customAddressError} + fullWidth + margin="normal" + /> + <TextField + id="custom-symbol" + 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 && !forceEditSymbol} + /> + <TextField + id="custom-decimals" + label={this.context.t('decimal')} + type="number" + value={customDecimals} + onChange={e => this.handleCustomDecimalsChange(e.target.value)} + error={customDecimalsError} + fullWidth + margin="normal" + disabled={autoFilled} + /> + </div> + ) + } + + renderSearchToken () { + const { tokenSelectorError, selectedTokens, searchResults } = this.state + + return ( + <div className="add-token__search-token"> + <TokenSearch + onSearch={({ results = [] }) => this.setState({ searchResults: results })} + error={tokenSelectorError} + /> + <div className="add-token__token-list"> + <TokenList + results={searchResults} + selectedTokens={selectedTokens} + onToggleToken={token => this.handleToggleToken(token)} + /> + </div> + </div> + ) + } + + renderTabs () { + return ( + <Tabs> + <Tab name={this.context.t('search')}> + { this.renderSearchToken() } + </Tab> + <Tab name={this.context.t('customToken')}> + { this.renderCustomTokenForm() } + </Tab> + </Tabs> + ) + } + + render () { + const { history, clearPendingTokens } = this.props + + return ( + <PageContainer + title={this.context.t('addTokens')} + tabsComponent={this.renderTabs()} + onSubmit={() => this.handleNext()} + disabled={this.hasError() || !this.hasSelected()} + onCancel={() => { + clearPendingTokens() + history.push(DEFAULT_ROUTE) + }} + /> + ) + } +} + +export default AddToken diff --git a/ui/app/pages/add-token/add-token.container.js b/ui/app/pages/add-token/add-token.container.js new file mode 100644 index 000000000..eee16dfc7 --- /dev/null +++ b/ui/app/pages/add-token/add-token.container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux' +import AddToken from './add-token.component' + +const { setPendingTokens, clearPendingTokens } = require('../../store/actions') + +const mapStateToProps = ({ metamask }) => { + const { identities, tokens, pendingTokens } = metamask + return { + identities, + tokens, + pendingTokens, + } +} + +const mapDispatchToProps = dispatch => { + return { + setPendingTokens: tokens => dispatch(setPendingTokens(tokens)), + clearPendingTokens: () => dispatch(clearPendingTokens()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(AddToken) 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/pages/add-token/index.scss b/ui/app/pages/add-token/index.scss new file mode 100644 index 000000000..ef6802f96 --- /dev/null +++ b/ui/app/pages/add-token/index.scss @@ -0,0 +1,45 @@ +@import 'token-list/index'; + +.add-token { + &__custom-token-form { + padding: 8px 16px 16px; + + input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + + input[type="number"]:hover::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + } + + &__search-token { + padding: 16px; + } + + &__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/pages/add-token/token-list/index.scss b/ui/app/pages/add-token/token-list/index.scss new file mode 100644 index 000000000..b7787a18e --- /dev/null +++ b/ui/app/pages/add-token/token-list/index.scss @@ -0,0 +1,65 @@ +@import 'token-list-placeholder/index'; + +.token-list { + &__title { + font-size: .75rem; + } + + &__tokens-container { + display: flex; + flex-direction: column; + } + + &__token { + transition: 200ms ease-in-out; + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 8px; + margin-top: 8px; + box-sizing: border-box; + border-radius: 10px; + cursor: pointer; + border: 2px solid transparent; + position: relative; + + &:hover { + border: 2px solid rgba($malibu-blue, .5); + } + + &--selected { + border: 2px solid $malibu-blue !important; + } + + &--disabled { + opacity: .4; + pointer-events: none; + } + } + + &__token-icon { + width: 48px; + height: 48px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + border-radius: 50%; + background-color: $white; + box-shadow: 0 2px 4px 0 rgba($black, .24); + margin-right: 12px; + flex: 0 0 auto; + } + + &__token-data { + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + } + + &__token-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} 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/pages/add-token/token-search/token-search.component.js b/ui/app/pages/add-token/token-search/token-search.component.js new file mode 100644 index 000000000..5542a19ff --- /dev/null +++ b/ui/app/pages/add-token/token-search/token-search.component.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react' +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 '../../../components/ui/text-field' + +const contractList = Object.entries(contractMap) + .map(([ _, tokenData]) => tokenData) + .filter(tokenData => Boolean(tokenData.erc20)) + +const fuse = new Fuse(contractList, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'symbol', weight: 0.5 }, + ], +}) + +export default class TokenSearch extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static defaultProps = { + error: null, + } + + static propTypes = { + onSearch: PropTypes.func, + error: PropTypes.string, + } + + constructor (props) { + super(props) + + this.state = { + searchQuery: '', + } + } + + handleSearch (searchQuery) { + this.setState({ searchQuery }) + const fuseSearchResult = fuse.search(searchQuery) + const addressSearchResult = contractList.filter(token => { + return token.address.toLowerCase() === searchQuery.toLowerCase() + }) + const results = [...addressSearchResult, ...fuseSearchResult] + this.props.onSearch({ searchQuery, results }) + } + + renderAdornment () { + return ( + <InputAdornment + position="start" + style={{ marginRight: '12px' }} + > + <img src="images/search.svg" /> + </InputAdornment> + ) + } + + render () { + const { error } = this.props + const { searchQuery } = this.state + + return ( + <TextField + id="search-tokens" + placeholder={this.context.t('searchTokens')} + type="text" + value={searchQuery} + onChange={e => this.handleSearch(e.target.value)} + error={error} + fullWidth + startAdornment={this.renderAdornment()} + /> + ) + } +} 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/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 new file mode 100644 index 000000000..7edb8f541 --- /dev/null +++ b/ui/app/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js @@ -0,0 +1,122 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +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 = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + clearPendingTokens: PropTypes.func, + addToken: PropTypes.func, + pendingTokens: PropTypes.object, + removeSuggestedTokens: PropTypes.func, + } + + componentDidMount () { + const { pendingTokens = {}, history } = this.props + + if (Object.keys(pendingTokens).length === 0) { + history.push(DEFAULT_ROUTE) + } + } + + getTokenName (name, symbol) { + return typeof name === 'undefined' + ? symbol + : `${name} (${symbol})` + } + + render () { + const { addToken, pendingTokens, removeSuggestedTokens, history } = this.props + const pendingTokenKey = Object.keys(pendingTokens)[0] + const pendingToken = pendingTokens[pendingTokenKey] + + return ( + <div className="page-container"> + <div className="page-container__header"> + <div className="page-container__title"> + { this.context.t('addSuggestedTokens') } + </div> + <div className="page-container__subtitle"> + { this.context.t('likeToAddTokens') } + </div> + </div> + <div className="page-container__content"> + <div className="confirm-add-token"> + <div className="confirm-add-token__header"> + <div className="confirm-add-token__token"> + { this.context.t('token') } + </div> + <div className="confirm-add-token__balance"> + { this.context.t('balance') } + </div> + </div> + <div className="confirm-add-token__token-list"> + { + Object.entries(pendingTokens) + .map(([ address, token ]) => { + const { name, symbol, image } = token + + return ( + <div + className="confirm-add-token__token-list-item" + key={address} + > + <div className="confirm-add-token__token confirm-add-token__data"> + <Identicon + className="confirm-add-token__token-icon" + diameter={48} + address={address} + image={image} + /> + <div className="confirm-add-token__name"> + { this.getTokenName(name, symbol) } + </div> + </div> + <div className="confirm-add-token__balance"> + <TokenBalance token={token} /> + </div> + </div> + ) + }) + } + </div> + </div> + </div> + <div className="page-container__footer"> + <header> + <Button + type="default" + large + className="page-container__footer-button" + onClick={() => { + removeSuggestedTokens() + .then(() => history.push(DEFAULT_ROUTE)) + }} + > + { this.context.t('cancel') } + </Button> + <Button + type="primary" + large + className="page-container__footer-button" + onClick={() => { + addToken(pendingToken) + .then(() => removeSuggestedTokens()) + .then(() => history.push(DEFAULT_ROUTE)) + }} + > + { this.context.t('addToken') } + </Button> + </header> + </div> + </div> + ) + } +} diff --git a/ui/app/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 new file mode 100644 index 000000000..a90fe148f --- /dev/null +++ b/ui/app/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import ConfirmAddSuggestedToken from './confirm-add-suggested-token.component' +import { withRouter } from 'react-router-dom' + +const extend = require('xtend') + +const { addToken, removeSuggestedTokens } = require('../../store/actions') + +const mapStateToProps = ({ metamask }) => { + const { pendingTokens, suggestedTokens } = metamask + const params = extend(pendingTokens, suggestedTokens) + + return { + pendingTokens: params, + } +} + +const mapDispatchToProps = dispatch => { + return { + addToken: ({address, symbol, decimals, image}) => dispatch(addToken(address, symbol, decimals, image)), + removeSuggestedTokens: () => dispatch(removeSuggestedTokens()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmAddSuggestedToken) 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/pages/confirm-add-token/confirm-add-token.component.js b/ui/app/pages/confirm-add-token/confirm-add-token.component.js new file mode 100644 index 000000000..c0ec624ac --- /dev/null +++ b/ui/app/pages/confirm-add-token/confirm-add-token.component.js @@ -0,0 +1,117 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +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 = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + clearPendingTokens: PropTypes.func, + addTokens: PropTypes.func, + pendingTokens: PropTypes.object, + } + + componentDidMount () { + const { pendingTokens = {}, history } = this.props + + if (Object.keys(pendingTokens).length === 0) { + history.push(DEFAULT_ROUTE) + } + } + + getTokenName (name, symbol) { + return typeof name === 'undefined' + ? symbol + : `${name} (${symbol})` + } + + render () { + const { history, addTokens, clearPendingTokens, pendingTokens } = this.props + + return ( + <div className="page-container"> + <div className="page-container__header"> + <div className="page-container__title"> + { this.context.t('addTokens') } + </div> + <div className="page-container__subtitle"> + { this.context.t('likeToAddTokens') } + </div> + </div> + <div className="page-container__content"> + <div className="confirm-add-token"> + <div className="confirm-add-token__header"> + <div className="confirm-add-token__token"> + { this.context.t('token') } + </div> + <div className="confirm-add-token__balance"> + { this.context.t('balance') } + </div> + </div> + <div className="confirm-add-token__token-list"> + { + Object.entries(pendingTokens) + .map(([ address, token ]) => { + const { name, symbol } = token + + return ( + <div + className="confirm-add-token__token-list-item" + key={address} + > + <div className="confirm-add-token__token confirm-add-token__data"> + <Identicon + className="confirm-add-token__token-icon" + diameter={48} + address={address} + /> + <div className="confirm-add-token__name"> + { this.getTokenName(name, symbol) } + </div> + </div> + <div className="confirm-add-token__balance"> + <TokenBalance token={token} /> + </div> + </div> + ) + }) + } + </div> + </div> + </div> + <div className="page-container__footer"> + <header> + <Button + type="default" + large + className="page-container__footer-button" + onClick={() => history.push(ADD_TOKEN_ROUTE)} + > + { this.context.t('back') } + </Button> + <Button + type="primary" + large + className="page-container__footer-button" + onClick={() => { + addTokens(pendingTokens) + .then(() => { + clearPendingTokens() + history.push(DEFAULT_ROUTE) + }) + }} + > + { this.context.t('addTokens') } + </Button> + </header> + </div> + </div> + ) + } +} diff --git a/ui/app/pages/confirm-add-token/confirm-add-token.container.js b/ui/app/pages/confirm-add-token/confirm-add-token.container.js new file mode 100644 index 000000000..961626177 --- /dev/null +++ b/ui/app/pages/confirm-add-token/confirm-add-token.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import ConfirmAddToken from './confirm-add-token.component' + +const { addTokens, clearPendingTokens } = require('../../store/actions') + +const mapStateToProps = ({ metamask }) => { + const { pendingTokens } = metamask + return { + pendingTokens, + } +} + +const mapDispatchToProps = dispatch => { + return { + addTokens: tokens => dispatch(addTokens(tokens)), + clearPendingTokens: () => dispatch(clearPendingTokens()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(ConfirmAddToken) 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/pages/confirm-approve/confirm-approve.container.js b/ui/app/pages/confirm-approve/confirm-approve.container.js new file mode 100644 index 000000000..5f8bb8f0b --- /dev/null +++ b/ui/app/pages/confirm-approve/confirm-approve.container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux' +import ConfirmApprove from './confirm-approve.component' +import { approveTokenAmountAndToAddressSelector } from '../../selectors/confirm-transaction' + +const mapStateToProps = state => { + const { confirmTransaction: { tokenProps: { tokenSymbol } = {} } } = state + const { tokenAmount } = approveTokenAmountAndToAddressSelector(state) + + return { + tokenAmount, + tokenSymbol, + } +} + +export default connect(mapStateToProps)(ConfirmApprove) 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/pages/confirm-send-ether/confirm-send-ether.component.js b/ui/app/pages/confirm-send-ether/confirm-send-ether.component.js new file mode 100644 index 000000000..8daad675e --- /dev/null +++ b/ui/app/pages/confirm-send-ether/confirm-send-ether.component.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' +import { SEND_ROUTE } from '../../helpers/constants/routes' + +export default class ConfirmSendEther extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + editTransaction: PropTypes.func, + history: PropTypes.object, + txParams: PropTypes.object, + } + + handleEdit ({ txData }) { + const { editTransaction, history } = this.props + editTransaction(txData) + history.push(SEND_ROUTE) + } + + shouldHideData () { + const { txParams = {} } = this.props + return !txParams.data + } + + render () { + const hideData = this.shouldHideData() + + return ( + <ConfirmTransactionBase + action={this.context.t('confirm')} + hideData={hideData} + onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} + /> + ) + } +} diff --git a/ui/app/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/app/pages/confirm-send-ether/confirm-send-ether.container.js new file mode 100644 index 000000000..713da702d --- /dev/null +++ b/ui/app/pages/confirm-send-ether/confirm-send-ether.container.js @@ -0,0 +1,45 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import { updateSend } from '../../store/actions' +import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck' +import ConfirmSendEther from './confirm-send-ether.component' + +const mapStateToProps = state => { + const { confirmTransaction: { txData: { txParams } = {} } } = state + + return { + txParams, + } +} + +const mapDispatchToProps = dispatch => { + return { + editTransaction: txData => { + const { id, txParams } = txData + const { + gas: gasLimit, + gasPrice, + to, + value: amount, + } = txParams + + dispatch(updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount, + errors: { to: null, amount: null }, + editingTransactionId: id && id.toString(), + })) + + dispatch(clearConfirmTransaction()) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmSendEther) 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/pages/confirm-send-token/confirm-send-token.component.js b/ui/app/pages/confirm-send-token/confirm-send-token.component.js new file mode 100644 index 000000000..7f3b1c082 --- /dev/null +++ b/ui/app/pages/confirm-send-token/confirm-send-token.component.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTokenTransactionBase from '../confirm-token-transaction-base' +import { SEND_ROUTE } from '../../helpers/constants/routes' + +export default class ConfirmSendToken extends Component { + static propTypes = { + history: PropTypes.object, + editTransaction: PropTypes.func, + tokenAmount: PropTypes.number, + } + + handleEdit (confirmTransactionData) { + const { editTransaction, history } = this.props + editTransaction(confirmTransactionData) + history.push(SEND_ROUTE) + } + + render () { + const { tokenAmount } = this.props + + return ( + <ConfirmTokenTransactionBase + onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} + tokenAmount={tokenAmount} + /> + ) + } +} diff --git a/ui/app/pages/confirm-send-token/confirm-send-token.container.js b/ui/app/pages/confirm-send-token/confirm-send-token.container.js new file mode 100644 index 000000000..db9b08c48 --- /dev/null +++ b/ui/app/pages/confirm-send-token/confirm-send-token.container.js @@ -0,0 +1,52 @@ +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/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) + + return { + tokenAmount, + } +} + +const mapDispatchToProps = dispatch => { + return { + editTransaction: ({ txData, tokenData, tokenProps }) => { + const { txParams: { to: tokenAddress, gas: gasLimit, gasPrice } = {}, id } = txData + const { params = [] } = tokenData + const { value: to } = params[0] || {} + const { value: tokenAmountInDec } = params[1] || {} + const tokenAmountInHex = conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) + dispatch(setSelectedToken(tokenAddress)) + dispatch(updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount: tokenAmountInHex, + errors: { to: null, amount: null }, + editingTransactionId: id && id.toString(), + token: { + ...tokenProps, + address: tokenAddress, + }, + })) + dispatch(clearConfirmTransaction()) + dispatch(showSendTokenPage()) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmSendToken) 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/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 new file mode 100644 index 000000000..dbda3c1dc --- /dev/null +++ b/ui/app/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js @@ -0,0 +1,119 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' +import UserPreferencedCurrencyDisplay from '../../components/app/user-preferenced-currency-display' +import { + formatCurrency, + convertTokenToFiat, + addFiat, + roundExponential, +} 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 = { + t: PropTypes.func, + } + + static propTypes = { + tokenAddress: PropTypes.string, + toAddress: PropTypes.string, + tokenAmount: PropTypes.number, + tokenSymbol: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + ethTransactionTotal: PropTypes.string, + contractExchangeRate: PropTypes.number, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + } + + getFiatTransactionAmount () { + const { tokenAmount, currentCurrency, conversionRate, contractExchangeRate } = this.props + + return convertTokenToFiat({ + value: tokenAmount, + toCurrency: currentCurrency, + conversionRate, + contractExchangeRate, + }) + } + + renderSubtitleComponent () { + const { contractExchangeRate, tokenAmount } = this.props + + const decimalEthValue = (tokenAmount * contractExchangeRate) || 0 + const hexWeiValue = getWeiHexFromDecimalValue({ + value: decimalEthValue, + fromCurrency: ETH, + fromDenomination: ETH, + }) + + return typeof contractExchangeRate === 'undefined' + ? ( + <span> + { this.context.t('noConversionRateAvailable') } + </span> + ) : ( + <UserPreferencedCurrencyDisplay + value={hexWeiValue} + type={PRIMARY} + showEthLogo + hideLabel + /> + ) + } + + renderPrimaryTotalTextOverride () { + const { tokenAmount, tokenSymbol, ethTransactionTotal } = this.props + const tokensText = `${tokenAmount} ${tokenSymbol}` + + return ( + <div> + <span>{ `${tokensText} + ` }</span> + <img + src="/images/eth.svg" + height="18" + /> + <span>{ ethTransactionTotal }</span> + </div> + ) + } + + getSecondaryTotalTextOverride () { + const { fiatTransactionTotal, currentCurrency, contractExchangeRate } = this.props + + if (typeof contractExchangeRate === 'undefined') { + return formatCurrency(fiatTransactionTotal, currentCurrency) + } else { + const fiatTransactionAmount = this.getFiatTransactionAmount() + const fiatTotal = addFiat(fiatTransactionAmount, fiatTransactionTotal) + const roundedFiatTotal = roundExponential(fiatTotal) + return formatCurrency(roundedFiatTotal, currentCurrency) + } + } + + render () { + const { + toAddress, + tokenAddress, + tokenSymbol, + tokenAmount, + ...restProps + } = this.props + + const tokensText = `${tokenAmount} ${tokenSymbol}` + + return ( + <ConfirmTransactionBase + toAddress={toAddress} + identiconAddress={tokenAddress} + title={tokensText} + subtitleComponent={this.renderSubtitleComponent()} + primaryTotalTextOverride={this.renderPrimaryTotalTextOverride()} + secondaryTotalTextOverride={this.getSecondaryTotalTextOverride()} + {...restProps} + /> + ) + } +} diff --git a/ui/app/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 new file mode 100644 index 000000000..f5f30a460 --- /dev/null +++ b/ui/app/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js @@ -0,0 +1,34 @@ +import { connect } from 'react-redux' +import ConfirmTokenTransactionBase from './confirm-token-transaction-base.component' +import { + tokenAmountAndToAddressSelector, + contractExchangeRateSelector, +} from '../../selectors/confirm-transaction' + +const mapStateToProps = (state, ownProps) => { + const { tokenAmount: ownTokenAmount } = ownProps + const { confirmTransaction, metamask: { currentCurrency, conversionRate } } = state + const { + txData: { txParams: { to: tokenAddress } = {} } = {}, + tokenProps: { tokenSymbol } = {}, + fiatTransactionTotal, + ethTransactionTotal, + } = confirmTransaction + + const { tokenAmount, toAddress } = tokenAmountAndToAddressSelector(state) + const contractExchangeRate = contractExchangeRateSelector(state) + + return { + toAddress, + tokenAddress, + tokenAmount: typeof ownTokenAmount !== 'undefined' ? ownTokenAmount : tokenAmount, + tokenSymbol, + currentCurrency, + conversionRate, + contractExchangeRate, + fiatTransactionTotal, + ethTransactionTotal, + } +} + +export default connect(mapStateToProps)(ConfirmTokenTransactionBase) 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/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js new file mode 100644 index 000000000..1da9c34bd --- /dev/null +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -0,0 +1,574 @@ +import ethUtil from 'ethereumjs-util' +import React, { Component } from 'react' +import PropTypes from 'prop-types' +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 '../../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 = { + // react-router props + match: PropTypes.object, + history: PropTypes.object, + // Redux props + balance: PropTypes.string, + cancelTransaction: PropTypes.func, + cancelAllTransactions: PropTypes.func, + clearConfirmTransaction: PropTypes.func, + clearSend: PropTypes.func, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + editTransaction: PropTypes.func, + ethTransactionAmount: PropTypes.string, + ethTransactionFee: PropTypes.string, + ethTransactionTotal: PropTypes.string, + fiatTransactionAmount: PropTypes.string, + fiatTransactionFee: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + fromAddress: PropTypes.string, + fromName: PropTypes.string, + hexTransactionAmount: PropTypes.string, + hexTransactionFee: PropTypes.string, + hexTransactionTotal: PropTypes.string, + isTxReprice: PropTypes.bool, + methodData: PropTypes.object, + nonce: PropTypes.string, + assetImage: PropTypes.string, + sendTransaction: PropTypes.func, + showCustomizeGasModal: PropTypes.func, + showTransactionConfirmedModal: PropTypes.func, + showRejectTransactionsConfirmationModal: PropTypes.func, + toAddress: PropTypes.string, + tokenData: PropTypes.object, + tokenProps: PropTypes.object, + toName: PropTypes.string, + 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, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + primaryTotalTextOverride: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + secondaryTotalTextOverride: PropTypes.string, + hideData: PropTypes.bool, + hideDetails: PropTypes.bool, + hideSubtitle: PropTypes.bool, + identiconAddress: PropTypes.string, + onCancel: PropTypes.func, + onEdit: PropTypes.func, + onEditGas: PropTypes.func, + onSubmit: PropTypes.func, + setMetaMetricsSendCount: PropTypes.func, + metaMetricsSendCount: PropTypes.number, + subtitle: PropTypes.string, + subtitleComponent: PropTypes.node, + summaryComponent: PropTypes.node, + title: PropTypes.string, + titleComponent: PropTypes.node, + valid: PropTypes.bool, + warning: PropTypes.string, + advancedInlineGasShown: PropTypes.bool, + insufficientBalance: PropTypes.bool, + hideFiatConversion: PropTypes.bool, + } + + state = { + submitting: false, + submitError: null, + } + + componentDidUpdate () { + const { + transactionStatus, + showTransactionConfirmedModal, + history, + clearConfirmTransaction, + } = this.props + + if (transactionStatus === DROPPED_STATUS || transactionStatus === CONFIRMED_STATUS) { + showTransactionConfirmedModal({ + onSubmit: () => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + + return + } + } + + getErrorKey () { + const { + balance, + conversionRate, + hexTransactionFee, + txData: { + simulationFails, + txParams: { + value: amount, + } = {}, + } = {}, + } = this.props + + const insufficientBalance = balance && !isBalanceSufficient({ + amount, + gasTotal: hexTransactionFee || '0x0', + balance, + conversionRate, + }) + + if (insufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + if (simulationFails) { + return { + valid: true, + errorKey: simulationFails.errorKey ? simulationFails.errorKey : TRANSACTION_ERROR_KEY, + } + } + + return { + valid: true, + } + } + + handleEditGas () { + 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() + } else { + showCustomizeGasModal() + } + } + + renderDetails () { + const { + detailsComponent, + primaryTotalTextOverride, + secondaryTotalTextOverride, + hexTransactionFee, + hexTransactionTotal, + hideDetails, + advancedInlineGasShown, + customGas, + insufficientBalance, + updateGasAndCalculate, + hideFiatConversion, + } = this.props + + if (hideDetails) { + return null + } + + return ( + detailsComponent || ( + <div className="confirm-page-container-content__details"> + <div className="confirm-page-container-content__gas-fee"> + <ConfirmDetailRow + label="Gas Fee" + value={hexTransactionFee} + 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={hideFiatConversion ? this.context.t('noConversionRateAvailable') : secondaryTotalTextOverride} + headerText="Amount + Gas Fee" + headerTextClassName="confirm-detail-row__header-text--total" + primaryValueTextColor="#2f9ae0" + /> + </div> + </div> + ) + ) + } + + renderData () { + const { t } = this.context + const { + txData: { + txParams: { + data, + } = {}, + } = {}, + methodData: { + name, + params, + } = {}, + hideData, + dataComponent, + } = this.props + + if (hideData) { + return null + } + + return dataComponent || ( + <div className="confirm-page-container-content__data"> + <div className="confirm-page-container-content__data-box-label"> + {`${t('functionType')}:`} + <span className="confirm-page-container-content__function-type"> + { name || t('notFound') } + </span> + </div> + { + params && ( + <div className="confirm-page-container-content__data-box"> + <div className="confirm-page-container-content__data-field-label"> + { `${t('parameters')}:` } + </div> + <div> + <pre>{ JSON.stringify(params, null, 2) }</pre> + </div> + </div> + ) + } + <div className="confirm-page-container-content__data-box-label"> + {`${t('hexData')}: ${ethUtil.toBuffer(data).length} bytes`} + </div> + <div className="confirm-page-container-content__data-box"> + { data } + </div> + </div> + ) + } + + handleEdit () { + 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 }) + } + + handleCancelAll () { + const { + cancelAllTransactions, + clearConfirmTransaction, + history, + showRejectTransactionsConfirmationModal, + unapprovedTxCount, + } = this.props + + showRejectTransactionsConfirmationModal({ + unapprovedTxCount, + async onSubmit () { + await cancelAllTransactions() + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + } + + handleCancel () { + 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) + .then(() => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }) + } + } + + handleSubmit () { + 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, + }, () => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Transaction Completed', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) + + setMetaMetricsSendCount(metaMetricsSendCount + 1) + .then(() => { + 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 () { + const { title, titleComponent, hexTransactionAmount } = this.props + + // Title string passed in by props takes priority + if (title) { + return null + } + + return titleComponent || ( + <UserPreferencedCurrencyDisplay + value={hexTransactionAmount} + type={PRIMARY} + showEthLogo + ethLogoHeight="26" + hideLabel + /> + ) + } + + renderSubtitleComponent () { + const { subtitle, subtitleComponent, hexTransactionAmount } = this.props + + // Subtitle string passed in by props takes priority + if (subtitle) { + return null + } + + return subtitleComponent || ( + <UserPreferencedCurrencyDisplay + value={hexTransactionAmount} + type={SECONDARY} + showEthLogo + hideLabel + /> + ) + } + + 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 } = {} } = this.props + const { metricsEvent } = this.context + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Confirm: Started', + }, + customVariables: { + origin, + }, + }) + } + + render () { + const { + isTxReprice, + fromName, + fromAddress, + toName, + toAddress, + methodData, + valid: propsValid = true, + errorMessage, + errorKey: propsErrorKey, + action, + title, + subtitle, + hideSubtitle, + identiconAddress, + summaryComponent, + contentComponent, + onEdit, + nonce, + assetImage, + warning, + unapprovedTxCount, + } = this.props + const { submitting, submitError } = this.state + + const { name } = methodData + const { valid, errorKey } = this.getErrorKey() + const { totalTx, positionOfCurrentTx, nextTxId, prevTxId, showNavigation, firstTx, lastTx, ofText, requestsWaitingText } = this.getNavigateTxData() + + return ( + <ConfirmPageContainer + fromName={fromName} + fromAddress={fromAddress} + toName={toName} + toAddress={toAddress} + showEdit={onEdit && !isTxReprice} + action={action || getMethodName(name) || this.context.t('contractInteraction')} + title={title} + titleComponent={this.renderTitleComponent()} + subtitle={subtitle} + subtitleComponent={this.renderSubtitleComponent()} + hideSubtitle={hideSubtitle} + summaryComponent={summaryComponent} + detailsComponent={this.renderDetails()} + dataComponent={this.renderData()} + contentComponent={contentComponent} + nonce={nonce} + unapprovedTxCount={unapprovedTxCount} + assetImage={assetImage} + identiconAddress={identiconAddress} + 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()} + onCancel={() => this.handleCancel()} + onSubmit={() => this.handleSubmit()} + /> + ) + } +} + +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/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js new file mode 100644 index 000000000..83543f1a4 --- /dev/null +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -0,0 +1,242 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import R from 'ramda' +import contractMap from 'eth-contract-metadata' +import ConfirmTransactionBase from './confirm-transaction-base.component' +import { + clearConfirmTransaction, + updateGasAndCalculate, +} 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 '../../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 { + ...acc, + [base.toLowerCase()]: contractMap[base], + } +}, {}) + +const mapStateToProps = (state, props) => { + const { toAddress: propsToAddress } = props + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const { confirmTransaction, metamask, gas } = state + const { + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexTransactionAmount, + hexTransactionFee, + hexTransactionTotal, + tokenData, + methodData, + txData, + tokenProps, + nonce, + } = confirmTransaction + const { txParams = {}, lastGasPrice, id: transactionId } = txData + const { + from: fromAddress, + to: txParamsToAddress, + gasPrice, + gas: gasLimit, + value: amount, + } = txParams + const accounts = getMetaMaskAccounts(state) + const { + conversionRate, + identities, + currentCurrency, + 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(checksumAddress(toAddress)) + ) + + const isTxReprice = Boolean(lastGasPrice) + + const transaction = R.find(({ id }) => id === transactionId)(selectedAddressTxList) + const transactionStatus = transaction ? transaction.status : '' + + const currentNetworkUnapprovedTxs = R.filter( + ({ metamaskNetworkId }) => metamaskNetworkId === network, + unapprovedTxs, + ) + const unapprovedTxCount = valuesFor(currentNetworkUnapprovedTxs).length + + const insufficientBalance = !isBalanceSufficient({ + amount, + gasTotal: calcGasTotal(gasLimit, gasPrice), + balance, + conversionRate, + }) + + return { + balance, + fromAddress, + fromName, + toAddress, + toName, + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexTransactionAmount, + hexTransactionFee, + hexTransactionTotal, + txData, + tokenData, + methodData, + tokenProps, + isTxReprice, + currentCurrency, + conversionRate, + transactionStatus, + nonce, + assetImage, + unapprovedTxs, + unapprovedTxCount, + currentNetworkUnapprovedTxs, + customGas: { + gasLimit: customGasLimit || gasLimit, + gasPrice: customGasPrice || gasPrice, + }, + advancedInlineGasShown: getAdvancedInlineGasShown(state), + insufficientBalance, + hideSubtitle: (!isMainnet && !showFiatInTestnets), + hideFiatConversion: (!isMainnet && !showFiatInTestnets), + metaMetricsSendCount, + } +} + +const mapDispatchToProps = dispatch => { + return { + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + clearSend: () => dispatch(clearSend()), + showTransactionConfirmedModal: ({ onSubmit }) => { + return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onSubmit })) + }, + showCustomizeGasModal: ({ txData, onSubmit, validate }) => { + return dispatch(showModal({ name: 'CUSTOMIZE_GAS', txData, onSubmit, validate })) + }, + updateGasAndCalculate: ({ gasLimit, gasPrice }) => { + return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) + }, + showRejectTransactionsConfirmationModal: ({ onSubmit, unapprovedTxCount }) => { + return dispatch(showModal({ name: 'REJECT_TRANSACTIONS', onSubmit, unapprovedTxCount })) + }, + cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), + cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), + sendTransaction: txData => dispatch(updateAndApproveTx(txData)), + setMetaMetricsSendCount: val => dispatch(setMetaMetricsSendCount(val)), + } +} + +const getValidateEditGas = ({ balance, conversionRate, txData }) => { + const { txParams: { value: amount } = {} } = txData + + return ({ gasLimit, gasPrice }) => { + const gasTotal = getHexGasTotal({ gasLimit, gasPrice }) + const hasSufficientBalance = isBalanceSufficient({ + amount, + gasTotal, + balance, + conversionRate, + }) + + if (!hasSufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + const gasLimitTooLow = gasLimit && conversionGreaterThan( + { + value: MIN_GAS_LIMIT_DEC, + fromNumericBase: 'dec', + conversionRate, + }, + { + value: gasLimit, + fromNumericBase: 'hex', + }, + ) + + if (gasLimitTooLow) { + return { + valid: false, + errorKey: GAS_LIMIT_TOO_LOW_ERROR_KEY, + } + } + + return { + valid: true, + } + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { balance, conversionRate, txData, unapprovedTxs } = stateProps + const { + cancelAllTransactions: dispatchCancelAllTransactions, + showCustomizeGasModal: dispatchShowCustomizeGasModal, + updateGasAndCalculate: dispatchUpdateGasAndCalculate, + ...otherDispatchProps + } = dispatchProps + + const validateEditGas = getValidateEditGas({ balance, conversionRate, txData }) + + return { + ...stateProps, + ...otherDispatchProps, + ...ownProps, + showCustomizeGasModal: () => dispatchShowCustomizeGasModal({ + txData, + onSubmit: customGas => dispatchUpdateGasAndCalculate(customGas), + validate: validateEditGas, + }), + cancelAllTransactions: () => dispatchCancelAllTransactions(valuesFor(unapprovedTxs)), + updateGasAndCalculate: dispatchUpdateGasAndCalculate, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(ConfirmTransactionBase) 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/components/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 index 8ca7ca4e7..8ca7ca4e7 100644 --- a/ui/app/components/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 diff --git a/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.component.js b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.component.js new file mode 100644 index 000000000..cd471b822 --- /dev/null +++ b/ui/app/pages/confirm-transaction-switch/confirm-transaction-switch.component.js @@ -0,0 +1,92 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Redirect } from 'react-router-dom' +import Loading from '../../components/ui/loading-screen' +import { + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TRANSFER_FROM_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, +} from '../../helpers/constants/routes' +import { isConfirmDeployContract } from '../../helpers/utils/transactions.util' +import { + TOKEN_METHOD_TRANSFER, + TOKEN_METHOD_APPROVE, + TOKEN_METHOD_TRANSFER_FROM, +} from '../../helpers/constants/transactions' + +export default class ConfirmTransactionSwitch extends Component { + static propTypes = { + txData: PropTypes.object, + methodData: PropTypes.object, + fetchingData: PropTypes.bool, + isEtherTransaction: PropTypes.bool, + } + + redirectToTransaction () { + const { + txData, + methodData: { name }, + fetchingData, + isEtherTransaction, + } = 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 (isEtherTransaction) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` + return <Redirect to={{ pathname }} /> + } + + if (data) { + const methodName = name && name.toLowerCase() + + switch (methodName) { + case TOKEN_METHOD_TRANSFER: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_TOKEN_PATH}` + return <Redirect to={{ pathname }} /> + } + case TOKEN_METHOD_APPROVE: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_APPROVE_PATH}` + return <Redirect to={{ pathname }} /> + } + case TOKEN_METHOD_TRANSFER_FROM: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TRANSFER_FROM_PATH}` + return <Redirect to={{ pathname }} /> + } + default: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TOKEN_METHOD_PATH}` + return <Redirect to={{ pathname }} /> + } + } + } + + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` + return <Redirect to={{ pathname }} /> + } + + render () { + const { txData } = this.props + + if (txData.txParams) { + return this.redirectToTransaction() + } else if (txData.msgParams) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${txData.id}${SIGNATURE_REQUEST_PATH}` + return <Redirect to={{ pathname }} /> + } + + return <Loading /> + } +} 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/pages/confirm-transaction/conf-tx.js b/ui/app/pages/confirm-transaction/conf-tx.js new file mode 100644 index 000000000..f9af6624e --- /dev/null +++ b/ui/app/pages/confirm-transaction/conf-tx.js @@ -0,0 +1,225 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const { withRouter } = require('react-router-dom') +const { compose } = require('recompose') +const actions = require('../../store/actions') +const txHelper = require('../../../lib/tx-helper') +const log = require('loglevel') +const R = require('ramda') + +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, + connect(mapStateToProps) +)(ConfirmTxScreen) + +function mapStateToProps (state) { + const { metamask } = state + const { + unapprovedMsgCount, + unapprovedPersonalMsgCount, + unapprovedTypedMessagesCount, + } = metamask + + return { + identities: state.metamask.identities, + accounts: getMetaMaskAccounts(state), + selectedAddress: state.metamask.selectedAddress, + unapprovedTxs: state.metamask.unapprovedTxs, + unapprovedMsgs: state.metamask.unapprovedMsgs, + unapprovedPersonalMsgs: state.metamask.unapprovedPersonalMsgs, + unapprovedTypedMessages: state.metamask.unapprovedTypedMessages, + index: state.appState.currentView.context, + warning: state.appState.warning, + network: state.metamask.network, + provider: state.metamask.provider, + conversionRate: state.metamask.conversionRate, + currentCurrency: state.metamask.currentCurrency, + blockGasLimit: state.metamask.currentBlockGasLimit, + computedBalances: state.metamask.computedBalances, + unapprovedMsgCount, + unapprovedPersonalMsgCount, + unapprovedTypedMessagesCount, + send: state.metamask.send, + selectedAddressTxList: state.metamask.selectedAddressTxList, + } +} + +inherits(ConfirmTxScreen, Component) +function ConfirmTxScreen () { + Component.call(this) +} + +ConfirmTxScreen.prototype.getUnapprovedMessagesTotal = function () { + const { + unapprovedMsgCount = 0, + unapprovedPersonalMsgCount = 0, + unapprovedTypedMessagesCount = 0, + } = this.props + + return unapprovedTypedMessagesCount + unapprovedMsgCount + unapprovedPersonalMsgCount +} + +ConfirmTxScreen.prototype.componentDidMount = function () { + const { + unapprovedTxs = {}, + network, + send, + } = this.props + const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network) + + if (unconfTxList.length === 0 && !send.to && this.getUnapprovedMessagesTotal() === 0) { + this.props.history.push(DEFAULT_ROUTE) + } +} + +ConfirmTxScreen.prototype.componentDidUpdate = function (prevProps) { + const { + unapprovedTxs = {}, + network, + selectedAddressTxList, + send, + history, + match: { params: { id: transactionId } = {} }, + } = this.props + + let prevTx + + if (transactionId) { + prevTx = R.find(({ id }) => id + '' === transactionId)(selectedAddressTxList) + } else { + const { index: prevIndex, unapprovedTxs: prevUnapprovedTxs } = prevProps + const prevUnconfTxList = txHelper(prevUnapprovedTxs, {}, {}, {}, network) + const prevTxData = prevUnconfTxList[prevIndex] || {} + prevTx = selectedAddressTxList.find(({ id }) => id === prevTxData.id) || {} + } + + const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network) + + if (prevTx && prevTx.status === 'dropped') { + this.props.dispatch(actions.showModal({ + name: 'TRANSACTION_CONFIRMED', + onSubmit: () => history.push(DEFAULT_ROUTE), + })) + + return + } + + if (unconfTxList.length === 0 && !send.to && this.getUnapprovedMessagesTotal() === 0) { + this.props.history.push(DEFAULT_ROUTE) + } +} + +ConfirmTxScreen.prototype.getTxData = function () { + const { + network, + index, + unapprovedTxs, + unapprovedMsgs, + unapprovedPersonalMsgs, + unapprovedTypedMessages, + match: { params: { id: transactionId } = {} }, + } = this.props + + const unconfTxList = txHelper( + unapprovedTxs, + unapprovedMsgs, + unapprovedPersonalMsgs, + unapprovedTypedMessages, + network + ) + + log.info(`rendering a combined ${unconfTxList.length} unconf msgs & txs`) + + return transactionId + ? R.find(({ id }) => id + '' === transactionId)(unconfTxList) + : unconfTxList[index] +} + +ConfirmTxScreen.prototype.render = function () { + const props = this.props + const { + currentCurrency, + conversionRate, + blockGasLimit, + } = props + + var txData = this.getTxData() || {} + const { msgParams } = txData + log.debug('msgParams detected, rendering pending msg') + + return msgParams + ? h(SignatureRequest, { + // Properties + txData: txData, + key: txData.id, + selectedAddress: props.selectedAddress, + accounts: props.accounts, + identities: props.identities, + conversionRate, + currentCurrency, + blockGasLimit, + // Actions + signMessage: this.signMessage.bind(this, txData), + signPersonalMessage: this.signPersonalMessage.bind(this, txData), + signTypedMessage: this.signTypedMessage.bind(this, txData), + cancelMessage: this.cancelMessage.bind(this, txData), + cancelPersonalMessage: this.cancelPersonalMessage.bind(this, txData), + cancelTypedMessage: this.cancelTypedMessage.bind(this, txData), + }) + : h(Loading) +} + +ConfirmTxScreen.prototype.signMessage = function (msgData, event) { + log.info('conf-tx.js: signing message') + var params = msgData.msgParams + params.metamaskId = msgData.id + this.stopPropagation(event) + return this.props.dispatch(actions.signMsg(params)) +} + +ConfirmTxScreen.prototype.stopPropagation = function (event) { + if (event.stopPropagation) { + event.stopPropagation() + } +} + +ConfirmTxScreen.prototype.signPersonalMessage = function (msgData, event) { + log.info('conf-tx.js: signing personal message') + var params = msgData.msgParams + params.metamaskId = msgData.id + this.stopPropagation(event) + return this.props.dispatch(actions.signPersonalMsg(params)) +} + +ConfirmTxScreen.prototype.signTypedMessage = function (msgData, event) { + log.info('conf-tx.js: signing typed message') + var params = msgData.msgParams + params.metamaskId = msgData.id + this.stopPropagation(event) + return this.props.dispatch(actions.signTypedMsg(params)) +} + +ConfirmTxScreen.prototype.cancelMessage = function (msgData, event) { + log.info('canceling message') + this.stopPropagation(event) + return this.props.dispatch(actions.cancelMsg(msgData)) +} + +ConfirmTxScreen.prototype.cancelPersonalMessage = function (msgData, event) { + log.info('canceling personal message') + this.stopPropagation(event) + return this.props.dispatch(actions.cancelPersonalMsg(msgData)) +} + +ConfirmTxScreen.prototype.cancelTypedMessage = function (msgData, event) { + log.info('canceling typed message') + this.stopPropagation(event) + return this.props.dispatch(actions.cancelTypedMsg(msgData)) +} diff --git a/ui/app/pages/confirm-transaction/confirm-transaction.component.js b/ui/app/pages/confirm-transaction/confirm-transaction.component.js new file mode 100644 index 000000000..35b8dc5aa --- /dev/null +++ b/ui/app/pages/confirm-transaction/confirm-transaction.component.js @@ -0,0 +1,160 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route } from 'react-router-dom' +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' +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 { + DEFAULT_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TRANSFER_FROM_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, +} from '../../helpers/constants/routes' + +export default class ConfirmTransaction extends Component { + static propTypes = { + history: PropTypes.object.isRequired, + totalUnapprovedCount: PropTypes.number.isRequired, + match: PropTypes.object, + send: PropTypes.object, + unconfirmedTransactions: PropTypes.array, + setTransactionToConfirm: PropTypes.func, + confirmTransaction: PropTypes.object, + clearConfirmTransaction: PropTypes.func, + fetchBasicGasAndTimeEstimates: PropTypes.func, + } + + getParamsTransactionId () { + const { match: { params: { id } = {} } } = this.props + return id || null + } + + componentDidMount () { + const { + totalUnapprovedCount = 0, + send = {}, + history, + confirmTransaction: { txData: { id: transactionId } = {} }, + fetchBasicGasAndTimeEstimates, + } = this.props + + if (!totalUnapprovedCount && !send.to) { + history.replace(DEFAULT_ROUTE) + return + } + + if (!transactionId) { + fetchBasicGasAndTimeEstimates() + this.setTransactionToConfirm() + } + } + + componentDidUpdate () { + const { + setTransactionToConfirm, + confirmTransaction: { txData: { id: transactionId } = {} }, + clearConfirmTransaction, + } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + if (paramsTransactionId && transactionId && paramsTransactionId !== transactionId + '') { + clearConfirmTransaction() + setTransactionToConfirm(paramsTransactionId) + return + } + + if (!transactionId) { + this.setTransactionToConfirm() + } + } + + setTransactionToConfirm () { + const { + history, + unconfirmedTransactions, + setTransactionToConfirm, + } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + if (paramsTransactionId) { + // Check to make sure params ID is valid + const tx = unconfirmedTransactions.find(({ id }) => id + '' === paramsTransactionId) + + if (!tx) { + history.replace(DEFAULT_ROUTE) + } else { + setTransactionToConfirm(paramsTransactionId) + } + } else if (unconfirmedTransactions.length) { + const totalUnconfirmed = unconfirmedTransactions.length + const transaction = unconfirmedTransactions[totalUnconfirmed - 1] + const { id: transactionId, loadingDefaults } = transaction + + if (!loadingDefaults) { + setTransactionToConfirm(transactionId) + } + } + } + + render () { + const { confirmTransaction: { txData: { id } } = {} } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + // Show routes when state.confirmTransaction has been set and when either the ID in the params + // isn't specified or is specified and matches the ID in state.confirmTransaction in order to + // support URLs of /confirm-transaction or /confirm-transaction/<transactionId> + return id && (!paramsTransactionId || paramsTransactionId === id + '') + ? ( + <Switch> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_DEPLOY_CONTRACT_PATH}`} + component={ConfirmDeployContract} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TOKEN_METHOD_PATH}`} + component={ConfirmTransactionBase} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_ETHER_PATH}`} + component={ConfirmSendEther} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_TOKEN_PATH}`} + component={ConfirmSendToken} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_APPROVE_PATH}`} + component={ConfirmApprove} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TRANSFER_FROM_PATH}`} + component={ConfirmTokenTransactionBase} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${SIGNATURE_REQUEST_PATH}`} + component={ConfTx} + /> + <Route path="*" component={ConfirmTransactionSwitch} /> + </Switch> + ) + : <Loading /> + } +} diff --git a/ui/app/pages/confirm-transaction/confirm-transaction.container.js b/ui/app/pages/confirm-transaction/confirm-transaction.container.js new file mode 100644 index 000000000..2dd5e833e --- /dev/null +++ b/ui/app/pages/confirm-transaction/confirm-transaction.container.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import { + setTransactionToConfirm, + clearConfirmTransaction, +} from '../../ducks/confirm-transaction/confirm-transaction.duck' +import { + fetchBasicGasAndTimeEstimates, +} from '../../ducks/gas/gas.duck' +import ConfirmTransaction from './confirm-transaction.component' +import { getTotalUnapprovedCount } from '../../selectors/selectors' +import { unconfirmedTransactionsListSelector } from '../../selectors/confirm-transaction' + +const mapStateToProps = state => { + const { metamask: { send }, confirmTransaction } = state + + return { + totalUnapprovedCount: getTotalUnapprovedCount(state), + send, + confirmTransaction, + unconfirmedTransactions: unconfirmedTransactionsListSelector(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + setTransactionToConfirm: transactionId => dispatch(setTransactionToConfirm(transactionId)), + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps), +)(ConfirmTransaction) 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/pages/create-account/connect-hardware/account-list.js b/ui/app/pages/create-account/connect-hardware/account-list.js new file mode 100644 index 000000000..617fb8833 --- /dev/null +++ b/ui/app/pages/create-account/connect-hardware/account-list.js @@ -0,0 +1,205 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const genAccountLink = require('../../../../lib/account-link.js') +const Select = require('react-select').default +import Button from '../../../components/ui/button' + +class AccountList extends Component { + constructor (props, context) { + super(props) + } + + getHdPaths () { + return [ + { + label: `Ledger Live`, + value: `m/44'/60'/0'/0/0`, + }, + { + label: `Legacy (MEW / MyCrypto)`, + value: `m/44'/60'/0'`, + }, + ] + } + + goToNextPage = () => { + // If we have < 5 accounts, it's restricted by BIP-44 + if (this.props.accounts.length === 5) { + this.props.getPage(this.props.device, 1, this.props.selectedPath) + } else { + this.props.onAccountRestriction() + } + } + + goToPreviousPage = () => { + this.props.getPage(this.props.device, -1, this.props.selectedPath) + } + + renderHdPathSelector () { + const { onPathChange, selectedPath } = this.props + + const options = this.getHdPaths() + return h('div', [ + h('h3.hw-connect__hdPath__title', {}, this.context.t('selectHdPath')), + h('p.hw-connect__msg', {}, this.context.t('selectPathHelp')), + h('div.hw-connect__hdPath', [ + h(Select, { + className: 'hw-connect__hdPath__select', + name: 'hd-path-select', + clearable: false, + value: selectedPath, + options, + onChange: (opt) => { + onPathChange(opt.value) + }, + }), + ]), + ]) + } + + capitalizeDevice (device) { + return device.slice(0, 1).toUpperCase() + device.slice(1) + } + + renderHeader () { + const { device } = this.props + return ( + h('div.hw-connect', [ + + h('h3.hw-connect__unlock-title', {}, `${this.context.t('unlock')} ${this.capitalizeDevice(device)}`), + + device.toLowerCase() === 'ledger' ? this.renderHdPathSelector() : null, + + h('h3.hw-connect__hdPath__title', {}, this.context.t('selectAnAccount')), + h('p.hw-connect__msg', {}, this.context.t('selectAnAccountHelp')), + ]) + ) + } + + renderAccounts () { + return h('div.hw-account-list', [ + this.props.accounts.map((a, i) => { + + return h('div.hw-account-list__item', { key: a.address }, [ + h('div.hw-account-list__item__radio', [ + h('input', { + type: 'radio', + name: 'selectedAccount', + id: `address-${i}`, + value: a.index, + onChange: (e) => this.props.onAccountChange(e.target.value), + checked: this.props.selectedAccount === a.index.toString(), + }), + h( + 'label.hw-account-list__item__label', + { + htmlFor: `address-${i}`, + }, + [ + h('span.hw-account-list__item__index', a.index + 1), + `${a.address.slice(0, 4)}...${a.address.slice(-4)}`, + h('span.hw-account-list__item__balance', `${a.balance}`), + ]), + ]), + h( + 'a.hw-account-list__item__link', + { + href: genAccountLink(a.address, this.props.network), + target: '_blank', + title: this.context.t('etherscanView'), + }, + h('img', { src: 'images/popout.svg' }) + ), + ]) + }), + ]) + } + + renderPagination () { + return h('div.hw-list-pagination', [ + h( + 'button.hw-list-pagination__button', + { + onClick: this.goToPreviousPage, + }, + `< ${this.context.t('prev')}` + ), + + h( + 'button.hw-list-pagination__button', + { + onClick: this.goToNextPage, + }, + `${this.context.t('next')} >` + ), + ]) + } + + renderButtons () { + const disabled = this.props.selectedAccount === null + const buttonProps = {} + if (disabled) { + buttonProps.disabled = true + } + + return h('div.new-account-connect-form__buttons', {}, [ + h(Button, { + type: 'default', + large: true, + className: 'new-account-connect-form__button', + onClick: this.props.onCancel.bind(this), + }, [this.context.t('cancel')]), + + h(Button, { + type: 'confirm', + large: true, + className: 'new-account-connect-form__button unlock', + disabled, + onClick: this.props.onUnlockAccount.bind(this, this.props.device), + }, [this.context.t('unlock')]), + ]) + } + + renderForgetDevice () { + return h('div.hw-forget-device-container', {}, [ + h('a', { + onClick: this.props.onForgetDevice.bind(this, this.props.device), + }, this.context.t('forgetDevice')), + ]) + } + + render () { + return h('div.new-account-connect-form.account-list', {}, [ + this.renderHeader(), + this.renderAccounts(), + this.renderPagination(), + this.renderButtons(), + this.renderForgetDevice(), + ]) + } + +} + + +AccountList.propTypes = { + onPathChange: PropTypes.func.isRequired, + selectedPath: PropTypes.string.isRequired, + device: PropTypes.string.isRequired, + accounts: PropTypes.array.isRequired, + onAccountChange: PropTypes.func.isRequired, + onForgetDevice: PropTypes.func.isRequired, + getPage: PropTypes.func.isRequired, + network: PropTypes.string, + selectedAccount: PropTypes.string, + history: PropTypes.object, + onUnlockAccount: PropTypes.func, + onCancel: PropTypes.func, + onAccountRestriction: PropTypes.func, +} + +AccountList.contextTypes = { + t: PropTypes.func, +} + +module.exports = AccountList diff --git a/ui/app/pages/create-account/connect-hardware/connect-screen.js b/ui/app/pages/create-account/connect-hardware/connect-screen.js new file mode 100644 index 000000000..7e9dee970 --- /dev/null +++ b/ui/app/pages/create-account/connect-hardware/connect-screen.js @@ -0,0 +1,197 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +import Button from '../../../components/ui/button' + +class ConnectScreen extends Component { + constructor (props, context) { + super(props) + this.state = { + selectedDevice: null, + } + } + + connect = () => { + if (this.state.selectedDevice) { + this.props.connectToHardwareWallet(this.state.selectedDevice) + } + return null + } + + renderConnectToTrezorButton () { + return h( + `button.hw-connect__btn${this.state.selectedDevice === 'trezor' ? '.selected' : ''}`, + { onClick: _ => this.setState({selectedDevice: 'trezor'}) }, + h('img.hw-connect__btn__img', { + src: 'images/trezor-logo.svg', + }) + ) + } + + renderConnectToLedgerButton () { + return h( + `button.hw-connect__btn${this.state.selectedDevice === 'ledger' ? '.selected' : ''}`, + { onClick: _ => this.setState({selectedDevice: 'ledger'}) }, + h('img.hw-connect__btn__img', { + src: 'images/ledger-logo.svg', + }) + ) + } + + renderButtons () { + return ( + h('div', {}, [ + h('div.hw-connect__btn-wrapper', {}, [ + this.renderConnectToLedgerButton(), + this.renderConnectToTrezorButton(), + ]), + h(Button, { + type: 'confirm', + large: true, + className: 'hw-connect__connect-btn', + onClick: this.connect, + disabled: !this.state.selectedDevice, + }, this.context.t('connect')), + ]) + ) + } + + renderUnsupportedBrowser () { + return ( + h('div.new-account-connect-form.unsupported-browser', {}, [ + h('div.hw-connect', [ + h('h3.hw-connect__title', {}, this.context.t('browserNotSupported')), + h('p.hw-connect__msg', {}, this.context.t('chromeRequiredForHardwareWallets')), + ]), + h(Button, { + type: 'primary', + large: true, + onClick: () => global.platform.openWindow({ + url: 'https://google.com/chrome', + }), + }, this.context.t('downloadGoogleChrome')), + ]) + ) + } + + renderHeader () { + return ( + h('div.hw-connect__header', {}, [ + h('h3.hw-connect__header__title', {}, this.context.t(`hardwareWallets`)), + h('p.hw-connect__header__msg', {}, this.context.t(`hardwareWalletsMsg`)), + ]) + ) + } + + getAffiliateLinks () { + const links = { + trezor: `<a class='hw-connect__get-hw__link' href='https://shop.trezor.io/?a=metamask' target='_blank'>Trezor</a>`, + ledger: `<a class='hw-connect__get-hw__link' href='https://www.ledger.com/products/ledger-nano-s?r=17c4991a03fa&tracker=MY_TRACKER' target='_blank'>Ledger</a>`, + } + + const text = this.context.t('orderOneHere') + const response = text.replace('Trezor', links.trezor).replace('Ledger', links.ledger) + + return h('div.hw-connect__get-hw__msg', { dangerouslySetInnerHTML: {__html: response }}) + } + + renderTrezorAffiliateLink () { + return h('div.hw-connect__get-hw', {}, [ + h('p.hw-connect__get-hw__msg', {}, this.context.t(`dontHaveAHardwareWallet`)), + this.getAffiliateLinks(), + ]) + } + + + scrollToTutorial = (e) => { + if (this.referenceNode) this.referenceNode.scrollIntoView({behavior: 'smooth'}) + } + + renderLearnMore () { + return ( + h('p.hw-connect__learn-more', { + onClick: this.scrollToTutorial, + }, [ + this.context.t('learnMore'), + h('img.hw-connect__learn-more__arrow', { src: 'images/caret-right.svg'}), + ]) + ) + } + + renderTutorialSteps () { + const steps = [ + { + asset: 'hardware-wallet-step-1', + dimensions: {width: '225px', height: '75px'}, + }, + { + asset: 'hardware-wallet-step-2', + dimensions: {width: '300px', height: '100px'}, + }, + { + asset: 'hardware-wallet-step-3', + dimensions: {width: '120px', height: '90px'}, + }, + ] + + return h('.hw-tutorial', { + ref: node => { this.referenceNode = node }, + }, + steps.map((step, i) => ( + h('div.hw-connect', {}, [ + h('h3.hw-connect__title', {}, this.context.t(`step${i + 1}HardwareWallet`)), + h('p.hw-connect__msg', {}, this.context.t(`step${i + 1}HardwareWalletMsg`)), + h('img.hw-connect__step-asset', { src: `images/${step.asset}.svg`, ...step.dimensions }), + ]) + )) + ) + } + + renderFooter () { + return ( + h('div.hw-connect__footer', {}, [ + h('h3.hw-connect__footer__title', {}, this.context.t(`readyToConnect`)), + this.renderButtons(), + h('p.hw-connect__footer__msg', {}, [ + this.context.t(`havingTroubleConnecting`), + h('a.hw-connect__footer__link', { + href: 'https://support.metamask.io/', + target: '_blank', + }, this.context.t('getHelp')), + ]), + ]) + ) + } + + renderConnectScreen () { + return ( + h('div.new-account-connect-form', {}, [ + this.renderHeader(), + this.renderButtons(), + this.renderTrezorAffiliateLink(), + this.renderLearnMore(), + this.renderTutorialSteps(), + this.renderFooter(), + ]) + ) + } + + render () { + if (this.props.browserSupported) { + return this.renderConnectScreen() + } + return this.renderUnsupportedBrowser() + } +} + +ConnectScreen.propTypes = { + connectToHardwareWallet: PropTypes.func.isRequired, + browserSupported: PropTypes.bool.isRequired, +} + +ConnectScreen.contextTypes = { + t: PropTypes.func, +} + +module.exports = ConnectScreen + diff --git a/ui/app/pages/create-account/connect-hardware/index.js b/ui/app/pages/create-account/connect-hardware/index.js new file mode 100644 index 000000000..1398fa680 --- /dev/null +++ b/ui/app/pages/create-account/connect-hardware/index.js @@ -0,0 +1,293 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const actions = require('../../../store/actions') +const { getMetaMaskAccounts } = require('../../../selectors/selectors') +const ConnectScreen = require('./connect-screen') +const AccountList = require('./account-list') +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) { + super(props) + this.state = { + error: null, + selectedAccount: null, + accounts: [], + browserSupported: true, + unlocked: false, + device: null, + } + } + + componentWillReceiveProps (nextProps) { + const { accounts } = nextProps + const newAccounts = this.state.accounts.map(a => { + const normalizedAddress = a.address.toLowerCase() + const balanceValue = accounts[normalizedAddress] && accounts[normalizedAddress].balance || null + a.balance = balanceValue ? formatBalance(balanceValue, 6) : '...' + return a + }) + this.setState({accounts: newAccounts}) + } + + + componentDidMount () { + this.checkIfUnlocked() + } + + async checkIfUnlocked () { + ['trezor', 'ledger'].forEach(async device => { + const unlocked = await this.props.checkHardwareStatus(device, this.props.defaultHdPaths[device]) + if (unlocked) { + this.setState({unlocked: true}) + this.getPage(device, 0, this.props.defaultHdPaths[device]) + } + }) + } + + connectToHardwareWallet = (device) => { + // Ledger hardware wallets are not supported on firefox + if (getPlatform() === PLATFORM_FIREFOX && device === 'ledger') { + this.setState({ browserSupported: false, error: null}) + return null + } + + if (this.state.accounts.length) { + return null + } + + // Default values + this.getPage(device, 0, this.props.defaultHdPaths[device]) + } + + onPathChange = (path) => { + this.props.setHardwareWalletDefaultHdPath({device: this.state.device, path}) + this.getPage(this.state.device, 0, path) + } + + onAccountChange = (account) => { + this.setState({selectedAccount: account.toString(), error: null}) + } + + onAccountRestriction = () => { + this.setState({error: this.context.t('ledgerAccountRestriction') }) + } + + showTemporaryAlert () { + this.props.showAlert(this.context.t('hardwareWalletConnected')) + // Autohide the alert after 5 seconds + setTimeout(_ => { + this.props.hideAlert() + }, 5000) + } + + getPage = (device, page, hdPath) => { + this.props + .connectHardware(device, page, hdPath) + .then(accounts => { + if (accounts.length) { + + // If we just loaded the accounts for the first time + // (device previously locked) show the global alert + if (this.state.accounts.length === 0 && !this.state.unlocked) { + this.showTemporaryAlert() + } + + const newState = { unlocked: true, device, error: null } + // Default to the first account + if (this.state.selectedAccount === null) { + accounts.forEach((a, i) => { + if (a.address.toLowerCase() === this.props.address) { + newState.selectedAccount = a.index.toString() + } + }) + // If the page doesn't contain the selected account, let's deselect it + } else if (!accounts.filter(a => a.index.toString() === this.state.selectedAccount).length) { + newState.selectedAccount = null + } + + + // Map accounts with balances + newState.accounts = accounts.map(account => { + const normalizedAddress = account.address.toLowerCase() + const balanceValue = this.props.accounts[normalizedAddress] && this.props.accounts[normalizedAddress].balance || null + account.balance = balanceValue ? formatBalance(balanceValue, 6) : '...' + return account + }) + + this.setState(newState) + } + }) + .catch(e => { + if (e === 'Window blocked') { + this.setState({ browserSupported: false, error: null}) + } else if (e !== 'Window closed' && e !== 'Popup closed') { + this.setState({ error: e.toString() }) + } + }) + } + + onForgetDevice = (device) => { + this.props.forgetDevice(device) + .then(_ => { + this.setState({ + error: null, + selectedAccount: null, + accounts: [], + unlocked: false, + }) + }).catch(e => { + this.setState({ error: e.toString() }) + }) + } + + onUnlockAccount = (device) => { + + if (this.state.selectedAccount === null) { + this.setState({ error: this.context.t('accountSelectionRequired') }) + } + + 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() }) + }) + } + + onCancel = () => { + this.props.history.push(DEFAULT_ROUTE) + } + + renderError () { + return this.state.error + ? h('span.error', { style: { margin: '20px 20px 10px', display: 'block', textAlign: 'center' } }, this.state.error) + : null + } + + renderContent () { + if (!this.state.accounts.length) { + return h(ConnectScreen, { + connectToHardwareWallet: this.connectToHardwareWallet, + browserSupported: this.state.browserSupported, + }) + } + + return h(AccountList, { + onPathChange: this.onPathChange, + selectedPath: this.props.defaultHdPaths[this.state.device], + device: this.state.device, + accounts: this.state.accounts, + selectedAccount: this.state.selectedAccount, + onAccountChange: this.onAccountChange, + network: this.props.network, + getPage: this.getPage, + history: this.props.history, + onUnlockAccount: this.onUnlockAccount, + onForgetDevice: this.onForgetDevice, + onCancel: this.onCancel, + onAccountRestriction: this.onAccountRestriction, + }) + } + + render () { + return h('div', [ + this.renderError(), + this.renderContent(), + ]) + } +} + +ConnectHardwareForm.propTypes = { + hideModal: PropTypes.func, + showImportPage: PropTypes.func, + showConnectPage: PropTypes.func, + connectHardware: PropTypes.func, + checkHardwareStatus: PropTypes.func, + forgetDevice: PropTypes.func, + showAlert: PropTypes.func, + hideAlert: PropTypes.func, + unlockHardwareWalletAccount: PropTypes.func, + setHardwareWalletDefaultHdPath: PropTypes.func, + numberOfExistingAccounts: PropTypes.number, + history: PropTypes.object, + t: PropTypes.func, + network: PropTypes.string, + accounts: PropTypes.object, + address: PropTypes.string, + defaultHdPaths: PropTypes.object, +} + +const mapStateToProps = state => { + const { + metamask: { network, selectedAddress, identities = {} }, + } = state + const accounts = getMetaMaskAccounts(state) + const numberOfExistingAccounts = Object.keys(identities).length + const { + appState: { defaultHdPaths }, + } = state + + return { + network, + accounts, + address: selectedAddress, + numberOfExistingAccounts, + defaultHdPaths, + } +} + +const mapDispatchToProps = dispatch => { + return { + setHardwareWalletDefaultHdPath: ({device, path}) => { + return dispatch(actions.setHardwareWalletDefaultHdPath({device, path})) + }, + connectHardware: (deviceName, page, hdPath) => { + return dispatch(actions.connectHardware(deviceName, page, hdPath)) + }, + checkHardwareStatus: (deviceName, hdPath) => { + return dispatch(actions.checkHardwareStatus(deviceName, hdPath)) + }, + forgetDevice: (deviceName) => { + return dispatch(actions.forgetDevice(deviceName)) + }, + unlockHardwareWalletAccount: (index, deviceName, hdPath) => { + return dispatch(actions.unlockHardwareWalletAccount(index, deviceName, hdPath)) + }, + showImportPage: () => dispatch(actions.showImportPage()), + showConnectPage: () => dispatch(actions.showConnectPage()), + showAlert: (msg) => dispatch(actions.showAlert(msg)), + hideAlert: () => dispatch(actions.hideAlert()), + } +} + +ConnectHardwareForm.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)( + ConnectHardwareForm +) 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/pages/create-account/import-account/json.js b/ui/app/pages/create-account/import-account/json.js new file mode 100644 index 000000000..17bef763c --- /dev/null +++ b/ui/app/pages/create-account/import-account/json.js @@ -0,0 +1,170 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const { withRouter } = require('react-router-dom') +const { compose } = require('recompose') +const connect = require('react-redux').connect +const actions = require('../../../store/actions') +const FileInput = require('react-simple-file-input').default +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 '../../../components/ui/button' + +class JsonImportSubview extends Component { + constructor (props) { + super(props) + + this.state = { + file: null, + fileContents: '', + } + } + + render () { + const { error } = this.props + + return ( + h('div.new-account-import-form__json', [ + + h('p', this.context.t('usedByClients')), + h('a.warning', { + href: HELP_LINK, + target: '_blank', + }, this.context.t('fileImportFail')), + + h(FileInput, { + readAs: 'text', + onLoad: this.onLoad.bind(this), + style: { + margin: '20px 0px 12px 34%', + fontSize: '15px', + display: 'flex', + justifyContent: 'center', + }, + }), + + h('input.new-account-import-form__input-password', { + type: 'password', + placeholder: this.context.t('enterPassword'), + id: 'json-password-box', + onKeyPress: this.createKeyringOnEnter.bind(this), + }), + + h('div.new-account-create-form__buttons', {}, [ + + 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: () => this.createNewKeychain(), + }, [this.context.t('import')]), + + ]), + + error ? h('span.error', error) : null, + ]) + ) + } + + onLoad (event, file) { + this.setState({file: file, fileContents: event.target.result}) + } + + createKeyringOnEnter (event) { + if (event.key === 'Enter') { + event.preventDefault() + this.createNewKeychain() + } + } + + createNewKeychain () { + const { firstAddress, displayWarning, importNewJsonAccount, setSelectedAddress, history } = this.props + const state = this.state + + if (!state) { + const message = this.context.t('validFileImport') + return displayWarning(message) + } + + const { fileContents } = state + + if (!fileContents) { + const message = this.context.t('needImportFile') + return displayWarning(message) + } + + const passwordInput = document.getElementById('json-password-box') + const password = passwordInput.value + + 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) + } + }) + .catch(err => err && displayWarning(err.message || err)) + } +} + +JsonImportSubview.propTypes = { + error: PropTypes.string, + goHome: PropTypes.func, + displayWarning: PropTypes.func, + firstAddress: PropTypes.string, + importNewJsonAccount: PropTypes.func, + history: PropTypes.object, + setSelectedAddress: PropTypes.func, + t: PropTypes.func, +} + +const mapStateToProps = state => { + return { + error: state.appState.warning, + firstAddress: Object.keys(getMetaMaskAccounts(state))[0], + } +} + +const mapDispatchToProps = dispatch => { + return { + goHome: () => dispatch(actions.goHome()), + displayWarning: warning => dispatch(actions.displayWarning(warning)), + importNewJsonAccount: options => dispatch(actions.importNewAccount('JSON File', options)), + setSelectedAddress: (address) => dispatch(actions.setSelectedAddress(address)), + } +} + +JsonImportSubview.contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, +} + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(JsonImportSubview) diff --git a/ui/app/pages/create-account/import-account/private-key.js b/ui/app/pages/create-account/import-account/private-key.js new file mode 100644 index 000000000..450614e87 --- /dev/null +++ b/ui/app/pages/create-account/import-account/private-key.js @@ -0,0 +1,128 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const { withRouter } = require('react-router-dom') +const { compose } = require('recompose') +const PropTypes = require('prop-types') +const connect = require('react-redux').connect +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( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(PrivateKeyImportView) + + +function mapStateToProps (state) { + return { + error: state.appState.warning, + firstAddress: Object.keys(getMetaMaskAccounts(state))[0], + } +} + +function mapDispatchToProps (dispatch) { + return { + importNewAccount: (strategy, [ privateKey ]) => { + return dispatch(actions.importNewAccount(strategy, [ privateKey ])) + }, + displayWarning: (message) => dispatch(actions.displayWarning(message || null)), + setSelectedAddress: (address) => dispatch(actions.setSelectedAddress(address)), + } +} + +inherits(PrivateKeyImportView, Component) +function PrivateKeyImportView () { + this.createKeyringOnEnter = this.createKeyringOnEnter.bind(this) + Component.call(this) +} + +PrivateKeyImportView.prototype.render = function () { + const { error, displayWarning } = this.props + + return ( + h('div.new-account-import-form__private-key', [ + + h('span.new-account-create-form__instruction', this.context.t('pastePrivateKey')), + + h('div.new-account-import-form__private-key-password-container', [ + + h('input.new-account-import-form__input-password', { + type: 'password', + id: 'private-key-box', + onKeyPress: e => this.createKeyringOnEnter(e), + }), + + ]), + + h('div.new-account-import-form__buttons', {}, [ + + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', + onClick: () => { + displayWarning(null) + this.props.history.push(DEFAULT_ROUTE) + }, + }, [this.context.t('cancel')]), + + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', + onClick: () => this.createNewKeychain(), + }, [this.context.t('import')]), + + ]), + + error ? h('span.error', error) : null, + ]) + ) +} + +PrivateKeyImportView.prototype.createKeyringOnEnter = function (event) { + if (event.key === 'Enter') { + event.preventDefault() + this.createNewKeychain() + } +} + +PrivateKeyImportView.prototype.createNewKeychain = function () { + const input = document.getElementById('private-key-box') + const privateKey = input.value + const { importNewAccount, history, displayWarning, setSelectedAddress, firstAddress } = this.props + + 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) + } + }) + .catch(err => err && displayWarning(err.message || err)) +} 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/pages/create-account/index.js b/ui/app/pages/create-account/index.js new file mode 100644 index 000000000..ce84db028 --- /dev/null +++ b/ui/app/pages/create-account/index.js @@ -0,0 +1,113 @@ +const Component = require('react').Component +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('../../store/actions') +const { getCurrentViewContext } = require('../../selectors/selectors') +const classnames = require('classnames') +const NewAccountCreateForm = require('./new-account') +const NewAccountImportForm = require('./import-account') +const ConnectHardwareForm = require('./connect-hardware') +const { + NEW_ACCOUNT_ROUTE, + IMPORT_ACCOUNT_ROUTE, + CONNECT_HARDWARE_ROUTE, +} = require('../../helpers/constants/routes') + +class CreateAccountPage extends Component { + renderTabs () { + const { history, location } = this.props + + return h('div.new-account__tabs', [ + h('div.new-account__tabs__tab', { + className: classnames('new-account__tabs__tab', { + 'new-account__tabs__selected': matchPath(location.pathname, { + path: NEW_ACCOUNT_ROUTE, exact: true, + }), + }), + onClick: () => history.push(NEW_ACCOUNT_ROUTE), + }, [ + this.context.t('create'), + ]), + + h('div.new-account__tabs__tab', { + className: classnames('new-account__tabs__tab', { + 'new-account__tabs__selected': matchPath(location.pathname, { + path: IMPORT_ACCOUNT_ROUTE, exact: true, + }), + }), + onClick: () => history.push(IMPORT_ACCOUNT_ROUTE), + }, [ + this.context.t('import'), + ]), + h( + 'div.new-account__tabs__tab', + { + className: classnames('new-account__tabs__tab', { + 'new-account__tabs__selected': matchPath(location.pathname, { + path: CONNECT_HARDWARE_ROUTE, + exact: true, + }), + }), + onClick: () => history.push(CONNECT_HARDWARE_ROUTE), + }, + this.context.t('connect') + ), + ]) + } + + render () { + return h('div.new-account', {}, [ + h('div.new-account__header', [ + h('div.new-account__title', this.context.t('newAccount')), + this.renderTabs(), + ]), + h('div.new-account__form', [ + h(Switch, [ + h(Route, { + exact: true, + path: NEW_ACCOUNT_ROUTE, + component: NewAccountCreateForm, + }), + h(Route, { + exact: true, + path: IMPORT_ACCOUNT_ROUTE, + component: NewAccountImportForm, + }), + h(Route, { + exact: true, + path: CONNECT_HARDWARE_ROUTE, + component: ConnectHardwareForm, + }), + ]), + ]), + ]) + } +} + +CreateAccountPage.propTypes = { + location: PropTypes.object, + history: PropTypes.object, + t: PropTypes.func, +} + +CreateAccountPage.contextTypes = { + t: PropTypes.func, +} + +const mapStateToProps = state => ({ + displayedForm: getCurrentViewContext(state), +}) + +const mapDispatchToProps = dispatch => ({ + 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)), +}) + +module.exports = connect(mapStateToProps, mapDispatchToProps)(CreateAccountPage) diff --git a/ui/app/pages/create-account/new-account.js b/ui/app/pages/create-account/new-account.js new file mode 100644 index 000000000..316fbe6f1 --- /dev/null +++ b/ui/app/pages/create-account/new-account.js @@ -0,0 +1,130 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +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) { + super(props) + + const { numberOfExistingAccounts = 0 } = props + const newAccountNumber = numberOfExistingAccounts + 1 + + this.state = { + newAccountName: '', + defaultAccountName: context.t('newAccountNumberName', [newAccountNumber]), + } + } + + render () { + const { newAccountName, defaultAccountName } = this.state + const { history, createAccount } = this.props + + return h('div.new-account-create-form', [ + + h('div.new-account-create-form__input-label', {}, [ + this.context.t('accountName'), + ]), + + h('div.new-account-create-form__input-wrapper', {}, [ + h('input.new-account-create-form__input', { + value: newAccountName, + placeholder: defaultAccountName, + onChange: event => this.setState({ newAccountName: event.target.value }), + }, []), + ]), + + h('div.new-account-create-form__buttons', {}, [ + + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', + onClick: () => history.push(DEFAULT_ROUTE), + }, [this.context.t('cancel')]), + + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', + onClick: () => { + createAccount(newAccountName || defaultAccountName) + .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')]), + + ]), + + ]) + } +} + +NewAccountCreateForm.propTypes = { + hideModal: PropTypes.func, + showImportPage: PropTypes.func, + showConnectPage: PropTypes.func, + createAccount: PropTypes.func, + numberOfExistingAccounts: PropTypes.number, + history: PropTypes.object, + t: PropTypes.func, +} + +const mapStateToProps = state => { + const { metamask: { network, selectedAddress, identities = {} } } = state + const numberOfExistingAccounts = Object.keys(identities).length + + return { + network, + address: selectedAddress, + numberOfExistingAccounts, + } +} + +const mapDispatchToProps = dispatch => { + return { + toCoinbase: address => dispatch(actions.buyEth({ network: '1', address, amount: 0 })), + hideModal: () => dispatch(actions.hideModal()), + createAccount: newAccountName => { + return dispatch(actions.addNewAccount()) + .then(newAccountAddress => { + if (newAccountName) { + dispatch(actions.setAccountLabel(newAccountAddress, newAccountName)) + } + }) + }, + showImportPage: () => dispatch(actions.showImportPage()), + showConnectPage: () => dispatch(actions.showConnectPage()), + } +} + +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/components/pages/first-time-flow/create-password/create-password.container.js b/ui/app/pages/first-time-flow/create-password/create-password.container.js index 89106f016..89106f016 100644 --- a/ui/app/components/pages/first-time-flow/create-password/create-password.container.js +++ b/ui/app/pages/first-time-flow/create-password/create-password.container.js 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/components/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 index e5ff1fde5..e5ff1fde5 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/pages/first-time-flow/create-password/index.js b/ui/app/pages/first-time-flow/create-password/index.js index 42e7436f9..42e7436f9 100644 --- a/ui/app/components/pages/first-time-flow/create-password/index.js +++ b/ui/app/pages/first-time-flow/create-password/index.js diff --git a/ui/app/components/pages/first-time-flow/create-password/new-account/index.js b/ui/app/pages/first-time-flow/create-password/new-account/index.js index 97db39cc3..97db39cc3 100644 --- a/ui/app/components/pages/first-time-flow/create-password/new-account/index.js +++ b/ui/app/pages/first-time-flow/create-password/new-account/index.js 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/components/pages/first-time-flow/create-password/unique-image/index.js b/ui/app/pages/first-time-flow/create-password/unique-image/index.js index 0e97bf755..0e97bf755 100644 --- a/ui/app/components/pages/first-time-flow/create-password/unique-image/index.js +++ b/ui/app/pages/first-time-flow/create-password/unique-image/index.js 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/components/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 index 34874aaec..34874aaec 100644 --- a/ui/app/components/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 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/components/pages/first-time-flow/end-of-flow/index.js b/ui/app/pages/first-time-flow/end-of-flow/index.js index b0643d155..b0643d155 100644 --- a/ui/app/components/pages/first-time-flow/end-of-flow/index.js +++ b/ui/app/pages/first-time-flow/end-of-flow/index.js diff --git a/ui/app/components/pages/first-time-flow/end-of-flow/index.scss b/ui/app/pages/first-time-flow/end-of-flow/index.scss index d7eb4513b..d7eb4513b 100644 --- a/ui/app/components/pages/first-time-flow/end-of-flow/index.scss +++ b/ui/app/pages/first-time-flow/end-of-flow/index.scss 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/components/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 index d68f7a153..d68f7a153 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/pages/first-time-flow/first-time-flow-switch/index.js b/ui/app/pages/first-time-flow/first-time-flow-switch/index.js index 3647756ef..3647756ef 100644 --- a/ui/app/components/pages/first-time-flow/first-time-flow-switch/index.js +++ b/ui/app/pages/first-time-flow/first-time-flow-switch/index.js 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/components/pages/first-time-flow/index.js b/ui/app/pages/first-time-flow/index.js index 5db42437c..5db42437c 100644 --- a/ui/app/components/pages/first-time-flow/index.js +++ b/ui/app/pages/first-time-flow/index.js 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/components/pages/first-time-flow/metametrics-opt-in/index.js b/ui/app/pages/first-time-flow/metametrics-opt-in/index.js index 4bc2fc3a7..4bc2fc3a7 100644 --- a/ui/app/components/pages/first-time-flow/metametrics-opt-in/index.js +++ b/ui/app/pages/first-time-flow/metametrics-opt-in/index.js diff --git a/ui/app/components/pages/first-time-flow/metametrics-opt-in/index.scss b/ui/app/pages/first-time-flow/metametrics-opt-in/index.scss index 6c2e37785..6c2e37785 100644 --- a/ui/app/components/pages/first-time-flow/metametrics-opt-in/index.scss +++ b/ui/app/pages/first-time-flow/metametrics-opt-in/index.scss 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/components/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 index f2476fc5c..f2476fc5c 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index c7b511503..c7b511503 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index 93137618c..93137618c 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/pages/first-time-flow/seed-phrase/index.js b/ui/app/pages/first-time-flow/seed-phrase/index.js index 185b3f089..185b3f089 100644 --- a/ui/app/components/pages/first-time-flow/seed-phrase/index.js +++ b/ui/app/pages/first-time-flow/seed-phrase/index.js 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/components/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 index 4a1b191b5..4a1b191b5 100644 --- a/ui/app/components/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 diff --git a/ui/app/components/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 index 8a47447ed..8a47447ed 100644 --- a/ui/app/components/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 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/components/pages/first-time-flow/select-action/index.js b/ui/app/pages/first-time-flow/select-action/index.js index 4fbe1823b..4fbe1823b 100644 --- a/ui/app/components/pages/first-time-flow/select-action/index.js +++ b/ui/app/pages/first-time-flow/select-action/index.js diff --git a/ui/app/components/pages/first-time-flow/select-action/index.scss b/ui/app/pages/first-time-flow/select-action/index.scss index e1b22d05b..e1b22d05b 100644 --- a/ui/app/components/pages/first-time-flow/select-action/index.scss +++ b/ui/app/pages/first-time-flow/select-action/index.scss 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/components/pages/first-time-flow/welcome/index.js b/ui/app/pages/first-time-flow/welcome/index.js index 8abeddaa1..8abeddaa1 100644 --- a/ui/app/components/pages/first-time-flow/welcome/index.js +++ b/ui/app/pages/first-time-flow/welcome/index.js diff --git a/ui/app/components/pages/first-time-flow/welcome/index.scss b/ui/app/pages/first-time-flow/welcome/index.scss index 3b5071480..3b5071480 100644 --- a/ui/app/components/pages/first-time-flow/welcome/index.scss +++ b/ui/app/pages/first-time-flow/welcome/index.scss 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/pages/home/home.component.js b/ui/app/pages/home/home.component.js new file mode 100644 index 000000000..29d93a9fa --- /dev/null +++ b/ui/app/pages/home/home.component.js @@ -0,0 +1,77 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Media from 'react-media' +import { Redirect } from 'react-router-dom' +import WalletView from '../../components/app/wallet-view' +import TransactionView from '../../components/app/transaction-view' +import ProviderApproval from '../provider-approval' + +import { + INITIALIZE_SEED_PHRASE_ROUTE, + RESTORE_VAULT_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, +} from '../../helpers/constants/routes' + +export default class Home extends PureComponent { + static propTypes = { + history: PropTypes.object, + forgottenPassword: PropTypes.bool, + seedWords: PropTypes.string, + suggestedTokens: PropTypes.object, + unconfirmedTransactionsCount: PropTypes.number, + providerRequests: PropTypes.array, + } + + componentDidMount () { + const { + history, + suggestedTokens = {}, + unconfirmedTransactionsCount = 0, + } = this.props + + // suggested new tokens + if (Object.keys(suggestedTokens).length > 0) { + history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE) + } + + if (unconfirmedTransactionsCount > 0) { + history.push(CONFIRM_TRANSACTION_ROUTE) + } + } + + render () { + const { + forgottenPassword, + seedWords, + providerRequests, + } = this.props + + // seed words + if (seedWords) { + return <Redirect to={{ pathname: INITIALIZE_SEED_PHRASE_ROUTE }}/> + } + + if (forgottenPassword) { + return <Redirect to={{ pathname: RESTORE_VAULT_ROUTE }} /> + } + + if (providerRequests && providerRequests.length > 0) { + return ( + <ProviderApproval providerRequest={providerRequests[0]} /> + ) + } + + return ( + <div className="main-container"> + <div className="account-and-transaction-details"> + <Media + query="(min-width: 576px)" + render={() => <WalletView />} + /> + <TransactionView /> + </div> + </div> + ) + } +} diff --git a/ui/app/pages/home/home.container.js b/ui/app/pages/home/home.container.js new file mode 100644 index 000000000..02ec4b9c6 --- /dev/null +++ b/ui/app/pages/home/home.container.js @@ -0,0 +1,32 @@ +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' + +const mapStateToProps = state => { + const { metamask, appState } = state + const { + noActiveNotices, + lostAccounts, + seedWords, + suggestedTokens, + providerRequests, + } = metamask + const { forgottenPassword } = appState + + return { + noActiveNotices, + lostAccounts, + forgottenPassword, + seedWords, + suggestedTokens, + unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state), + providerRequests, + } +} + +export default compose( + withRouter, + connect(mapStateToProps) +)(Home) 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/components/pages/keychains/index.scss b/ui/app/pages/keychains/index.scss index 868185419..868185419 100644 --- a/ui/app/components/pages/keychains/index.scss +++ b/ui/app/pages/keychains/index.scss diff --git a/ui/app/pages/keychains/restore-vault.js b/ui/app/pages/keychains/restore-vault.js new file mode 100644 index 000000000..574949258 --- /dev/null +++ b/ui/app/pages/keychains/restore-vault.js @@ -0,0 +1,197 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import {connect} from 'react-redux' +import { + createNewVaultAndRestore, + unMarkPasswordForgotten, +} 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 = { + warning: PropTypes.string, + createNewVaultAndRestore: PropTypes.func.isRequired, + leaveImportSeedScreenState: PropTypes.func, + history: PropTypes.object, + isLoading: PropTypes.bool, + }; + + state = { + seedPhrase: '', + password: '', + confirmPassword: '', + seedPhraseError: null, + passwordError: null, + confirmPasswordError: null, + } + + parseSeedPhrase = (seedPhrase) => { + return seedPhrase + .match(/\w+/g) + .join(' ') + } + + handleSeedPhraseChange (seedPhrase) { + let seedPhraseError = null + + if (seedPhrase && this.parseSeedPhrase(seedPhrase).split(' ').length !== 12) { + seedPhraseError = this.context.t('seedPhraseReq') + } + + this.setState({ seedPhrase, seedPhraseError }) + } + + handlePasswordChange (password) { + const { confirmPassword } = this.state + let confirmPasswordError = null + let passwordError = null + + if (password && password.length < 8) { + passwordError = this.context.t('passwordNotLongEnough') + } + + if (confirmPassword && password !== confirmPassword) { + confirmPasswordError = this.context.t('passwordsDontMatch') + } + + this.setState({ password, passwordError, confirmPasswordError }) + } + + handleConfirmPasswordChange (confirmPassword) { + const { password } = this.state + let confirmPasswordError = null + + if (password !== confirmPassword) { + confirmPasswordError = this.context.t('passwordsDontMatch') + } + + this.setState({ confirmPassword, confirmPasswordError }) + } + + onClick = () => { + const { password, seedPhrase } = this.state + const { + createNewVaultAndRestore, + leaveImportSeedScreenState, + history, + } = this.props + + leaveImportSeedScreenState() + createNewVaultAndRestore(password, this.parseSeedPhrase(seedPhrase)) + .then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Retention', + action: 'userEntersSeedPhrase', + name: 'onboardingRestoredVault', + }, + }) + history.push(DEFAULT_ROUTE) + }) + } + + hasError () { + const { passwordError, confirmPasswordError, seedPhraseError } = this.state + return passwordError || confirmPasswordError || seedPhraseError + } + + render () { + const { + seedPhrase, + password, + confirmPassword, + seedPhraseError, + passwordError, + confirmPasswordError, + } = this.state + const { t } = this.context + const { isLoading } = this.props + const disabled = !seedPhrase || !password || !confirmPassword || isLoading || this.hasError() + + return ( + <div className="first-view-main-wrapper"> + <div className="first-view-main"> + <div className="import-account"> + <a + className="import-account__back-button" + onClick={e => { + e.preventDefault() + this.props.history.goBack() + }} + href="#" + > + {`< Back`} + </a> + <div className="import-account__title"> + { this.context.t('restoreAccountWithSeed') } + </div> + <div className="import-account__selector-label"> + { this.context.t('secretPhrase') } + </div> + <div className="import-account__input-wrapper"> + <label className="import-account__input-label">Wallet Seed</label> + <textarea + className="import-account__secret-phrase" + onChange={e => this.handleSeedPhraseChange(e.target.value)} + value={this.state.seedPhrase} + placeholder={this.context.t('separateEachWord')} + /> + </div> + <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 + /> + <Button + type="first-time" + className="first-time-flow__button" + onClick={() => !disabled && this.onClick()} + disabled={disabled} + > + {this.context.t('restore')} + </Button> + </div> + </div> + </div> + ) + } +} + +export default connect( + ({ appState: { warning, isLoading } }) => ({ warning, isLoading }), + dispatch => ({ + leaveImportSeedScreenState: () => { + dispatch(unMarkPasswordForgotten()) + }, + createNewVaultAndRestore: (pw, seed) => dispatch(createNewVaultAndRestore(pw, seed)), + }) +)(RestoreVaultPage) diff --git a/ui/app/pages/keychains/reveal-seed.js b/ui/app/pages/keychains/reveal-seed.js new file mode 100644 index 000000000..edc9db5a0 --- /dev/null +++ b/ui/app/pages/keychains/reveal-seed.js @@ -0,0 +1,177 @@ +const { Component } = require('react') +const { connect } = require('react-redux') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const classnames = require('classnames') + +const { requestRevealSeedWords } = require('../../store/actions') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') +const ExportTextContainer = require('../../components/ui/export-text-container') + +import Button from '../../components/ui/button' + +const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' +const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' + +class RevealSeedPage extends Component { + constructor (props) { + super(props) + + this.state = { + screen: PASSWORD_PROMPT_SCREEN, + password: '', + seedWords: null, + error: null, + } + } + + 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.setState({ seedWords, screen: REVEAL_SEED_SCREEN })) + .catch(error => this.setState({ error: error.message })) + } + + renderWarning () { + return ( + h('.page-container__warning-container', [ + h('img.page-container__warning-icon', { + src: 'images/warning.svg', + }), + h('.page-container__warning-message', [ + h('.page-container__warning-title', [this.context.t('revealSeedWordsWarningTitle')]), + h('div', [this.context.t('revealSeedWordsWarning')]), + ]), + ]) + ) + } + + renderContent () { + return this.state.screen === PASSWORD_PROMPT_SCREEN + ? this.renderPasswordPromptContent() + : 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 { t } = this.context + + return ( + h('div', [ + h('label.reveal-seed__label', t('yourPrivateSeedPhrase')), + h(ExportTextContainer, { + text: this.state.seedWords, + filename: t('metamaskSeedWords'), + }), + ]) + ) + } + + renderFooter () { + return this.state.screen === PASSWORD_PROMPT_SCREEN + ? this.renderPasswordPromptFooter() + : this.renderRevealSeedFooter() + } + + renderPasswordPromptFooter () { + return ( + h('.page-container__footer', [ + h('header', [ + h(Button, { + type: 'default', + large: true, + className: 'page-container__footer-button', + onClick: () => this.props.history.push(DEFAULT_ROUTE), + }, this.context.t('cancel')), + h(Button, { + type: 'primary', + large: true, + className: 'page-container__footer-button', + onClick: event => this.handleSubmit(event), + disabled: this.state.password === '', + }, this.context.t('next')), + ]), + ]) + ) + } + + renderRevealSeedFooter () { + return ( + h('.page-container__footer', [ + 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('revealSeedWordsTitle')), + h('.page-container__subtitle', this.context.t('revealSeedWordsDescription')), + ]), + h('.page-container__content', [ + this.renderWarning(), + h('.reveal-seed__content', [ + this.renderContent(), + ]), + ]), + this.renderFooter(), + ]) + ) + } +} + +RevealSeedPage.propTypes = { + requestRevealSeedWords: PropTypes.func, + history: PropTypes.object, +} + +RevealSeedPage.contextTypes = { + t: PropTypes.func, +} + +const mapDispatchToProps = dispatch => { + return { + requestRevealSeedWords: password => dispatch(requestRevealSeedWords(password)), + } +} + +module.exports = connect(null, mapDispatchToProps)(RevealSeedPage) diff --git a/ui/app/components/pages/lock/index.js b/ui/app/pages/lock/index.js index 7bfe2a61f..7bfe2a61f 100644 --- a/ui/app/components/pages/lock/index.js +++ b/ui/app/pages/lock/index.js 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/pages/lock/lock.container.js b/ui/app/pages/lock/lock.container.js new file mode 100644 index 000000000..6a20b6ed1 --- /dev/null +++ b/ui/app/pages/lock/lock.container.js @@ -0,0 +1,24 @@ +import Lock from './lock.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { lockMetamask } from '../../store/actions' + +const mapStateToProps = state => { + const { metamask: { isUnlocked } } = state + + return { + isUnlocked, + } +} + +const mapDispatchToProps = dispatch => { + return { + lockMetamask: () => dispatch(lockMetamask()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(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/pages/notice/notice.js b/ui/app/pages/notice/notice.js new file mode 100644 index 000000000..d8274dfcb --- /dev/null +++ b/ui/app/pages/notice/notice.js @@ -0,0 +1,203 @@ +const { Component } = require('react') +const h = require('react-hyperscript') +const { connect } = require('react-redux') +const PropTypes = require('prop-types') +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('../../store/actions') +const { DEFAULT_ROUTE } = require('../../helpers/constants/routes') + +class Notice extends Component { + constructor (props) { + super(props) + + this.state = { + disclaimerDisabled: true, + } + } + + componentWillMount () { + if (!this.props.notice) { + this.props.history.push(DEFAULT_ROUTE) + } + } + + componentDidMount () { + // eslint-disable-next-line react/no-find-dom-node + var node = findDOMNode(this) + linker.setupListener(node) + if (document.getElementsByClassName('notice-box')[0].clientHeight < 310) { + this.setState({ disclaimerDisabled: false }) + } + } + + componentWillReceiveProps (nextProps) { + if (!nextProps.notice) { + this.props.history.push(DEFAULT_ROUTE) + } + } + + componentWillUnmount () { + // eslint-disable-next-line react/no-find-dom-node + var node = findDOMNode(this) + linker.teardownListener(node) + } + + handleAccept () { + this.setState({ disclaimerDisabled: true }) + this.props.onConfirm() + } + + render () { + const { notice = {} } = this.props + const { title, date, body } = notice + const { disclaimerDisabled } = this.state + + return ( + h('.flex-column.flex-center.flex-grow', { + style: { + width: '100%', + }, + }, [ + h('h3.flex-center.text-transform-uppercase.terms-header', { + style: { + background: '#EBEBEB', + color: '#AEAEAE', + width: '100%', + fontSize: '20px', + textAlign: 'center', + padding: 6, + }, + }, [ + title, + ]), + + h('h5.flex-center.text-transform-uppercase.terms-header', { + style: { + background: '#EBEBEB', + color: '#AEAEAE', + marginBottom: 24, + width: '100%', + fontSize: '20px', + textAlign: 'center', + padding: 6, + }, + }, [ + date, + ]), + + h('style', ` + + .markdown { + overflow-x: hidden; + } + + .markdown h1, .markdown h2, .markdown h3 { + margin: 10px 0; + font-weight: bold; + } + + .markdown strong { + font-weight: bold; + } + .markdown em { + font-style: italic; + } + + .markdown p { + margin: 10px 0; + } + + .markdown a { + color: #df6b0e; + } + + `), + + h('div.markdown', { + onScroll: (e) => { + var object = e.currentTarget + if (object.offsetHeight + object.scrollTop + 100 >= object.scrollHeight) { + this.setState({ disclaimerDisabled: false }) + } + }, + style: { + background: 'rgb(235, 235, 235)', + height: '310px', + padding: '6px', + width: '90%', + overflowY: 'scroll', + scroll: 'auto', + }, + }, [ + h(ReactMarkdown, { + className: 'notice-box', + source: body, + skipHtml: true, + }), + ]), + + h('button.primary', { + disabled: disclaimerDisabled, + onClick: () => this.handleAccept(), + style: { + marginTop: '18px', + }, + }, 'Accept'), + ]) + ) + } + +} + +const mapStateToProps = state => { + const { metamask } = state + const { noActiveNotices, nextUnreadNotice, lostAccounts } = metamask + + return { + noActiveNotices, + nextUnreadNotice, + lostAccounts, + } +} + +Notice.propTypes = { + notice: PropTypes.object, + onConfirm: PropTypes.func, + history: PropTypes.object, +} + +const mapDispatchToProps = dispatch => { + return { + markNoticeRead: nextUnreadNotice => dispatch(actions.markNoticeRead(nextUnreadNotice)), + markAccountsFound: () => dispatch(actions.markAccountsFound()), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { noActiveNotices, nextUnreadNotice, lostAccounts } = stateProps + const { markNoticeRead, markAccountsFound } = dispatchProps + + let notice + let onConfirm + + if (!noActiveNotices) { + notice = nextUnreadNotice + onConfirm = () => markNoticeRead(nextUnreadNotice) + } else if (lostAccounts && lostAccounts.length > 0) { + notice = generateLostAccountsNotice(lostAccounts) + onConfirm = () => markAccountsFound() + } + + return { + ...stateProps, + ...dispatchProps, + ...ownProps, + notice, + onConfirm, + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Notice) 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/pages/provider-approval/provider-approval.component.js b/ui/app/pages/provider-approval/provider-approval.component.js new file mode 100644 index 000000000..1f1d68da7 --- /dev/null +++ b/ui/app/pages/provider-approval/provider-approval.component.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types' +import React, { Component } from 'react' +import ProviderPageContainer from '../../components/app/provider-page-container' + +export default class ProviderApproval extends Component { + static propTypes = { + approveProviderRequest: PropTypes.func.isRequired, + providerRequest: PropTypes.object.isRequired, + rejectProviderRequest: PropTypes.func.isRequired, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + render () { + const { approveProviderRequest, providerRequest, rejectProviderRequest } = this.props + return ( + <ProviderPageContainer + approveProviderRequest={approveProviderRequest} + origin={providerRequest.origin} + tabID={providerRequest.tabID} + rejectProviderRequest={rejectProviderRequest} + siteImage={providerRequest.siteImage} + siteTitle={providerRequest.siteTitle} + /> + ) + } +} diff --git a/ui/app/pages/provider-approval/provider-approval.container.js b/ui/app/pages/provider-approval/provider-approval.container.js new file mode 100644 index 000000000..d53c0ae4d --- /dev/null +++ b/ui/app/pages/provider-approval/provider-approval.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import ProviderApproval from './provider-approval.component' +import { approveProviderRequest, rejectProviderRequest } from '../../store/actions' + +function mapDispatchToProps (dispatch) { + return { + approveProviderRequest: tabID => dispatch(approveProviderRequest(tabID)), + rejectProviderRequest: tabID => dispatch(rejectProviderRequest(tabID)), + } +} + +export default connect(null, mapDispatchToProps)(ProviderApproval) 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/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..0e8482c63 --- /dev/null +++ b/ui/app/pages/settings/index.scss @@ -0,0 +1,80 @@ +@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/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..72f7d835e 100644 --- a/ui/app/components/pages/settings/info-tab/info-tab.component.js +++ b/ui/app/pages/settings/info-tab/info-tab.component.js 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..f69c21e82 --- /dev/null +++ b/ui/app/pages/settings/settings-tab/settings-tab.component.js @@ -0,0 +1,674 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import infuraCurrencies from '../../../helpers/constants/infura-conversion.json' +import validUrl from 'valid-url' +import { exportAsFile } from '../../../helpers/utils/util' +import SimpleDropdown from '../../../components/app/dropdowns/simple-dropdown' +import ToggleButton from 'react-toggle-button' +import { REVEAL_SEED_ROUTE, MOBILE_SYNC_ROUTE } from '../../../helpers/constants/routes' +import locales from '../../../../../app/_locales/index.json' +import TextField from '../../../components/ui/text-field' +import Button from '../../../components/ui/button' + +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 = { + 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, + 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, + participateInMetaMetrics: PropTypes.bool, + setParticipateInMetaMetrics: PropTypes.func, + } + + state = { + newRpc: '', + chainId: '', + showOptions: false, + ticker: '', + 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('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> + ) + } + + renderNewRpcUrl () { + const { t } = this.context + const { newRpc, chainId, ticker, nickname } = this.state + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('newNetwork') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <TextField + type="text" + id="new-rpc" + placeholder={t('rpcURL')} + value={newRpc} + onChange={e => this.setState({ newRpc: e.target.value })} + onKeyPress={e => { + if (e.key === 'Enter') { + this.validateRpc(newRpc, chainId, ticker, nickname) + } + }} + fullWidth + margin="dense" + /> + <TextField + type="text" + id="chainid" + placeholder={t('optionalChainId')} + value={chainId} + onChange={e => this.setState({ chainId: e.target.value })} + onKeyPress={e => { + if (e.key === 'Enter') { + this.validateRpc(newRpc, chainId, ticker, nickname) + } + }} + style={{ + display: this.state.showOptions ? null : 'none', + }} + fullWidth + margin="dense" + /> + <TextField + type="text" + id="ticker" + placeholder={t('optionalSymbol')} + value={ticker} + onChange={e => this.setState({ ticker: e.target.value })} + onKeyPress={e => { + if (e.key === 'Enter') { + this.validateRpc(newRpc, chainId, ticker, nickname) + } + }} + style={{ + display: this.state.showOptions ? null : 'none', + }} + fullWidth + margin="dense" + /> + <TextField + type="text" + id="nickname" + placeholder={t('optionalNickname')} + value={nickname} + onChange={e => this.setState({ nickname: e.target.value })} + onKeyPress={e => { + if (e.key === 'Enter') { + this.validateRpc(newRpc, chainId, ticker, nickname) + } + }} + style={{ + display: this.state.showOptions ? null : 'none', + }} + fullWidth + margin="dense" + /> + <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 }) + }} + > + { t(this.state.showOptions ? 'hideAdvancedOptions' : 'showAdvancedOptions') } + </span> + <button + className="button btn-primary settings-tab__rpc-save-button" + onClick={e => { + e.preventDefault() + this.validateRpc(newRpc, chainId, ticker, nickname) + }} + > + { t('save') } + </button> + </div> + </div> + </div> + </div> + ) + } + + 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)) { + displayWarning(this.context.t('uriErrorMsg')) + } else { + displayWarning(this.context.t('invalidRPC')) + } + } + } + + 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> + ) + } + + + renderMobileSync () { + const { t } = this.context + const { history } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('syncWithMobile') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="primary" + large + onClick={event => { + event.preventDefault() + history.push(MOBILE_SYNC_ROUTE) + }} + > + { t('syncWithMobile') } + </Button> + </div> + </div> + </div> + ) + } + + + renderResetAccount () { + const { t } = this.context + const { showResetAccountConfirmationModal } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('resetAccount') }</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() + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Reset Account', + name: 'Reset Account', + }, + }) + showResetAccountConfirmationModal() + }} + > + { t('resetAccount') } + </Button> + </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> + ) + } + + renderHexDataOptIn () { + const { t } = this.context + const { sendHexData, setHexDataFeatureFlag } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('showHexData') }</span> + <div className="settings-page__content-description"> + { t('showHexDataDescription') } + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={sendHexData} + onToggle={value => setHexDataFeatureFlag(!value)} + activeLabel="" + inactiveLabel="" + /> + </div> + </div> + </div> + ) + } + + renderAdvancedGasInputInline () { + const { t } = this.context + const { advancedInlineGas, setAdvancedInlineGasFeatureFlag } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('showAdvancedGasInline') }</span> + <div className="settings-page__content-description"> + { t('showAdvancedGasInlineDescription') } + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={advancedInlineGas} + onToggle={value => setAdvancedInlineGasFeatureFlag(!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> + ) + } + + renderShowConversionInTestnets () { + const { t } = this.context + const { + showFiatInTestnets, + setShowFiatConversionOnTestnetsPreference, + } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('showFiatConversionInTestnets') }</span> + <div className="settings-page__content-description"> + { t('showFiatConversionInTestnetsDescription') } + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={showFiatInTestnets} + onToggle={value => setShowFiatConversionOnTestnetsPreference(!value)} + activeLabel="" + inactiveLabel="" + /> + </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> + ) + } + + render () { + const { warning } = this.props + + return ( + <div className="settings-page__content"> + { warning && <div className="settings-tab__error">{ warning }</div> } + { this.renderCurrentConversion() } + { this.renderUsePrimaryCurrencyOptions() } + { this.renderShowConversionInTestnets() } + { this.renderCurrentLocale() } + { this.renderNewRpcUrl() } + { this.renderStateLogs() } + { this.renderSeedWords() } + { this.renderResetAccount() } + { this.renderClearApproval() } + { this.renderPrivacyOptIn() } + { this.renderHexDataOptIn() } + { this.renderAdvancedGasInputInline() } + { this.renderBlockieOptIn() } + { this.renderMobileSync() } + { this.renderMetaMetricsOptIn() } + </div> + ) + } +} diff --git a/ui/app/pages/settings/settings-tab/settings-tab.container.js b/ui/app/pages/settings/settings-tab/settings-tab.container.js new file mode 100644 index 000000000..3ae4985d7 --- /dev/null +++ b/ui/app/pages/settings/settings-tab/settings-tab.container.js @@ -0,0 +1,81 @@ +import SettingsTab from './settings-tab.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { + setCurrentCurrency, + updateAndSetCustomRpc, + displayWarning, + revealSeedConfirmation, + setUseBlockie, + updateCurrentLocale, + setFeatureFlag, + showModal, + setUseNativeCurrencyAsPrimaryCurrencyPreference, + setShowFiatConversionOnTestnetsPreference, + setParticipateInMetaMetrics, +} from '../../../store/actions' +import { preferencesSelector } from '../../../selectors/selectors' + +const mapStateToProps = state => { + const { appState: { warning }, metamask } = state + const { + currentCurrency, + conversionDate, + nativeCurrency, + useBlockie, + featureFlags: { + sendHexData, + privacyMode, + advancedInlineGas, + } = {}, + provider = {}, + currentLocale, + participateInMetaMetrics, + } = metamask + const { useNativeCurrencyAsPrimaryCurrency, showFiatInTestnets } = preferencesSelector(state) + + return { + warning, + currentLocale, + currentCurrency, + conversionDate, + nativeCurrency, + useBlockie, + sendHexData, + advancedInlineGas, + privacyMode, + provider, + useNativeCurrencyAsPrimaryCurrency, + showFiatInTestnets, + participateInMetaMetrics, + } +} + +const mapDispatchToProps = dispatch => { + return { + setCurrentCurrency: currency => dispatch(setCurrentCurrency(currency)), + setRpcTarget: (newRpc, chainId, ticker, nickname) => dispatch(updateAndSetCustomRpc(newRpc, chainId, ticker, nickname)), + displayWarning: warning => dispatch(displayWarning(warning)), + revealSeedConfirmation: () => dispatch(revealSeedConfirmation()), + setUseBlockie: value => dispatch(setUseBlockie(value)), + updateCurrentLocale: key => dispatch(updateCurrentLocale(key)), + setHexDataFeatureFlag: shouldShow => dispatch(setFeatureFlag('sendHexData', shouldShow)), + setAdvancedInlineGasFeatureFlag: shouldShow => dispatch(setFeatureFlag('advancedInlineGas', shouldShow)), + setPrivacyMode: enabled => dispatch(setFeatureFlag('privacyMode', enabled)), + showResetAccountConfirmationModal: () => dispatch(showModal({ name: 'CONFIRM_RESET_ACCOUNT' })), + setUseNativeCurrencyAsPrimaryCurrencyPreference: value => { + return dispatch(setUseNativeCurrencyAsPrimaryCurrencyPreference(value)) + }, + setShowFiatConversionOnTestnetsPreference: value => { + return dispatch(setShowFiatConversionOnTestnetsPreference(value)) + }, + showClearApprovalModal: () => dispatch(showModal({ name: 'CLEAR_APPROVED_ORIGINS' })), + setParticipateInMetaMetrics: (val) => dispatch(setParticipateInMetaMetrics(val)), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(SettingsTab) diff --git a/ui/app/pages/settings/settings.component.js b/ui/app/pages/settings/settings.component.js new file mode 100644 index 000000000..d67d3fcfe --- /dev/null +++ b/ui/app/pages/settings/settings.component.js @@ -0,0 +1,54 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route, matchPath } from 'react-router-dom' +import TabBar from '../../components/app/tab-bar' +import SettingsTab from './settings-tab' +import InfoTab from './info-tab' +import { DEFAULT_ROUTE, SETTINGS_ROUTE, INFO_ROUTE } from '../../helpers/constants/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/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 3d44bd037..3d44bd037 100644 --- a/ui/app/components/pages/unlock-page/index.scss +++ b/ui/app/pages/unlock-page/index.scss diff --git a/ui/app/pages/unlock-page/unlock-page.component.js b/ui/app/pages/unlock-page/unlock-page.component.js new file mode 100644 index 000000000..3aeb2a59b --- /dev/null +++ b/ui/app/pages/unlock-page/unlock-page.component.js @@ -0,0 +1,191 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import Button from '@material-ui/core/Button' +import TextField from '../../components/ui/text-field' +import getCaretCoordinates from 'textarea-caret' +import { EventEmitter } from 'events' +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 = { + history: PropTypes.object, + isUnlocked: PropTypes.bool, + onImport: PropTypes.func, + onRestore: PropTypes.func, + onSubmit: PropTypes.func, + forceUpdateMetamaskState: PropTypes.func, + showOptInModal: PropTypes.func, + } + + constructor (props) { + super(props) + + this.state = { + password: '', + error: null, + } + + this.submitting = false + this.animationEventEmitter = new EventEmitter() + } + + componentWillMount () { + const { isUnlocked, history } = this.props + + if (isUnlocked) { + history.push(DEFAULT_ROUTE) + } + } + + handleSubmit = async event => { + event.preventDefault() + event.stopPropagation() + + const { password } = this.state + const { onSubmit, forceUpdateMetamaskState, showOptInModal } = this.props + + if (password === '' || this.submitting) { + return + } + + this.setState({ error: null }) + this.submitting = true + + try { + 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 + } + } + + handleInputChange ({ target }) { + this.setState({ password: target.value, error: null }) + + // tell mascot to look at page action + const element = target + const boundingRect = element.getBoundingClientRect() + const coordinates = getCaretCoordinates(element, element.selectionEnd) + this.animationEventEmitter.emit('point', { + x: boundingRect.left + coordinates.left - element.scrollLeft, + y: boundingRect.top + coordinates.top - element.scrollTop, + }) + } + + renderSubmitButton () { + const style = { + backgroundColor: '#f7861c', + color: 'white', + marginTop: '20px', + height: '60px', + fontWeight: '400', + boxShadow: 'none', + borderRadius: '4px', + } + + return ( + <Button + type="submit" + style={style} + disabled={!this.state.password} + fullWidth + variant="raised" + size="large" + onClick={this.handleSubmit} + disableRipple + > + { this.context.t('login') } + </Button> + ) + } + + render () { + const { password, error } = this.state + const { t } = this.context + const { onImport, onRestore } = this.props + + return ( + <div className="unlock-page__container"> + <div className="unlock-page"> + <div className="unlock-page__mascot-container"> + <Mascot + animationEventEmitter={this.animationEventEmitter} + width="120" + height="120" + /> + </div> + <h1 className="unlock-page__title"> + { t('welcomeBack') } + </h1> + <div>{ t('unlockMessage') }</div> + <form + className="unlock-page__form" + onSubmit={this.handleSubmit} + > + <TextField + id="password" + label={t('password')} + type="password" + value={password} + onChange={event => this.handleInputChange(event)} + error={error} + autoFocus + autoComplete="current-password" + material + fullWidth + /> + </form> + { this.renderSubmitButton() } + <div className="unlock-page__links"> + <div + className="unlock-page__link" + onClick={() => onRestore()} + > + { t('restoreFromSeed') } + </div> + <div + className="unlock-page__link unlock-page__link--import" + onClick={() => onImport()} + > + { t('importUsingSeed') } + </div> + </div> + </div> + </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..bd43666fc --- /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() + } + } + + 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/reducers.js b/ui/app/reducers.js deleted file mode 100644 index 786af853d..000000000 --- a/ui/app/reducers.js +++ /dev/null @@ -1,95 +0,0 @@ -const clone = require('clone') -const extend = require('xtend') -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' -import reduceGas from './ducks/gas.duck' - -window.METAMASK_CACHED_LOG_STATE = null - -module.exports = rootReducer - -function rootReducer (state, action) { - // clone - state = extend(state) - - if (action.type === 'GLOBAL_FORCE_UPDATE') { - return action.value - } - - // - // MetaMask - // - - state.metamask = reduceMetamask(state, action) - - // - // AppState - // - - state.appState = reduceApp(state, action) - - // - // LocaleMessages - // - - state.localeMessages = reduceLocale(state, action) - - // - // Send - // - - state.send = reduceSend(state, action) - - state.confirmTransaction = reduceConfirmTransaction(state, action) - - state.gas = reduceGas(state, action) - - window.METAMASK_CACHED_LOG_STATE = state - return state -} - -window.getCleanAppState = function () { - const state = clone(window.METAMASK_CACHED_LOG_STATE) - // append additional information - state.version = global.platform.getVersion() - state.browser = window.navigator.userAgent - // ensure seedWords are not included - if (state.metamask) delete state.metamask.seedWords - if (state.appState.currentView) delete state.appState.currentView.seedWords - return state -} - -window.logStateString = function (cb) { - const state = window.getCleanAppState() - global.platform.getPlatformInfo((err, platform) => { - if (err) return cb(err) - state.platform = platform - const stateString = JSON.stringify(state, removeSeedWords, 2) - cb(null, stateString) - }) -} - -window.logState = function (toClipboard) { - return window.logStateString((err, result) => { - if (err) { - console.error(err.message) - } else if (toClipboard) { - copyToClipboard(result) - console.log('State log copied') - } else { - console.log(result) - } - }) -} - -function removeSeedWords (key, value) { - return key === 'seedWords' ? undefined : value -} diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js deleted file mode 100644 index 22cfe7f8d..000000000 --- a/ui/app/reducers/app.js +++ /dev/null @@ -1,788 +0,0 @@ -const extend = require('xtend') -const actions = require('../actions') -const txHelper = require('../../lib/tx-helper') -const log = require('loglevel') - -module.exports = reduceApp - - -function reduceApp (state, action) { - log.debug('App Reducer got ' + action.type) - // clone and defaults - const selectedAddress = state.metamask.selectedAddress - const hasUnconfActions = checkUnconfActions(state) - let name = 'accounts' - if (selectedAddress) { - name = 'accountDetail' - } - - if (hasUnconfActions) { - log.debug('pending txs detected, defaulting to conf-tx view.') - name = 'confTx' - } - - var defaultView = { - name, - detailView: null, - context: selectedAddress, - } - - // confirm seed words - var seedWords = state.metamask.seedWords - var seedConfView = { - name: 'createVaultComplete', - seedWords, - } - - // default state - var appState = extend({ - shouldClose: false, - menuOpen: false, - modal: { - open: false, - modalState: { - name: null, - props: {}, - }, - previousModalState: { - name: null, - }, - }, - sidebar: { - isOpen: false, - transitionName: '', - type: '', - props: {}, - }, - alertOpen: false, - alertMessage: null, - qrCodeData: null, - networkDropdownOpen: false, - currentView: seedWords ? seedConfView : defaultView, - accountDetail: { - subview: 'transactions', - }, - // Used to render transition direction - transForward: true, - // Used to display loading indicator - isLoading: false, - // Used to display error text - warning: null, - buyView: {}, - isMouseUser: false, - gasIsLoading: false, - networkNonce: null, - defaultHdPaths: { - trezor: `m/44'/60'/0'/0`, - ledger: `m/44'/60'/0'/0/0`, - }, - lastSelectedProvider: null, - }, state.appState) - - switch (action.type) { - // dropdown methods - case actions.NETWORK_DROPDOWN_OPEN: - return extend(appState, { - networkDropdownOpen: true, - }) - - case actions.NETWORK_DROPDOWN_CLOSE: - return extend(appState, { - networkDropdownOpen: false, - }) - - // sidebar methods - case actions.SIDEBAR_OPEN: - return extend(appState, { - sidebar: { - ...action.value, - isOpen: true, - }, - }) - - case actions.SIDEBAR_CLOSE: - return extend(appState, { - sidebar: { - ...appState.sidebar, - isOpen: false, - }, - }) - - // alert methods - case actions.ALERT_OPEN: - return extend(appState, { - alertOpen: true, - alertMessage: action.value, - }) - - case actions.ALERT_CLOSE: - return extend(appState, { - alertOpen: false, - alertMessage: null, - }) - - // qr scanner methods - case actions.QR_CODE_DETECTED: - return extend(appState, { - qrCodeData: action.value, - }) - - - // modal methods: - case actions.MODAL_OPEN: - const { name, ...modalProps } = action.payload - - return extend(appState, { - modal: { - open: true, - modalState: { - name: name, - props: { ...modalProps }, - }, - previousModalState: { ...appState.modal.modalState }, - }, - }) - - case actions.MODAL_CLOSE: - return extend(appState, { - modal: Object.assign( - state.appState.modal, - { open: false }, - { modalState: { name: null, props: {} } }, - { previousModalState: appState.modal.modalState}, - ), - }) - - // transition methods - case actions.TRANSITION_FORWARD: - return extend(appState, { - transForward: true, - }) - - case actions.TRANSITION_BACKWARD: - return extend(appState, { - transForward: false, - }) - - // intialize - - case actions.SHOW_CREATE_VAULT: - return extend(appState, { - currentView: { - name: 'createVault', - }, - transForward: true, - warning: null, - }) - - case actions.SHOW_RESTORE_VAULT: - return extend(appState, { - currentView: { - name: 'restoreVault', - }, - transForward: true, - forgottenPassword: true, - }) - - case actions.FORGOT_PASSWORD: - const newState = extend(appState, { - forgottenPassword: action.value, - }) - - if (action.value) { - newState.currentView = { - name: 'restoreVault', - } - } - - return newState - - case actions.SHOW_INIT_MENU: - return extend(appState, { - currentView: defaultView, - transForward: false, - }) - - case actions.SHOW_CONFIG_PAGE: - return extend(appState, { - currentView: { - name: 'config', - context: appState.currentView.context, - }, - transForward: action.value, - }) - - case actions.SHOW_ADD_TOKEN_PAGE: - return extend(appState, { - currentView: { - name: 'add-token', - context: appState.currentView.context, - }, - transForward: action.value, - }) - - case actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE: - return extend(appState, { - currentView: { - name: 'add-suggested-token', - context: appState.currentView.context, - }, - transForward: action.value, - }) - - case actions.SHOW_IMPORT_PAGE: - return extend(appState, { - currentView: { - name: 'import-menu', - }, - transForward: true, - warning: null, - }) - - case actions.SHOW_NEW_ACCOUNT_PAGE: - return extend(appState, { - currentView: { - name: 'new-account-page', - context: action.formToSelect, - }, - transForward: true, - warning: null, - }) - - case actions.SET_NEW_ACCOUNT_FORM: - return extend(appState, { - currentView: { - name: appState.currentView.name, - context: action.formToSelect, - }, - }) - - case actions.SHOW_INFO_PAGE: - return extend(appState, { - currentView: { - name: 'info', - context: appState.currentView.context, - }, - transForward: true, - }) - - case actions.CREATE_NEW_VAULT_IN_PROGRESS: - return extend(appState, { - currentView: { - name: 'createVault', - inProgress: true, - }, - transForward: true, - isLoading: true, - }) - - case actions.SHOW_NEW_VAULT_SEED: - return extend(appState, { - currentView: { - name: 'createVaultComplete', - seedWords: action.value, - }, - transForward: true, - isLoading: false, - }) - - case actions.NEW_ACCOUNT_SCREEN: - return extend(appState, { - currentView: { - name: 'new-account', - context: appState.currentView.context, - }, - transForward: true, - }) - - case actions.SHOW_SEND_PAGE: - return extend(appState, { - currentView: { - name: 'sendTransaction', - context: appState.currentView.context, - }, - transForward: true, - warning: null, - }) - - case actions.SHOW_SEND_TOKEN_PAGE: - return extend(appState, { - currentView: { - name: 'sendToken', - context: appState.currentView.context, - }, - transForward: true, - warning: null, - }) - - case actions.SHOW_NEW_KEYCHAIN: - return extend(appState, { - currentView: { - name: 'newKeychain', - context: appState.currentView.context, - }, - transForward: true, - }) - - // unlock - - case actions.UNLOCK_METAMASK: - return extend(appState, { - forgottenPassword: appState.forgottenPassword ? !appState.forgottenPassword : null, - detailView: {}, - transForward: true, - isLoading: false, - warning: null, - }) - - case actions.LOCK_METAMASK: - return extend(appState, { - currentView: defaultView, - transForward: false, - warning: null, - }) - - case actions.BACK_TO_INIT_MENU: - return extend(appState, { - warning: null, - transForward: false, - forgottenPassword: true, - currentView: { - name: 'InitMenu', - }, - }) - - case actions.BACK_TO_UNLOCK_VIEW: - return extend(appState, { - warning: null, - transForward: true, - forgottenPassword: false, - currentView: { - name: 'UnlockScreen', - }, - }) - // reveal seed words - - case actions.REVEAL_SEED_CONFIRMATION: - return extend(appState, { - currentView: { - name: 'reveal-seed-conf', - }, - transForward: true, - warning: null, - }) - - // accounts - - case actions.SET_SELECTED_ACCOUNT: - return extend(appState, { - activeAddress: action.value, - }) - - case actions.GO_HOME: - return extend(appState, { - currentView: extend(appState.currentView, { - name: 'accountDetail', - }), - accountDetail: { - subview: 'transactions', - accountExport: 'none', - privateKey: '', - }, - transForward: false, - warning: null, - }) - - case actions.SHOW_ACCOUNT_DETAIL: - return extend(appState, { - forgottenPassword: appState.forgottenPassword ? !appState.forgottenPassword : null, - currentView: { - name: 'accountDetail', - context: action.value, - }, - accountDetail: { - subview: 'transactions', - accountExport: 'none', - privateKey: '', - }, - transForward: false, - }) - - case actions.BACK_TO_ACCOUNT_DETAIL: - return extend(appState, { - currentView: { - name: 'accountDetail', - context: action.value, - }, - accountDetail: { - subview: 'transactions', - accountExport: 'none', - privateKey: '', - }, - transForward: false, - }) - - case actions.SHOW_ACCOUNTS_PAGE: - return extend(appState, { - currentView: { - name: seedWords ? 'createVaultComplete' : 'accounts', - seedWords, - }, - transForward: true, - isLoading: false, - warning: null, - scrollToBottom: false, - forgottenPassword: false, - }) - - case actions.SHOW_NOTICE: - return extend(appState, { - transForward: true, - isLoading: false, - }) - - case actions.REVEAL_ACCOUNT: - return extend(appState, { - scrollToBottom: true, - }) - - case actions.SHOW_CONF_TX_PAGE: - return extend(appState, { - currentView: { - name: 'confTx', - context: action.id ? indexForPending(state, action.id) : 0, - }, - transForward: action.transForward, - warning: null, - isLoading: false, - }) - - case actions.SHOW_CONF_MSG_PAGE: - return extend(appState, { - currentView: { - name: hasUnconfActions ? 'confTx' : 'account-detail', - context: 0, - }, - transForward: true, - warning: null, - isLoading: false, - }) - - case actions.COMPLETED_TX: - log.debug('reducing COMPLETED_TX for tx ' + action.value) - const otherUnconfActions = getUnconfActionList(state) - .filter(tx => tx.id !== action.value) - const hasOtherUnconfActions = otherUnconfActions.length > 0 - - if (hasOtherUnconfActions) { - log.debug('reducer detected txs - rendering confTx view') - return extend(appState, { - transForward: false, - currentView: { - name: 'confTx', - context: 0, - }, - warning: null, - }) - } else { - log.debug('attempting to close popup') - return extend(appState, { - // indicate notification should close - shouldClose: true, - transForward: false, - warning: null, - currentView: { - name: 'accountDetail', - context: state.metamask.selectedAddress, - }, - accountDetail: { - subview: 'transactions', - }, - }) - } - - case actions.NEXT_TX: - return extend(appState, { - transForward: true, - currentView: { - name: 'confTx', - context: ++appState.currentView.context, - warning: null, - }, - }) - - case actions.VIEW_PENDING_TX: - const context = indexForPending(state, action.value) - return extend(appState, { - transForward: true, - currentView: { - name: 'confTx', - context, - warning: null, - }, - }) - - case actions.PREVIOUS_TX: - return extend(appState, { - transForward: false, - currentView: { - name: 'confTx', - context: --appState.currentView.context, - warning: null, - }, - }) - - case actions.TRANSACTION_ERROR: - return extend(appState, { - currentView: { - name: 'confTx', - errorMessage: 'There was a problem submitting this transaction.', - }, - }) - - case actions.UNLOCK_FAILED: - return extend(appState, { - warning: action.value || 'Incorrect password. Try again.', - }) - - case actions.UNLOCK_SUCCEEDED: - return extend(appState, { - warning: '', - }) - - case actions.SET_HARDWARE_WALLET_DEFAULT_HD_PATH: - const { device, path } = action.value - const newDefaults = {...appState.defaultHdPaths} - newDefaults[device] = path - - return extend(appState, { - defaultHdPaths: newDefaults, - }) - - case actions.SHOW_LOADING: - return extend(appState, { - isLoading: true, - loadingMessage: action.value, - }) - - case actions.HIDE_LOADING: - return extend(appState, { - isLoading: false, - }) - - case actions.SHOW_SUB_LOADING_INDICATION: - return extend(appState, { - isSubLoading: true, - }) - - case actions.HIDE_SUB_LOADING_INDICATION: - return extend(appState, { - isSubLoading: false, - }) - case actions.CLEAR_SEED_WORD_CACHE: - return extend(appState, { - transForward: true, - currentView: {}, - isLoading: false, - accountDetail: { - subview: 'transactions', - accountExport: 'none', - privateKey: '', - }, - }) - - case actions.DISPLAY_WARNING: - return extend(appState, { - warning: action.value, - isLoading: false, - }) - - case actions.HIDE_WARNING: - return extend(appState, { - warning: undefined, - }) - - case actions.REQUEST_ACCOUNT_EXPORT: - return extend(appState, { - transForward: true, - currentView: { - name: 'accountDetail', - context: appState.currentView.context, - }, - accountDetail: { - subview: 'export', - accountExport: 'requested', - }, - }) - - case actions.EXPORT_ACCOUNT: - return extend(appState, { - accountDetail: { - subview: 'export', - accountExport: 'completed', - }, - }) - - case actions.SHOW_PRIVATE_KEY: - return extend(appState, { - accountDetail: { - subview: 'export', - accountExport: 'completed', - privateKey: action.value, - }, - }) - - case actions.BUY_ETH_VIEW: - return extend(appState, { - transForward: true, - currentView: { - name: 'buyEth', - context: appState.currentView.name, - }, - identity: state.metamask.identities[action.value], - buyView: { - subview: 'Coinbase', - amount: '15.00', - buyAddress: action.value, - formView: { - coinbase: true, - shapeshift: false, - }, - }, - }) - - case actions.ONBOARDING_BUY_ETH_VIEW: - return extend(appState, { - transForward: true, - currentView: { - name: 'onboardingBuyEth', - context: appState.currentView.name, - }, - identity: state.metamask.identities[action.value], - }) - - case actions.COINBASE_SUBVIEW: - return extend(appState, { - buyView: { - subview: 'Coinbase', - formView: { - coinbase: true, - shapeshift: false, - }, - buyAddress: appState.buyView.buyAddress, - amount: appState.buyView.amount, - }, - }) - - case actions.SHAPESHIFT_SUBVIEW: - return extend(appState, { - buyView: { - subview: 'ShapeShift', - formView: { - coinbase: false, - shapeshift: true, - marketinfo: action.value.marketinfo, - coinOptions: action.value.coinOptions, - }, - buyAddress: action.value.buyAddress || appState.buyView.buyAddress, - amount: appState.buyView.amount || 0, - }, - }) - - case actions.PAIR_UPDATE: - return extend(appState, { - buyView: { - subview: 'ShapeShift', - formView: { - coinbase: false, - shapeshift: true, - marketinfo: action.value.marketinfo, - coinOptions: appState.buyView.formView.coinOptions, - }, - buyAddress: appState.buyView.buyAddress, - amount: appState.buyView.amount, - warning: null, - }, - }) - - case actions.SHOW_QR: - return extend(appState, { - qrRequested: true, - transForward: true, - - Qr: { - message: action.value.message, - data: action.value.data, - }, - }) - - case actions.SHOW_QR_VIEW: - return extend(appState, { - currentView: { - name: 'qr', - context: appState.currentView.context, - }, - transForward: true, - Qr: { - message: action.value.message, - data: action.value.data, - }, - }) - - case actions.SET_MOUSE_USER_STATE: - return extend(appState, { - isMouseUser: action.value, - }) - - case actions.GAS_LOADING_STARTED: - return extend(appState, { - gasIsLoading: true, - }) - - case actions.GAS_LOADING_FINISHED: - return extend(appState, { - gasIsLoading: false, - }) - - case actions.SET_NETWORK_NONCE: - return extend(appState, { - networkNonce: action.value, - }) - - case actions.SET_PREVIOUS_PROVIDER: - if (action.value === 'loading') { - return appState - } - return extend(appState, { - lastSelectedProvider: action.value, - }) - - default: - return appState - } -} - -function checkUnconfActions (state) { - const unconfActionList = getUnconfActionList(state) - const hasUnconfActions = unconfActionList.length > 0 - return hasUnconfActions -} - -function getUnconfActionList (state) { - const { unapprovedTxs, unapprovedMsgs, - unapprovedPersonalMsgs, unapprovedTypedMessages, network } = state.metamask - - const unconfActionList = txHelper(unapprovedTxs, unapprovedMsgs, unapprovedPersonalMsgs, unapprovedTypedMessages, network) - return unconfActionList -} - -function indexForPending (state, txId) { - const unconfTxList = getUnconfActionList(state) - const match = unconfTxList.find((tx) => tx.id === txId) - const index = unconfTxList.indexOf(match) - return index -} - -// function indexForLastPending (state) { -// return getUnconfActionList(state).length -// } diff --git a/ui/app/reducers/locale.js b/ui/app/reducers/locale.js deleted file mode 100644 index bdd97acb4..000000000 --- a/ui/app/reducers/locale.js +++ /dev/null @@ -1,17 +0,0 @@ -const extend = require('xtend') -const actions = require('../actions') - -module.exports = reduceMetamask - -function reduceMetamask (state, action) { - const localeMessagesState = extend({}, state.localeMessages) - - switch (action.type) { - case actions.SET_LOCALE_MESSAGES: - return extend(localeMessagesState, { - current: action.value, - }) - default: - return localeMessagesState - } -} diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js deleted file mode 100644 index d4b920748..000000000 --- a/ui/app/reducers/metamask.js +++ /dev/null @@ -1,419 +0,0 @@ -const extend = require('xtend') -const actions = require('../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 - -function reduceMetamask (state, action) { - let newState - - // clone + defaults - var metamaskState = extend({ - isInitialized: false, - isUnlocked: false, - isAccountMenuOpen: false, - isPopup: getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP, - rpcTarget: 'https://rawtestrpc.metamask.io/', - identities: {}, - unapprovedTxs: {}, - noActiveNotices: true, - nextUnreadNotice: undefined, - frequentRpcList: [], - addressBook: [], - selectedTokenAddress: null, - contractExchangeRates: {}, - tokenExchangeRates: {}, - tokens: [], - pendingTokens: {}, - send: { - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - forceGasMin: null, - toNickname: '', - }, - coinOptions: {}, - useBlockie: false, - featureFlags: {}, - networkEndpointType: OLD_UI_NETWORK_TYPE, - isRevealingSeedWords: false, - welcomeScreenSeen: false, - currentLocale: '', - preferences: { - useNativeCurrencyAsPrimaryCurrency: true, - showFiatInTestnets: false, - }, - firstTimeFlowType: null, - completedOnboarding: false, - knownMethodData: {}, - participateInMetaMetrics: null, - metaMetricsSendCount: 0, - }, state.metamask) - - switch (action.type) { - - case actions.SHOW_ACCOUNTS_PAGE: - newState = extend(metamaskState, { - isRevealingSeedWords: false, - }) - delete newState.seedWords - return newState - - case actions.SHOW_NOTICE: - return extend(metamaskState, { - noActiveNotices: false, - nextUnreadNotice: action.value, - }) - - case actions.CLEAR_NOTICES: - return extend(metamaskState, { - noActiveNotices: true, - nextUnreadNotice: undefined, - }) - - case actions.UPDATE_METAMASK_STATE: - return extend(metamaskState, action.value) - - case actions.UNLOCK_METAMASK: - return extend(metamaskState, { - isUnlocked: true, - isInitialized: true, - selectedAddress: action.value, - }) - - case actions.LOCK_METAMASK: - return extend(metamaskState, { - isUnlocked: false, - }) - - case actions.SET_RPC_LIST: - return extend(metamaskState, { - frequentRpcList: action.value, - }) - - case actions.SET_RPC_TARGET: - return extend(metamaskState, { - provider: { - type: 'rpc', - rpcTarget: action.value, - }, - }) - - case actions.SET_PROVIDER_TYPE: - return extend(metamaskState, { - provider: { - type: action.value, - }, - }) - - case actions.COMPLETED_TX: - var stringId = String(action.id) - newState = extend(metamaskState, { - unapprovedTxs: {}, - unapprovedMsgs: {}, - }) - for (const id in metamaskState.unapprovedTxs) { - if (id !== stringId) { - newState.unapprovedTxs[id] = metamaskState.unapprovedTxs[id] - } - } - for (const id in metamaskState.unapprovedMsgs) { - if (id !== stringId) { - newState.unapprovedMsgs[id] = metamaskState.unapprovedMsgs[id] - } - } - return newState - - case actions.EDIT_TX: - return extend(metamaskState, { - send: { - ...metamaskState.send, - editingTransactionId: action.value, - }, - }) - - - case actions.SHOW_NEW_VAULT_SEED: - return extend(metamaskState, { - isRevealingSeedWords: true, - seedWords: action.value, - }) - - case actions.CLEAR_SEED_WORD_CACHE: - newState = extend(metamaskState, { - isUnlocked: true, - isInitialized: true, - selectedAddress: action.value, - }) - delete newState.seedWords - return newState - - case actions.SHOW_ACCOUNT_DETAIL: - newState = extend(metamaskState, { - isUnlocked: true, - isInitialized: true, - selectedAddress: action.value, - }) - delete newState.seedWords - return newState - - case actions.SET_SELECTED_TOKEN: - return extend(metamaskState, { - selectedTokenAddress: action.value, - }) - - case actions.SET_ACCOUNT_LABEL: - const account = action.value.account - const name = action.value.label - const id = {} - id[account] = extend(metamaskState.identities[account], { name }) - const identities = extend(metamaskState.identities, id) - return extend(metamaskState, { identities }) - - case actions.SET_CURRENT_FIAT: - return extend(metamaskState, { - currentCurrency: action.value.currentCurrency, - conversionRate: action.value.conversionRate, - conversionDate: action.value.conversionDate, - }) - - case actions.UPDATE_TOKENS: - return extend(metamaskState, { - tokens: action.newTokens, - }) - - // metamask.send - case actions.UPDATE_GAS_LIMIT: - return extend(metamaskState, { - send: { - ...metamaskState.send, - gasLimit: action.value, - }, - }) - - case actions.UPDATE_GAS_PRICE: - return extend(metamaskState, { - send: { - ...metamaskState.send, - gasPrice: action.value, - }, - }) - - case actions.TOGGLE_ACCOUNT_MENU: - return extend(metamaskState, { - isAccountMenuOpen: !metamaskState.isAccountMenuOpen, - }) - - case actions.UPDATE_GAS_TOTAL: - return extend(metamaskState, { - send: { - ...metamaskState.send, - gasTotal: action.value, - }, - }) - - case actions.UPDATE_SEND_TOKEN_BALANCE: - return extend(metamaskState, { - send: { - ...metamaskState.send, - tokenBalance: action.value, - }, - }) - - case actions.UPDATE_SEND_HEX_DATA: - return extend(metamaskState, { - send: { - ...metamaskState.send, - data: action.value, - }, - }) - - case actions.UPDATE_SEND_FROM: - return extend(metamaskState, { - send: { - ...metamaskState.send, - from: action.value, - }, - }) - - case actions.UPDATE_SEND_TO: - return extend(metamaskState, { - send: { - ...metamaskState.send, - to: action.value.to, - toNickname: action.value.nickname, - }, - }) - - case actions.UPDATE_SEND_AMOUNT: - return extend(metamaskState, { - send: { - ...metamaskState.send, - amount: action.value, - }, - }) - - case actions.UPDATE_SEND_MEMO: - return extend(metamaskState, { - send: { - ...metamaskState.send, - memo: action.value, - }, - }) - - case actions.UPDATE_MAX_MODE: - return extend(metamaskState, { - send: { - ...metamaskState.send, - maxModeOn: action.value, - }, - }) - - case actions.UPDATE_SEND: - return extend(metamaskState, { - send: { - ...metamaskState.send, - ...action.value, - }, - }) - - case actions.CLEAR_SEND: - return extend(metamaskState, { - send: { - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: null, - from: '', - to: '', - amount: '0x0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - forceGasMin: null, - toNickname: '', - }, - }) - - case actions.UPDATE_TRANSACTION_PARAMS: - const { id: txId, value } = action - let { selectedAddressTxList } = metamaskState - selectedAddressTxList = selectedAddressTxList.map(tx => { - if (tx.id === txId) { - tx.txParams = value - } - return tx - }) - - return extend(metamaskState, { - selectedAddressTxList, - }) - - case actions.PAIR_UPDATE: - const { value: { marketinfo: pairMarketInfo } } = action - return extend(metamaskState, { - tokenExchangeRates: { - ...metamaskState.tokenExchangeRates, - [pairMarketInfo.pair]: pairMarketInfo, - }, - }) - - case actions.SHAPESHIFT_SUBVIEW: - const { value: { marketinfo: ssMarketInfo, coinOptions } } = action - return extend(metamaskState, { - tokenExchangeRates: { - ...metamaskState.tokenExchangeRates, - [ssMarketInfo.pair]: ssMarketInfo, - }, - 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, - }) - - case actions.UPDATE_FEATURE_FLAGS: - return extend(metamaskState, { - featureFlags: action.value, - }) - - case actions.UPDATE_NETWORK_ENDPOINT_TYPE: - return extend(metamaskState, { - networkEndpointType: action.value, - }) - - case actions.CLOSE_WELCOME_SCREEN: - return extend(metamaskState, { - welcomeScreenSeen: true, - }) - - case actions.SET_CURRENT_LOCALE: - return extend(metamaskState, { - currentLocale: action.value, - }) - - case actions.SET_PENDING_TOKENS: - return extend(metamaskState, { - pendingTokens: { ...action.payload }, - }) - - case actions.CLEAR_PENDING_TOKENS: { - return extend(metamaskState, { - pendingTokens: {}, - }) - } - - case actions.UPDATE_PREFERENCES: { - return extend(metamaskState, { - 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, - }) - } - - default: - return metamaskState - - } -} diff --git a/ui/app/root.js b/ui/app/root.js deleted file mode 100644 index c95c56581..000000000 --- a/ui/app/root.js +++ /dev/null @@ -1,34 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const { Provider } = require('react-redux') -const h = require('react-hyperscript') -const { HashRouter } = require('react-router-dom') -const App = require('./app') -const I18nProvider = require('./i18n-provider') -const MetaMetricsProvider = require('./metametrics/metametrics.provider') - -class Root extends Component { - render () { - const { store } = this.props - - return ( - h(Provider, { store }, [ - h(HashRouter, { - hashType: 'noslash', - }, [ - h(MetaMetricsProvider, [ - h(I18nProvider, [ - h(App), - ]), - ]), - ]), - ]) - ) - } -} - -Root.propTypes = { - store: PropTypes.object, -} - -module.exports = Root diff --git a/ui/app/selectors.js b/ui/app/selectors.js deleted file mode 100644 index 663c3f12b..000000000 --- a/ui/app/selectors.js +++ /dev/null @@ -1,301 +0,0 @@ -import {NETWORK_TYPES} from './constants/common' -import { stripHexPrefix } from 'ethereumjs-util' - -const abi = require('human-standard-token-abi') -import { - transactionsSelector, -} from './selectors/transactions' -const { - multiplyCurrencies, -} = require('./conversion-util') - -const selectors = { - getSelectedAddress, - getSelectedIdentity, - getSelectedAccount, - getSelectedToken, - getSelectedTokenExchangeRate, - getSelectedTokenAssetImage, - getAssetImages, - getTokenExchangeRate, - conversionRateSelector, - transactionsSelector, - accountsWithSendEtherInfoSelector, - getCurrentAccountWithSendEtherInfo, - getGasIsLoading, - getForceGasMin, - getAddressBook, - getSendFrom, - getCurrentCurrency, - getNativeCurrency, - getSendAmount, - getSelectedTokenToFiatRate, - getSelectedTokenContract, - getSendMaxModeState, - getCurrentViewContext, - getTotalUnapprovedCount, - preferencesSelector, - getMetaMaskAccounts, - getCurrentEthBalance, - getNetworkIdentifier, - isBalanceCached, - getAdvancedInlineGasShown, - getIsMainnet, - getCurrentNetworkId, - getSelectedAsset, - getCurrentKeyring, - getAccountType, - getNumberOfAccounts, - getNumberOfTokens, -} - -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(getMetaMaskAccounts(state))[0] - - return selectedAddress -} - -function getSelectedIdentity (state) { - const selectedAddress = getSelectedAddress(state) - const identities = state.metamask.identities - - 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 = getMetaMaskAccounts(state) - const selectedAddress = getSelectedAddress(state) - - return accounts[selectedAddress] -} - -function getSelectedToken (state) { - const tokens = state.metamask.tokens || [] - const selectedTokenAddress = state.metamask.selectedTokenAddress - const selectedToken = tokens.filter(({ address }) => address === selectedTokenAddress)[0] - const sendToken = state.metamask.send.token - - return selectedToken || sendToken || null -} - -function getSelectedTokenExchangeRate (state) { - const contractExchangeRates = state.metamask.contractExchangeRates - const selectedToken = getSelectedToken(state) || {} - const { address } = selectedToken - return contractExchangeRates[address] || 0 -} - -function getSelectedTokenAssetImage (state) { - const assetImages = state.metamask.assetImages || {} - const selectedToken = getSelectedToken(state) || {} - const { address } = selectedToken - return assetImages[address] -} - -function getAssetImages (state) { - const assetImages = state.metamask.assetImages || {} - return assetImages -} - -function getTokenExchangeRate (state, address) { - const contractExchangeRates = state.metamask.contractExchangeRates - return contractExchangeRates[address] || 0 -} - -function conversionRateSelector (state) { - return state.metamask.conversionRate -} - -function getAddressBook (state) { - return state.metamask.addressBook -} - -function accountsWithSendEtherInfoSelector (state) { - const accounts = getMetaMaskAccounts(state) - const { identities } = state.metamask - - const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { - return Object.assign({}, account, identities[key]) - }) - - return accountsWithSendEtherInfo -} - -function getCurrentAccountWithSendEtherInfo (state) { - const currentAddress = getSelectedAddress(state) - const accounts = accountsWithSendEtherInfoSelector(state) - - return accounts.find(({ address }) => address === currentAddress) -} - -function getCurrentEthBalance (state) { - return getCurrentAccountWithSendEtherInfo(state).balance -} - -function getGasIsLoading (state) { - return state.appState.gasIsLoading -} - -function getForceGasMin (state) { - return state.metamask.send.forceGasMin -} - -function getSendFrom (state) { - return state.metamask.send.from -} - -function getSendAmount (state) { - return state.metamask.send.amount -} - -function getSendMaxModeState (state) { - return state.metamask.send.maxModeOn -} - -function getCurrentCurrency (state) { - return state.metamask.currentCurrency -} - -function getNativeCurrency (state) { - return state.metamask.nativeCurrency -} - -function getSelectedTokenToFiatRate (state) { - const selectedTokenExchangeRate = getSelectedTokenExchangeRate(state) - const conversionRate = conversionRateSelector(state) - - const tokenToFiatRate = multiplyCurrencies( - conversionRate, - selectedTokenExchangeRate, - { toNumericBase: 'dec' } - ) - - return tokenToFiatRate -} - -function getSelectedTokenContract (state) { - const selectedToken = getSelectedToken(state) - return selectedToken - ? global.eth.contract(abi).at(selectedToken.address) - : null -} - -function getCurrentViewContext (state) { - const { currentView = {} } = state.appState - return currentView.context -} - -function getTotalUnapprovedCount ({ metamask }) { - const { - unapprovedTxs = {}, - unapprovedMsgCount, - unapprovedPersonalMsgCount, - unapprovedTypedMessagesCount, - } = metamask - - return Object.keys(unapprovedTxs).length + unapprovedMsgCount + unapprovedPersonalMsgCount + - unapprovedTypedMessagesCount -} - -function getIsMainnet (state) { - const networkType = getNetworkIdentifier(state) - return networkType === NETWORK_TYPES.MAINNET -} - -function preferencesSelector ({ metamask }) { - return metamask.preferences -} - -function getAdvancedInlineGasShown (state) { - return Boolean(state.metamask.featureFlags.advancedInlineGas) -} diff --git a/ui/app/selectors/confirm-transaction.js b/ui/app/selectors/confirm-transaction.js index ccd16fadd..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 diff --git a/ui/app/selectors/custom-gas.js b/ui/app/selectors/custom-gas.js index 8039c0746..ecffb37ca 100644 --- a/ui/app/selectors/custom-gas.js +++ b/ui/app/selectors/custom-gas.js @@ -3,22 +3,22 @@ import { conversionUtil, multiplyCurrencies, conversionGreaterThan, -} from '../conversion-util' +} from '../helpers/utils/conversion-util' import { getCurrentCurrency, getIsMainnet, preferencesSelector, -} from '../selectors' +} from './selectors' import { formatCurrency, -} from '../helpers/confirm-transaction/util' +} from '../helpers/utils/confirm-tx.util' import { decEthToConvertedCurrency as ethTotalToConvertedCurrency, -} from '../helpers/conversions.util' +} from '../helpers/utils/conversions.util' import { formatETHFee, -} from '../helpers/formatters' +} from '../helpers/utils/formatters' import { calcGasTotal, -} from '../components/send/send.utils' +} from '../components/app/send/send.utils' import { addHexPrefix } from 'ethereumjs-util' const selectors = { 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/selectors.js b/ui/app/selectors/selectors.js new file mode 100644 index 000000000..ac226900f --- /dev/null +++ b/ui/app/selectors/selectors.js @@ -0,0 +1,301 @@ +import {NETWORK_TYPES} from '../helpers/constants/common' +import { stripHexPrefix } from 'ethereumjs-util' + +const abi = require('human-standard-token-abi') +import { + transactionsSelector, +} from './transactions' +const { + multiplyCurrencies, +} = require('../helpers/utils/conversion-util') + +const selectors = { + getSelectedAddress, + getSelectedIdentity, + getSelectedAccount, + getSelectedToken, + getSelectedTokenExchangeRate, + getSelectedTokenAssetImage, + getAssetImages, + getTokenExchangeRate, + conversionRateSelector, + transactionsSelector, + accountsWithSendEtherInfoSelector, + getCurrentAccountWithSendEtherInfo, + getGasIsLoading, + getForceGasMin, + getAddressBook, + getSendFrom, + getCurrentCurrency, + getNativeCurrency, + getSendAmount, + getSelectedTokenToFiatRate, + getSelectedTokenContract, + getSendMaxModeState, + getCurrentViewContext, + getTotalUnapprovedCount, + preferencesSelector, + getMetaMaskAccounts, + getCurrentEthBalance, + getNetworkIdentifier, + isBalanceCached, + getAdvancedInlineGasShown, + getIsMainnet, + getCurrentNetworkId, + getSelectedAsset, + getCurrentKeyring, + getAccountType, + getNumberOfAccounts, + getNumberOfTokens, +} + +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(getMetaMaskAccounts(state))[0] + + return selectedAddress +} + +function getSelectedIdentity (state) { + const selectedAddress = getSelectedAddress(state) + const identities = state.metamask.identities + + 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 = getMetaMaskAccounts(state) + const selectedAddress = getSelectedAddress(state) + + return accounts[selectedAddress] +} + +function getSelectedToken (state) { + const tokens = state.metamask.tokens || [] + const selectedTokenAddress = state.metamask.selectedTokenAddress + const selectedToken = tokens.filter(({ address }) => address === selectedTokenAddress)[0] + const sendToken = state.metamask.send.token + + return selectedToken || sendToken || null +} + +function getSelectedTokenExchangeRate (state) { + const contractExchangeRates = state.metamask.contractExchangeRates + const selectedToken = getSelectedToken(state) || {} + const { address } = selectedToken + return contractExchangeRates[address] || 0 +} + +function getSelectedTokenAssetImage (state) { + const assetImages = state.metamask.assetImages || {} + const selectedToken = getSelectedToken(state) || {} + const { address } = selectedToken + return assetImages[address] +} + +function getAssetImages (state) { + const assetImages = state.metamask.assetImages || {} + return assetImages +} + +function getTokenExchangeRate (state, address) { + const contractExchangeRates = state.metamask.contractExchangeRates + return contractExchangeRates[address] || 0 +} + +function conversionRateSelector (state) { + return state.metamask.conversionRate +} + +function getAddressBook (state) { + return state.metamask.addressBook +} + +function accountsWithSendEtherInfoSelector (state) { + const accounts = getMetaMaskAccounts(state) + const { identities } = state.metamask + + const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { + return Object.assign({}, account, identities[key]) + }) + + return accountsWithSendEtherInfo +} + +function getCurrentAccountWithSendEtherInfo (state) { + const currentAddress = getSelectedAddress(state) + const accounts = accountsWithSendEtherInfoSelector(state) + + return accounts.find(({ address }) => address === currentAddress) +} + +function getCurrentEthBalance (state) { + return getCurrentAccountWithSendEtherInfo(state).balance +} + +function getGasIsLoading (state) { + return state.appState.gasIsLoading +} + +function getForceGasMin (state) { + return state.metamask.send.forceGasMin +} + +function getSendFrom (state) { + return state.metamask.send.from +} + +function getSendAmount (state) { + return state.metamask.send.amount +} + +function getSendMaxModeState (state) { + return state.metamask.send.maxModeOn +} + +function getCurrentCurrency (state) { + return state.metamask.currentCurrency +} + +function getNativeCurrency (state) { + return state.metamask.nativeCurrency +} + +function getSelectedTokenToFiatRate (state) { + const selectedTokenExchangeRate = getSelectedTokenExchangeRate(state) + const conversionRate = conversionRateSelector(state) + + const tokenToFiatRate = multiplyCurrencies( + conversionRate, + selectedTokenExchangeRate, + { toNumericBase: 'dec' } + ) + + return tokenToFiatRate +} + +function getSelectedTokenContract (state) { + const selectedToken = getSelectedToken(state) + return selectedToken + ? global.eth.contract(abi).at(selectedToken.address) + : null +} + +function getCurrentViewContext (state) { + const { currentView = {} } = state.appState + return currentView.context +} + +function getTotalUnapprovedCount ({ metamask }) { + const { + unapprovedTxs = {}, + unapprovedMsgCount, + unapprovedPersonalMsgCount, + unapprovedTypedMessagesCount, + } = metamask + + return Object.keys(unapprovedTxs).length + unapprovedMsgCount + unapprovedPersonalMsgCount + + unapprovedTypedMessagesCount +} + +function getIsMainnet (state) { + const networkType = getNetworkIdentifier(state) + return networkType === NETWORK_TYPES.MAINNET +} + +function preferencesSelector ({ metamask }) { + return metamask.preferences +} + +function getAdvancedInlineGasShown (state) { + return Boolean(state.metamask.featureFlags.advancedInlineGas) +} diff --git a/ui/app/selectors/tests/custom-gas.test.js b/ui/app/selectors/tests/custom-gas.test.js deleted file mode 100644 index 73240d997..000000000 --- a/ui/app/selectors/tests/custom-gas.test.js +++ /dev/null @@ -1,595 +0,0 @@ -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/transactions.js b/ui/app/selectors/transactions.js index fc1271c59..b1d27b333 100644 --- a/ui/app/selectors/transactions.js +++ b/ui/app/selectors/transactions.js @@ -4,12 +4,12 @@ import { APPROVED_STATUS, SUBMITTED_STATUS, CONFIRMED_STATUS, -} from '../constants/transactions' +} from '../helpers/constants/transactions' import { TRANSACTION_TYPE_CANCEL, TRANSACTION_TYPE_RETRY, } from '../../../app/scripts/controllers/transactions/enums' -import { hexToDecimal } from '../helpers/conversions.util' +import { hexToDecimal } from '../helpers/utils/conversions.util' import { selectedTokenAddressSelector } from './tokens' import txHelper from '../../lib/tx-helper' diff --git a/ui/app/store.js b/ui/app/store.js deleted file mode 100644 index feebbabc0..000000000 --- a/ui/app/store.js +++ /dev/null @@ -1,21 +0,0 @@ -const createStore = require('redux').createStore -const applyMiddleware = require('redux').applyMiddleware -const thunkMiddleware = require('redux-thunk').default -const rootReducer = require('./reducers') -const createLogger = require('redux-logger').createLogger - -global.METAMASK_DEBUG = process.env.METAMASK_DEBUG - -module.exports = configureStore - -const loggerMiddleware = createLogger({ - predicate: () => global.METAMASK_DEBUG, -}) - -const middlewares = [thunkMiddleware, loggerMiddleware] - -const createStoreWithMiddleware = applyMiddleware(...middlewares)(createStore) - -function configureStore (initialState) { - return createStoreWithMiddleware(rootReducer, initialState) -} diff --git a/ui/app/store/actions.js b/ui/app/store/actions.js new file mode 100644 index 000000000..b99b051cb --- /dev/null +++ b/ui/app/store/actions.js @@ -0,0 +1,2761 @@ +const abi = require('human-standard-token-abi') +const pify = require('pify') +const getBuyEthUrl = require('../../../app/scripts/lib/buy-eth-url') +const { getTokenAddressFromTokenObject } = require('../helpers/utils/util') +const { + calcTokenBalance, + estimateGas, +} = require('../components/app/send/send.utils') +const ethUtil = require('ethereumjs-util') +const { fetchLocale } = require('../helpers/utils/i18n-helper') +const log = require('loglevel') +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, + + GO_HOME: 'GO_HOME', + goHome: goHome, + // modal state + MODAL_OPEN: 'UI_MODAL_OPEN', + MODAL_CLOSE: 'UI_MODAL_CLOSE', + showModal: showModal, + hideModal: hideModal, + // sidebar state + SIDEBAR_OPEN: 'UI_SIDEBAR_OPEN', + SIDEBAR_CLOSE: 'UI_SIDEBAR_CLOSE', + showSidebar: showSidebar, + hideSidebar: hideSidebar, + // sidebar state + ALERT_OPEN: 'UI_ALERT_OPEN', + ALERT_CLOSE: 'UI_ALERT_CLOSE', + showAlert: showAlert, + hideAlert: hideAlert, + QR_CODE_DETECTED: 'UI_QR_CODE_DETECTED', + qrCodeDetected, + // network dropdown open + NETWORK_DROPDOWN_OPEN: 'UI_NETWORK_DROPDOWN_OPEN', + NETWORK_DROPDOWN_CLOSE: 'UI_NETWORK_DROPDOWN_CLOSE', + showNetworkDropdown: showNetworkDropdown, + hideNetworkDropdown: hideNetworkDropdown, + // menu state/ + getNetworkStatus: 'getNetworkStatus', + // transition state + TRANSITION_FORWARD: 'TRANSITION_FORWARD', + TRANSITION_BACKWARD: 'TRANSITION_BACKWARD', + transitionForward, + transitionBackward, + // remote state + UPDATE_METAMASK_STATE: 'UPDATE_METAMASK_STATE', + updateMetamaskState: updateMetamaskState, + // notices + MARK_NOTICE_READ: 'MARK_NOTICE_READ', + markNoticeRead: markNoticeRead, + SHOW_NOTICE: 'SHOW_NOTICE', + showNotice: showNotice, + CLEAR_NOTICES: 'CLEAR_NOTICES', + clearNotices: clearNotices, + markAccountsFound, + // intialize screen + 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, + unMarkPasswordForgotten, + SHOW_INIT_MENU: 'SHOW_INIT_MENU', + SHOW_NEW_VAULT_SEED: 'SHOW_NEW_VAULT_SEED', + SHOW_INFO_PAGE: 'SHOW_INFO_PAGE', + SHOW_IMPORT_PAGE: 'SHOW_IMPORT_PAGE', + SHOW_NEW_ACCOUNT_PAGE: 'SHOW_NEW_ACCOUNT_PAGE', + SET_NEW_ACCOUNT_FORM: 'SET_NEW_ACCOUNT_FORM', + unlockMetamask: unlockMetamask, + unlockFailed: unlockFailed, + unlockSucceeded, + showCreateVault: showCreateVault, + showRestoreVault: showRestoreVault, + showInitializeMenu: showInitializeMenu, + showImportPage, + showNewAccountPage, + setNewAccountForm, + createNewVaultAndKeychain: createNewVaultAndKeychain, + createNewVaultAndRestore: createNewVaultAndRestore, + createNewVaultInProgress: createNewVaultInProgress, + createNewVaultAndGetSeedPhrase, + unlockAndGetSeedPhrase, + addNewKeyring, + importNewAccount, + addNewAccount, + connectHardware, + checkHardwareStatus, + forgetDevice, + unlockHardwareWalletAccount, + NEW_ACCOUNT_SCREEN: 'NEW_ACCOUNT_SCREEN', + navigateToNewAccountScreen, + resetAccount, + removeAccount, + showNewVaultSeed: showNewVaultSeed, + showInfoPage: showInfoPage, + CLOSE_WELCOME_SCREEN: 'CLOSE_WELCOME_SCREEN', + closeWelcomeScreen, + // seed recovery actions + REVEAL_SEED_CONFIRMATION: 'REVEAL_SEED_CONFIRMATION', + revealSeedConfirmation: revealSeedConfirmation, + requestRevealSeed: requestRevealSeed, + requestRevealSeedWords, + // unlock screen + UNLOCK_IN_PROGRESS: 'UNLOCK_IN_PROGRESS', + UNLOCK_FAILED: 'UNLOCK_FAILED', + UNLOCK_SUCCEEDED: 'UNLOCK_SUCCEEDED', + UNLOCK_METAMASK: 'UNLOCK_METAMASK', + LOCK_METAMASK: 'LOCK_METAMASK', + tryUnlockMetamask: tryUnlockMetamask, + lockMetamask: lockMetamask, + unlockInProgress: unlockInProgress, + // error handling + displayWarning: displayWarning, + DISPLAY_WARNING: 'DISPLAY_WARNING', + HIDE_WARNING: 'HIDE_WARNING', + hideWarning: hideWarning, + // accounts screen + SET_SELECTED_ACCOUNT: 'SET_SELECTED_ACCOUNT', + SET_SELECTED_TOKEN: 'SET_SELECTED_TOKEN', + setSelectedToken, + SHOW_ACCOUNT_DETAIL: 'SHOW_ACCOUNT_DETAIL', + SHOW_ACCOUNTS_PAGE: 'SHOW_ACCOUNTS_PAGE', + SHOW_CONF_TX_PAGE: 'SHOW_CONF_TX_PAGE', + SHOW_CONF_MSG_PAGE: 'SHOW_CONF_MSG_PAGE', + SET_CURRENT_FIAT: 'SET_CURRENT_FIAT', + showQrScanner, + setCurrentCurrency, + setCurrentAccountTab, + // account detail screen + SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', + showSendPage: showSendPage, + SHOW_SEND_TOKEN_PAGE: 'SHOW_SEND_TOKEN_PAGE', + showSendTokenPage, + ADD_TO_ADDRESS_BOOK: 'ADD_TO_ADDRESS_BOOK', + addToAddressBook: addToAddressBook, + REQUEST_ACCOUNT_EXPORT: 'REQUEST_ACCOUNT_EXPORT', + requestExportAccount: requestExportAccount, + EXPORT_ACCOUNT: 'EXPORT_ACCOUNT', + exportAccount: exportAccount, + SHOW_PRIVATE_KEY: 'SHOW_PRIVATE_KEY', + showPrivateKey: showPrivateKey, + exportAccountComplete, + SET_ACCOUNT_LABEL: 'SET_ACCOUNT_LABEL', + setAccountLabel, + updateNetworkNonce, + SET_NETWORK_NONCE: 'SET_NETWORK_NONCE', + // tx conf screen + COMPLETED_TX: 'COMPLETED_TX', + TRANSACTION_ERROR: 'TRANSACTION_ERROR', + NEXT_TX: 'NEXT_TX', + PREVIOUS_TX: 'PREV_TX', + EDIT_TX: 'EDIT_TX', + signMsg: signMsg, + cancelMsg: cancelMsg, + signPersonalMsg, + cancelPersonalMsg, + signTypedMsg, + cancelTypedMsg, + sendTx: sendTx, + signTx: signTx, + signTokenTx: signTokenTx, + updateTransaction, + updateAndApproveTx, + cancelTx: cancelTx, + cancelTxs, + completedTx: completedTx, + txError: txError, + nextTx: nextTx, + editTx, + previousTx: previousTx, + cancelAllTx: cancelAllTx, + viewPendingTx: viewPendingTx, + VIEW_PENDING_TX: 'VIEW_PENDING_TX', + updateTransactionParams, + UPDATE_TRANSACTION_PARAMS: 'UPDATE_TRANSACTION_PARAMS', + // send screen + UPDATE_GAS_LIMIT: 'UPDATE_GAS_LIMIT', + UPDATE_GAS_PRICE: 'UPDATE_GAS_PRICE', + UPDATE_GAS_TOTAL: 'UPDATE_GAS_TOTAL', + UPDATE_SEND_FROM: 'UPDATE_SEND_FROM', + UPDATE_SEND_HEX_DATA: 'UPDATE_SEND_HEX_DATA', + UPDATE_SEND_TOKEN_BALANCE: 'UPDATE_SEND_TOKEN_BALANCE', + UPDATE_SEND_TO: 'UPDATE_SEND_TO', + UPDATE_SEND_AMOUNT: 'UPDATE_SEND_AMOUNT', + UPDATE_SEND_MEMO: 'UPDATE_SEND_MEMO', + UPDATE_SEND_ERRORS: 'UPDATE_SEND_ERRORS', + UPDATE_SEND_WARNINGS: 'UPDATE_SEND_WARNINGS', + UPDATE_MAX_MODE: 'UPDATE_MAX_MODE', + UPDATE_SEND: 'UPDATE_SEND', + CLEAR_SEND: 'CLEAR_SEND', + OPEN_FROM_DROPDOWN: 'OPEN_FROM_DROPDOWN', + CLOSE_FROM_DROPDOWN: 'CLOSE_FROM_DROPDOWN', + GAS_LOADING_STARTED: 'GAS_LOADING_STARTED', + GAS_LOADING_FINISHED: 'GAS_LOADING_FINISHED', + setGasLimit, + setGasPrice, + updateGasData, + setGasTotal, + setSendTokenBalance, + updateSendTokenBalance, + updateSendHexData, + updateSendTo, + updateSendAmount, + updateSendMemo, + setMaxModeTo, + updateSend, + updateSendErrors, + updateSendWarnings, + clearSend, + setSelectedAddress, + gasLoadingStarted, + gasLoadingFinished, + // app messages + confirmSeedWords: confirmSeedWords, + showAccountDetail: showAccountDetail, + BACK_TO_ACCOUNT_DETAIL: 'BACK_TO_ACCOUNT_DETAIL', + backToAccountDetail: backToAccountDetail, + showAccountsPage: showAccountsPage, + showConfTxPage: showConfTxPage, + // config screen + SHOW_CONFIG_PAGE: 'SHOW_CONFIG_PAGE', + 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', + showAddTokenPage, + showAddSuggestedTokenPage, + addToken, + addTokens, + removeToken, + updateTokens, + removeSuggestedTokens, + addKnownMethodData, + UPDATE_TOKENS: 'UPDATE_TOKENS', + updateAndSetCustomRpc: updateAndSetCustomRpc, + setRpcTarget: setRpcTarget, + delRpcTarget: delRpcTarget, + setProviderType: setProviderType, + SET_HARDWARE_WALLET_DEFAULT_HD_PATH: 'SET_HARDWARE_WALLET_DEFAULT_HD_PATH', + setHardwareWalletDefaultHdPath, + updateProviderType, + // loading overlay + SHOW_LOADING: 'SHOW_LOADING_INDICATION', + HIDE_LOADING: 'HIDE_LOADING_INDICATION', + showLoadingIndication: showLoadingIndication, + hideLoadingIndication: hideLoadingIndication, + // buy Eth with coinbase + onboardingBuyEthView, + ONBOARDING_BUY_ETH_VIEW: 'ONBOARDING_BUY_ETH_VIEW', + BUY_ETH: 'BUY_ETH', + buyEth: buyEth, + buyEthView: buyEthView, + buyWithShapeShift, + BUY_ETH_VIEW: 'BUY_ETH_VIEW', + COINBASE_SUBVIEW: 'COINBASE_SUBVIEW', + coinBaseSubview: coinBaseSubview, + SHAPESHIFT_SUBVIEW: 'SHAPESHIFT_SUBVIEW', + shapeShiftSubview: shapeShiftSubview, + PAIR_UPDATE: 'PAIR_UPDATE', + pairUpdate: pairUpdate, + coinShiftRquest: coinShiftRquest, + SHOW_SUB_LOADING_INDICATION: 'SHOW_SUB_LOADING_INDICATION', + showSubLoadingIndication: showSubLoadingIndication, + HIDE_SUB_LOADING_INDICATION: 'HIDE_SUB_LOADING_INDICATION', + hideSubLoadingIndication: hideSubLoadingIndication, +// QR STUFF: + SHOW_QR: 'SHOW_QR', + showQrView: showQrView, + reshowQrCode: reshowQrCode, + SHOW_QR_VIEW: 'SHOW_QR_VIEW', +// FORGOT PASSWORD: + BACK_TO_INIT_MENU: 'BACK_TO_INIT_MENU', + goBackToInitView: goBackToInitView, + RECOVERY_IN_PROGRESS: 'RECOVERY_IN_PROGRESS', + BACK_TO_UNLOCK_VIEW: 'BACK_TO_UNLOCK_VIEW', + backToUnlockView: backToUnlockView, + // SHOWING KEYCHAIN + SHOW_NEW_KEYCHAIN: 'SHOW_NEW_KEYCHAIN', + showNewKeychain: showNewKeychain, + + callBackgroundThenUpdate, + forceUpdateMetamaskState, + + TOGGLE_ACCOUNT_MENU: 'TOGGLE_ACCOUNT_MENU', + toggleAccountMenu, + + useEtherscanProvider, + + 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', + setCurrentLocale, + updateCurrentLocale, + setLocaleMessages, + // + // Feature Flags + setFeatureFlag, + updateFeatureFlags, + UPDATE_FEATURE_FLAGS: 'UPDATE_FEATURE_FLAGS', + + // Preferences + setPreference, + 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', + + // Network + updateNetworkEndpointType, + UPDATE_NETWORK_ENDPOINT_TYPE: 'UPDATE_NETWORK_ENDPOINT_TYPE', + + retryTransaction, + SET_PENDING_TOKENS: 'SET_PENDING_TOKENS', + CLEAR_PENDING_TOKENS: 'CLEAR_PENDING_TOKENS', + setPendingTokens, + clearPendingTokens, + + createCancelTransaction, + createSpeedUpTransaction, + + approveProviderRequest, + rejectProviderRequest, + clearApprovedOrigins, + + setFirstTimeFlowType, + SET_FIRST_TIME_FLOW_TYPE: 'SET_FIRST_TIME_FLOW_TYPE', +} + +module.exports = actions + +var background = null +function _setBackgroundConnection (backgroundConnection) { + background = backgroundConnection +} + +function goHome () { + return { + type: actions.GO_HOME, + } +} + +// async actions + +function tryUnlockMetamask (password) { + return dispatch => { + dispatch(actions.showLoadingIndication()) + dispatch(actions.unlockInProgress()) + log.debug(`background.submitPassword`) + + return new Promise((resolve, reject) => { + background.submitPassword(password, error => { + if (error) { + return reject(error) + } + + resolve() + }) + }) + .then(() => { + dispatch(actions.unlockSucceeded()) + return forceUpdateMetamaskState(dispatch) + }) + .then(() => { + return new Promise((resolve, reject) => { + background.verifySeedPhrase(err => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + resolve() + }) + }) + }) + .then(() => { + dispatch(actions.transitionForward()) + dispatch(actions.hideLoadingIndication()) + }) + .catch(err => { + dispatch(actions.unlockFailed(err.message)) + dispatch(actions.hideLoadingIndication()) + return Promise.reject(err) + }) + } +} + +function transitionForward () { + return { + type: this.TRANSITION_FORWARD, + } +} + +function transitionBackward () { + return { + type: this.TRANSITION_BACKWARD, + } +} + +function confirmSeedWords () { + return dispatch => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.clearSeedWordCache`) + return new Promise((resolve, reject) => { + background.clearSeedWordCache((err, account) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + log.info('Seed word cache cleared. ' + account) + dispatch(actions.showAccountsPage()) + resolve(account) + }) + }) + } +} + +function createNewVaultAndRestore (password, seed) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.createNewVaultAndRestore`) + + return new Promise((resolve, reject) => { + background.clearSeedWordCache((err) => { + if (err) { + return reject(err) + } + + background.createNewVaultAndRestore(password, seed, (err) => { + if (err) { + return reject(err) + } + + resolve() + }) + }) + }) + .then(() => dispatch(actions.unMarkPasswordForgotten())) + .then(() => { + dispatch(actions.showAccountsPage()) + dispatch(actions.hideLoadingIndication()) + }) + .catch(err => { + dispatch(actions.displayWarning(err.message)) + dispatch(actions.hideLoadingIndication()) + return Promise.reject(err) + }) + } +} + +function createNewVaultAndKeychain (password) { + return dispatch => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.createNewVaultAndKeychain`) + + return new Promise((resolve, reject) => { + background.createNewVaultAndKeychain(password, err => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + log.debug(`background.placeSeedWords`) + + background.placeSeedWords((err) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + resolve() + }) + }) + }) + .then(() => forceUpdateMetamaskState(dispatch)) + .then(() => dispatch(actions.hideLoadingIndication())) + .catch(() => dispatch(actions.hideLoadingIndication())) + } +} + +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 => { + if (error) { + return reject(error) + } + + resolve(true) + }) + }) +} + +function verifySeedPhrase () { + return new Promise((resolve, reject) => { + background.verifySeedPhrase((error, seedWords) => { + if (error) { + return reject(error) + } + + resolve(seedWords) + }) + }) +} + +function requestRevealSeed (password) { + return dispatch => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.submitPassword`) + return new Promise((resolve, reject) => { + background.submitPassword(password, err => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + log.debug(`background.placeSeedWords`) + background.placeSeedWords((err, result) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.showNewVaultSeed(result)) + dispatch(actions.hideLoadingIndication()) + resolve() + }) + }) + }) + } +} + +function requestRevealSeedWords (password) { + return async dispatch => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.submitPassword`) + + try { + await verifyPassword(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 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()) + + return new Promise((resolve, reject) => { + background.resetAccount((err, account) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + log.info('Transaction history reset for ' + account) + dispatch(actions.showAccountsPage()) + resolve(account) + }) + }) + } +} + +function removeAccount (address) { + return dispatch => { + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + background.removeAccount(address, (err, account) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + log.info('Account removed: ' + account) + dispatch(actions.showAccountsPage()) + resolve() + }) + }) + } +} + +function addNewKeyring (type, opts) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.addNewKeyring`) + background.addNewKeyring(type, opts, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) return dispatch(actions.displayWarning(err.message)) + dispatch(actions.showAccountsPage()) + }) + } +} + +function importNewAccount (strategy, args) { + return async (dispatch) => { + let newState + dispatch(actions.showLoadingIndication('This may take a while, please be patient.')) + try { + log.debug(`background.importAccountWithStrategy`) + await pify(background.importAccountWithStrategy).call(background, strategy, args) + log.debug(`background.getState`) + newState = await pify(background.getState).call(background) + } catch (err) { + dispatch(actions.hideLoadingIndication()) + dispatch(actions.displayWarning(err.message)) + throw err + } + dispatch(actions.hideLoadingIndication()) + dispatch(actions.updateMetamaskState(newState)) + if (newState.selectedAddress) { + dispatch({ + type: actions.SHOW_ACCOUNT_DETAIL, + value: newState.selectedAddress, + }) + } + return newState + } +} + +function navigateToNewAccountScreen () { + return { + type: this.NEW_ACCOUNT_SCREEN, + } +} + +function addNewAccount () { + log.debug(`background.addNewAccount`) + return (dispatch, getState) => { + const oldIdentities = getState().metamask.identities + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.addNewAccount((err, { identities: newIdentities}) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + const newAccountAddress = Object.keys(newIdentities).find(address => !oldIdentities[address]) + + dispatch(actions.hideLoadingIndication()) + + forceUpdateMetamaskState(dispatch) + return resolve(newAccountAddress) + }) + }) + } +} + +function checkHardwareStatus (deviceName, hdPath) { + log.debug(`background.checkHardwareStatus`, deviceName, hdPath) + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.checkHardwareStatus(deviceName, hdPath, (err, unlocked) => { + if (err) { + log.error(err) + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.hideLoadingIndication()) + + forceUpdateMetamaskState(dispatch) + return resolve(unlocked) + }) + }) + } +} + +function forgetDevice (deviceName) { + log.debug(`background.forgetDevice`, deviceName) + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.forgetDevice(deviceName, (err, response) => { + if (err) { + log.error(err) + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.hideLoadingIndication()) + + forceUpdateMetamaskState(dispatch) + return resolve() + }) + }) + } +} + +function connectHardware (deviceName, page, hdPath) { + log.debug(`background.connectHardware`, deviceName, page, hdPath) + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.connectHardware(deviceName, page, hdPath, (err, accounts) => { + if (err) { + log.error(err) + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.hideLoadingIndication()) + + forceUpdateMetamaskState(dispatch) + return resolve(accounts) + }) + }) + } +} + +function unlockHardwareWalletAccount (index, deviceName, hdPath) { + log.debug(`background.unlockHardwareWalletAccount`, index, deviceName, hdPath) + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.unlockHardwareWalletAccount(index, deviceName, hdPath, (err, accounts) => { + if (err) { + log.error(err) + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.hideLoadingIndication()) + return resolve() + }) + }) + } +} + +function showInfoPage () { + return { + type: actions.SHOW_INFO_PAGE, + } +} + +function showQrScanner (ROUTE) { + return (dispatch, getState) => { + return WebcamUtils.checkStatus() + .then(status => { + if (!status.environmentReady) { + // We need to switch to fullscreen mode to ask for permission + global.platform.openExtensionInBrowser(`${ROUTE}`, `scan=true`) + } else { + dispatch(actions.showModal({ + name: 'QR_SCANNER', + })) + } + }).catch(e => { + dispatch(actions.showModal({ + name: 'QR_SCANNER', + error: true, + errorType: e.type, + })) + }) + } +} + +function setCurrentCurrency (currencyCode) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.setCurrentCurrency`) + background.setCurrentCurrency(currencyCode, (err, data) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + log.error(err.stack) + return dispatch(actions.displayWarning(err.message)) + } + dispatch({ + type: actions.SET_CURRENT_FIAT, + value: { + currentCurrency: data.currentCurrency, + conversionRate: data.conversionRate, + conversionDate: data.conversionDate, + }, + }) + }) + } +} + +function signMsg (msgData) { + log.debug('action - signMsg') + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + log.debug(`actions calling background.signMessage`) + background.signMessage(msgData, (err, newState) => { + log.debug('signMessage called back') + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + + if (err) { + log.error(err) + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.completedTx(msgData.metamaskId)) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return resolve(msgData) + }) + }) + } +} + +function signPersonalMsg (msgData) { + log.debug('action - signPersonalMsg') + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + log.debug(`actions calling background.signPersonalMessage`) + background.signPersonalMessage(msgData, (err, newState) => { + log.debug('signPersonalMessage called back') + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + + if (err) { + log.error(err) + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.completedTx(msgData.metamaskId)) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return resolve(msgData) + }) + }) + } +} + +function signTypedMsg (msgData) { + log.debug('action - signTypedMsg') + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + log.debug(`actions calling background.signTypedMessage`) + background.signTypedMessage(msgData, (err, newState) => { + log.debug('signTypedMessage called back') + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + + if (err) { + log.error(err) + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.completedTx(msgData.metamaskId)) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return resolve(msgData) + }) + }) + } +} + +function signTx (txData) { + return (dispatch) => { + global.ethQuery.sendTransaction(txData, (err, data) => { + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + }) + dispatch(actions.showConfTxPage({})) + } +} + +function setGasLimit (gasLimit) { + return { + type: actions.UPDATE_GAS_LIMIT, + value: gasLimit, + } +} + +function setGasPrice (gasPrice) { + return { + type: actions.UPDATE_GAS_PRICE, + value: gasPrice, + } +} + +function setGasTotal (gasTotal) { + return { + type: actions.UPDATE_GAS_TOTAL, + value: gasTotal, + } +} + +function updateGasData ({ + gasPrice, + blockGasLimit, + recentBlocks, + selectedAddress, + selectedToken, + to, + value, + data, +}) { + return (dispatch) => { + dispatch(actions.gasLoadingStarted()) + return estimateGas({ + estimateGasMethod: background.estimateGas, + blockGasLimit, + selectedAddress, + selectedToken, + to, + value, + estimateGasPrice: gasPrice, + data, + }) + .then(gas => { + dispatch(actions.setGasLimit(gas)) + dispatch(gasDuck.setCustomGasLimit(gas)) + dispatch(updateSendErrors({ gasLoadingError: null })) + dispatch(actions.gasLoadingFinished()) + }) + .catch(err => { + log.error(err) + dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' })) + dispatch(actions.gasLoadingFinished()) + }) + } +} + +function gasLoadingStarted () { + return { + type: actions.GAS_LOADING_STARTED, + } +} + +function gasLoadingFinished () { + return { + type: actions.GAS_LOADING_FINISHED, + } +} + +function updateSendTokenBalance ({ + selectedToken, + tokenContract, + address, +}) { + return (dispatch) => { + const tokenBalancePromise = tokenContract + ? tokenContract.balanceOf(address) + : Promise.resolve() + return tokenBalancePromise + .then(usersToken => { + if (usersToken) { + const newTokenBalance = calcTokenBalance({ selectedToken, usersToken }) + dispatch(setSendTokenBalance(newTokenBalance)) + } + }) + .catch(err => { + log.error(err) + updateSendErrors({ tokenBalance: 'tokenBalanceError' }) + }) + } +} + +function updateSendErrors (errorObject) { + return { + type: actions.UPDATE_SEND_ERRORS, + value: errorObject, + } +} + +function updateSendWarnings (warningObject) { + return { + type: actions.UPDATE_SEND_WARNINGS, + value: warningObject, + } +} + +function setSendTokenBalance (tokenBalance) { + return { + type: actions.UPDATE_SEND_TOKEN_BALANCE, + value: tokenBalance, + } +} + +function updateSendHexData (value) { + return { + type: actions.UPDATE_SEND_HEX_DATA, + value, + } +} + +function updateSendTo (to, nickname = '') { + return { + type: actions.UPDATE_SEND_TO, + value: { to, nickname }, + } +} + +function updateSendAmount (amount) { + return { + type: actions.UPDATE_SEND_AMOUNT, + value: amount, + } +} + +function updateSendMemo (memo) { + return { + type: actions.UPDATE_SEND_MEMO, + value: memo, + } +} + +function setMaxModeTo (bool) { + return { + type: actions.UPDATE_MAX_MODE, + value: bool, + } +} + +function updateSend (newSend) { + return { + type: actions.UPDATE_SEND, + value: newSend, + } +} + +function clearSend () { + return { + type: actions.CLEAR_SEND, + } +} + + +function sendTx (txData) { + log.info(`actions - sendTx: ${JSON.stringify(txData.txParams)}`) + return (dispatch, getState) => { + log.debug(`actions calling background.approveTransaction`) + background.approveTransaction(txData.id, (err) => { + if (err) { + dispatch(actions.txError(err)) + return log.error(err.message) + } + dispatch(actions.completedTx(txData.id)) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + }) + } +} + +function signTokenTx (tokenAddress, toAddress, amount, txData) { + return dispatch => { + dispatch(actions.showLoadingIndication()) + const token = global.eth.contract(abi).at(tokenAddress) + token.transfer(toAddress, ethUtil.addHexPrefix(amount), txData) + .catch(err => { + dispatch(actions.hideLoadingIndication()) + dispatch(actions.displayWarning(err.message)) + }) + dispatch(actions.showConfTxPage({})) + } +} + +function updateTransaction (txData) { + log.info('actions: updateTx: ' + JSON.stringify(txData)) + return dispatch => { + log.debug(`actions calling background.updateTx`) + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + background.updateTransaction(txData, (err) => { + dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) + if (err) { + dispatch(actions.txError(err)) + dispatch(actions.goHome()) + log.error(err.message) + return reject(err) + } + + resolve(txData) + }) + }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { + dispatch(actions.showConfTxPage({ id: txData.id })) + dispatch(actions.hideLoadingIndication()) + return txData + }) + } +} + +function updateAndApproveTx (txData) { + log.info('actions: updateAndApproveTx: ' + JSON.stringify(txData)) + return (dispatch, getState) => { + log.debug(`actions calling background.updateAndApproveTx`) + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + background.updateAndApproveTransaction(txData, err => { + dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) + dispatch(actions.clearSend()) + + if (err) { + dispatch(actions.txError(err)) + dispatch(actions.goHome()) + log.error(err.message) + reject(err) + } + + resolve(txData) + }) + }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { + dispatch(actions.clearSend()) + dispatch(actions.completedTx(txData.id)) + dispatch(actions.hideLoadingIndication()) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return txData + }) + .catch((err) => { + dispatch(actions.hideLoadingIndication()) + return Promise.reject(err) + }) + } +} + +function completedTx (id) { + return { + type: actions.COMPLETED_TX, + value: id, + } +} + +function updateTransactionParams (id, txParams) { + return { + type: actions.UPDATE_TRANSACTION_PARAMS, + id, + value: txParams, + } +} + +function txError (err) { + return { + type: actions.TRANSACTION_ERROR, + message: err.message, + } +} + +function cancelMsg (msgData) { + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + log.debug(`background.cancelMessage`) + background.cancelMessage(msgData.id, (err, newState) => { + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + + if (err) { + return reject(err) + } + + dispatch(actions.completedTx(msgData.id)) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return resolve(msgData) + }) + }) + } +} + +function cancelPersonalMsg (msgData) { + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + const id = msgData.id + background.cancelPersonalMessage(id, (err, newState) => { + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + + if (err) { + return reject(err) + } + + dispatch(actions.completedTx(id)) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return resolve(msgData) + }) + }) + } +} + +function cancelTypedMsg (msgData) { + return (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + const id = msgData.id + background.cancelTypedMessage(id, (err, newState) => { + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + + if (err) { + return reject(err) + } + + dispatch(actions.completedTx(id)) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return resolve(msgData) + }) + }) + } +} + +function cancelTx (txData) { + return (dispatch, getState) => { + log.debug(`background.cancelTransaction`) + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + background.cancelTransaction(txData.id, err => { + if (err) { + return reject(err) + } + + resolve() + }) + }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { + dispatch(actions.clearSend()) + dispatch(actions.completedTx(txData.id)) + dispatch(actions.hideLoadingIndication()) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION && + !hasUnconfirmedTransactions(getState())) { + return global.platform.closeCurrentWindow() + } + + return txData + }) + } +} + +/** + * Cancels all of the given transactions + * @param {Array<object>} txDataList a list of tx data objects + * @return {function(*): Promise<void>} + */ +function cancelTxs (txDataList) { + return async (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + const txIds = txDataList.map(({id}) => id) + const cancellations = txIds.map((id) => new Promise((resolve, reject) => { + background.cancelTransaction(id, (err) => { + if (err) { + return reject(err) + } + + resolve() + }) + })) + + await Promise.all(cancellations) + const newState = await updateMetamaskStateFromBackground() + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.clearSend()) + + txIds.forEach((id) => { + dispatch(actions.completedTx(id)) + }) + + dispatch(actions.hideLoadingIndication()) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { + return global.platform.closeCurrentWindow() + } + } +} + +/** + * @deprecated + * @param {Array<object>} txsData + * @return {Function} + */ +function cancelAllTx (txsData) { + return (dispatch) => { + txsData.forEach((txData, i) => { + background.cancelTransaction(txData.id, () => { + dispatch(actions.completedTx(txData.id)) + i === txsData.length - 1 ? dispatch(actions.goHome()) : null + }) + }) + } +} +// +// initialize screen +// + +function showCreateVault () { + return { + type: actions.SHOW_CREATE_VAULT, + } +} + +function showRestoreVault () { + return { + type: actions.SHOW_RESTORE_VAULT, + } +} + +function markPasswordForgotten () { + return (dispatch) => { + return background.markPasswordForgotten(() => { + dispatch(actions.hideLoadingIndication()) + dispatch(actions.forgotPassword()) + forceUpdateMetamaskState(dispatch) + }) + } +} + +function unMarkPasswordForgotten () { + return dispatch => { + return new Promise(resolve => { + background.unMarkPasswordForgotten(() => { + dispatch(actions.forgotPassword(false)) + resolve() + }) + }) + .then(() => forceUpdateMetamaskState(dispatch)) + } +} + +function forgotPassword (forgotPasswordState = true) { + return { + type: actions.FORGOT_PASSWORD, + value: forgotPasswordState, + } +} + +function showInitializeMenu () { + return { + type: actions.SHOW_INIT_MENU, + } +} + +function showImportPage () { + return { + type: actions.SHOW_IMPORT_PAGE, + } +} + +function showNewAccountPage (formToSelect) { + return { + type: actions.SHOW_NEW_ACCOUNT_PAGE, + formToSelect, + } +} + +function setNewAccountForm (formToSelect) { + return { + type: actions.SET_NEW_ACCOUNT_FORM, + formToSelect, + } +} + +function createNewVaultInProgress () { + return { + type: actions.CREATE_NEW_VAULT_IN_PROGRESS, + } +} + +function showNewVaultSeed (seed) { + return { + type: actions.SHOW_NEW_VAULT_SEED, + value: seed, + } +} + +function closeWelcomeScreen () { + return { + type: actions.CLOSE_WELCOME_SCREEN, + } +} + +function backToUnlockView () { + return { + type: actions.BACK_TO_UNLOCK_VIEW, + } +} + +function showNewKeychain () { + return { + type: actions.SHOW_NEW_KEYCHAIN, + } +} + +// +// unlock screen +// + +function unlockInProgress () { + return { + type: actions.UNLOCK_IN_PROGRESS, + } +} + +function unlockFailed (message) { + return { + type: actions.UNLOCK_FAILED, + value: message, + } +} + +function unlockSucceeded (message) { + return { + type: actions.UNLOCK_SUCCEEDED, + value: message, + } +} + +function unlockMetamask (account) { + return { + type: actions.UNLOCK_METAMASK, + value: account, + } +} + +function updateMetamaskState (newState) { + return { + type: actions.UPDATE_METAMASK_STATE, + value: newState, + } +} + +const backgroundSetLocked = () => { + return new Promise((resolve, reject) => { + background.setLocked(error => { + if (error) { + return reject(error) + } + resolve() + }) + }) +} + +const updateMetamaskStateFromBackground = () => { + log.debug(`background.getState`) + + return new Promise((resolve, reject) => { + background.getState((error, newState) => { + if (error) { + return reject(error) + } + + resolve(newState) + }) + }) +} + +function lockMetamask () { + log.debug(`background.setLocked`) + + return dispatch => { + dispatch(actions.showLoadingIndication()) + + return backgroundSetLocked() + .then(() => updateMetamaskStateFromBackground()) + .catch(error => { + dispatch(actions.displayWarning(error.message)) + return Promise.reject(error) + }) + .then(newState => { + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.hideLoadingIndication()) + dispatch({ type: actions.LOCK_METAMASK }) + }) + .catch(() => { + dispatch(actions.hideLoadingIndication()) + dispatch({ type: actions.LOCK_METAMASK }) + }) + } +} + +function setCurrentAccountTab (newTabName) { + log.debug(`background.setCurrentAccountTab: ${newTabName}`) + return callBackgroundThenUpdateNoSpinner(background.setCurrentAccountTab, newTabName) +} + +function setSelectedToken (tokenAddress) { + return { + type: actions.SET_SELECTED_TOKEN, + value: tokenAddress || null, + } +} + +function setSelectedAddress (address) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.setSelectedAddress`) + background.setSelectedAddress(address, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + }) + } +} + +function showAccountDetail (address) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.setSelectedAddress`) + background.setSelectedAddress(address, (err, tokens) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + dispatch(updateTokens(tokens)) + dispatch({ + type: actions.SHOW_ACCOUNT_DETAIL, + value: address, + }) + dispatch(actions.setSelectedToken()) + }) + } +} + +function backToAccountDetail (address) { + return { + type: actions.BACK_TO_ACCOUNT_DETAIL, + value: address, + } +} + +function showAccountsPage () { + return { + type: actions.SHOW_ACCOUNTS_PAGE, + } +} + +function showConfTxPage ({transForward = true, id}) { + return { + type: actions.SHOW_CONF_TX_PAGE, + transForward, + id, + } +} + +function nextTx () { + return { + type: actions.NEXT_TX, + } +} + +function viewPendingTx (txId) { + return { + type: actions.VIEW_PENDING_TX, + value: txId, + } +} + +function previousTx () { + return { + type: actions.PREVIOUS_TX, + } +} + +function editTx (txId) { + return { + type: actions.EDIT_TX, + value: txId, + } +} + +function showConfigPage (transitionForward = true) { + return { + type: actions.SHOW_CONFIG_PAGE, + value: transitionForward, + } +} + +function showAddTokenPage (transitionForward = true) { + return { + type: actions.SHOW_ADD_TOKEN_PAGE, + value: transitionForward, + } +} + +function showAddSuggestedTokenPage (transitionForward = true) { + return { + type: actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE, + value: transitionForward, + } +} + +function addToken (address, symbol, decimals, image) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.addToken(address, symbol, decimals, image, (err, tokens) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + dispatch(actions.updateTokens(tokens)) + resolve(tokens) + }) + }) + } +} + +function removeToken (address) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.removeToken(address, (err, tokens) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + dispatch(actions.updateTokens(tokens)) + resolve(tokens) + }) + }) + } +} + +function addTokens (tokens) { + return dispatch => { + if (Array.isArray(tokens)) { + dispatch(actions.setSelectedToken(getTokenAddressFromTokenObject(tokens[0]))) + return Promise.all(tokens.map(({ address, symbol, decimals }) => ( + dispatch(addToken(address, symbol, decimals)) + ))) + } else { + dispatch(actions.setSelectedToken(getTokenAddressFromTokenObject(tokens))) + return Promise.all( + Object + .entries(tokens) + .map(([_, { address, symbol, decimals }]) => ( + dispatch(addToken(address, symbol, decimals)) + )) + ) + } + } +} + +function removeSuggestedTokens () { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.removeSuggestedTokens((err, suggestedTokens) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + } + dispatch(actions.clearPendingTokens()) + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { + return global.platform.closeCurrentWindow() + } + resolve(suggestedTokens) + }) + }) + .then(() => updateMetamaskStateFromBackground()) + .then(suggestedTokens => dispatch(actions.updateMetamaskState({...suggestedTokens}))) + } +} + +function addKnownMethodData (fourBytePrefix, methodData) { + return (dispatch) => { + background.addKnownMethodData(fourBytePrefix, methodData) + } +} + +function updateTokens (newTokens) { + return { + type: actions.UPDATE_TOKENS, + newTokens, + } +} + +function clearPendingTokens () { + return { + type: actions.CLEAR_PENDING_TOKENS, + } +} + +function goBackToInitView () { + return { + type: actions.BACK_TO_INIT_MENU, + } +} + +// +// notice +// + +function markNoticeRead (notice) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.markNoticeRead`) + return new Promise((resolve, reject) => { + background.markNoticeRead(notice, (err, notice) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + if (notice) { + dispatch(actions.showNotice(notice)) + resolve(true) + } else { + dispatch(actions.clearNotices()) + resolve(false) + } + }) + }) + } +} + +function showNotice (notice) { + return { + type: actions.SHOW_NOTICE, + value: notice, + } +} + +function clearNotices () { + return { + type: actions.CLEAR_NOTICES, + } +} + +function markAccountsFound () { + log.debug(`background.markAccountsFound`) + return callBackgroundThenUpdate(background.markAccountsFound) +} + +function retryTransaction (txId, gasPrice) { + log.debug(`background.retryTransaction`) + let newTxId + + return dispatch => { + return new Promise((resolve, reject) => { + background.retryTransaction(txId, gasPrice, (err, newState) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + + const { selectedAddressTxList } = newState + const { id } = selectedAddressTxList[selectedAddressTxList.length - 1] + newTxId = id + resolve(newState) + }) + }) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => newTxId) + } +} + +function createCancelTransaction (txId, customGasPrice) { + log.debug('background.cancelTransaction') + let newTxId + + return dispatch => { + return new Promise((resolve, reject) => { + background.createCancelTransaction(txId, customGasPrice, (err, newState) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + + const { selectedAddressTxList } = newState + const { id } = selectedAddressTxList[selectedAddressTxList.length - 1] + newTxId = id + resolve(newState) + }) + }) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => newTxId) + } +} + +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, 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()) + }) + + } +} + +function updateProviderType (type) { + return { + type: actions.SET_PROVIDER_TYPE, + value: type, + } +} + +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 || newRpc, (err, result) => { + if (err) { + log.error(err) + return dispatch(actions.displayWarning('Had a problem changing networks!')) + } + dispatch(actions.setSelectedToken()) + }) + } +} + +function delRpcTarget (oldRpc) { + return (dispatch) => { + log.debug(`background.delRpcTarget: ${oldRpc}`) + background.delCustomRpc(oldRpc, (err, result) => { + if (err) { + log.error(err) + return dispatch(self.displayWarning('Had a problem removing network!')) + } + dispatch(actions.setSelectedToken()) + }) + } +} + +// Calls the addressBookController to add a new address. +function addToAddressBook (recipient, nickname = '') { + log.debug(`background.addToAddressBook`) + return (dispatch) => { + background.setAddressBook(recipient, nickname, (err, result) => { + if (err) { + log.error(err) + return dispatch(self.displayWarning('Address book failed to update')) + } + }) + } +} + +function useEtherscanProvider () { + log.debug(`background.useEtherscanProvider`) + background.useEtherscanProvider() + return { + type: actions.USE_ETHERSCAN_PROVIDER, + } +} + +function showNetworkDropdown () { + return { + type: actions.NETWORK_DROPDOWN_OPEN, + } +} + +function hideNetworkDropdown () { + return { + type: actions.NETWORK_DROPDOWN_CLOSE, + } +} + + +function showModal (payload) { + return { + type: actions.MODAL_OPEN, + payload, + } +} + +function hideModal (payload) { + return { + type: actions.MODAL_CLOSE, + payload, + } +} + +function showSidebar ({ transitionName, type, props }) { + return { + type: actions.SIDEBAR_OPEN, + value: { + transitionName, + type, + props, + }, + } +} + +function hideSidebar () { + return { + type: actions.SIDEBAR_CLOSE, + } +} + +function showAlert (msg) { + return { + type: actions.ALERT_OPEN, + value: msg, + } +} + +function hideAlert () { + return { + type: actions.ALERT_CLOSE, + } +} + +/** + * This action will receive two types of values via qrCodeData + * an object with the following structure {type, values} + * or null (used to clear the previous value) + */ +function qrCodeDetected (qrCodeData) { + return { + type: actions.QR_CODE_DETECTED, + value: qrCodeData, + } +} + +function showLoadingIndication (message) { + return { + type: actions.SHOW_LOADING, + value: message, + } +} + +function setHardwareWalletDefaultHdPath ({ device, path }) { + return { + type: actions.SET_HARDWARE_WALLET_DEFAULT_HD_PATH, + value: {device, path}, + } +} + +function hideLoadingIndication () { + return { + type: actions.HIDE_LOADING, + } +} + +function showSubLoadingIndication () { + return { + type: actions.SHOW_SUB_LOADING_INDICATION, + } +} + +function hideSubLoadingIndication () { + return { + type: actions.HIDE_SUB_LOADING_INDICATION, + } +} + +function displayWarning (text) { + return { + type: actions.DISPLAY_WARNING, + value: text, + } +} + +function hideWarning () { + return { + type: actions.HIDE_WARNING, + } +} + +function requestExportAccount () { + return { + type: actions.REQUEST_ACCOUNT_EXPORT, + } +} + +function exportAccount (password, address) { + var self = this + + return function (dispatch) { + dispatch(self.showLoadingIndication()) + + log.debug(`background.submitPassword`) + return new Promise((resolve, reject) => { + background.submitPassword(password, function (err) { + if (err) { + log.error('Error in submiting password.') + dispatch(self.hideLoadingIndication()) + dispatch(self.displayWarning('Incorrect Password.')) + return reject(err) + } + log.debug(`background.exportAccount`) + return background.exportAccount(address, function (err, result) { + dispatch(self.hideLoadingIndication()) + + if (err) { + log.error(err) + dispatch(self.displayWarning('Had a problem exporting the account.')) + return reject(err) + } + + // dispatch(self.exportAccountComplete()) + dispatch(self.showPrivateKey(result)) + + return resolve(result) + }) + }) + }) + } +} + +function exportAccountComplete () { + return { + type: actions.EXPORT_ACCOUNT, + } +} + +function showPrivateKey (key) { + return { + type: actions.SHOW_PRIVATE_KEY, + value: key, + } +} + +function setAccountLabel (account, label) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + log.debug(`background.setAccountLabel`) + + return new Promise((resolve, reject) => { + background.setAccountLabel(account, label, (err) => { + dispatch(actions.hideLoadingIndication()) + + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + + dispatch({ + type: actions.SET_ACCOUNT_LABEL, + value: { account, label }, + }) + + resolve(account) + }) + }) + } +} + +function showSendPage () { + return { + type: actions.SHOW_SEND_PAGE, + } +} + +function showSendTokenPage () { + return { + type: actions.SHOW_SEND_TOKEN_PAGE, + } +} + +function buyEth (opts) { + return (dispatch) => { + const url = getBuyEthUrl(opts) + global.platform.openWindow({ url }) + dispatch({ + type: actions.BUY_ETH, + }) + } +} + +function onboardingBuyEthView (address) { + return { + type: actions.ONBOARDING_BUY_ETH_VIEW, + value: address, + } +} + +function buyEthView (address) { + return { + type: actions.BUY_ETH_VIEW, + value: address, + } +} + +function coinBaseSubview () { + return { + type: actions.COINBASE_SUBVIEW, + } +} + +function pairUpdate (coin) { + return (dispatch) => { + dispatch(actions.showSubLoadingIndication()) + dispatch(actions.hideWarning()) + shapeShiftRequest('marketinfo', {pair: `${coin.toLowerCase()}_eth`}, (mktResponse) => { + dispatch(actions.hideSubLoadingIndication()) + if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) + dispatch({ + type: actions.PAIR_UPDATE, + value: { + marketinfo: mktResponse, + }, + }) + }) + } +} + +function shapeShiftSubview (network) { + var pair = 'btc_eth' + return (dispatch) => { + dispatch(actions.showSubLoadingIndication()) + shapeShiftRequest('marketinfo', {pair}, (mktResponse) => { + shapeShiftRequest('getcoins', {}, (response) => { + dispatch(actions.hideSubLoadingIndication()) + if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) + dispatch({ + type: actions.SHAPESHIFT_SUBVIEW, + value: { + marketinfo: mktResponse, + coinOptions: response, + }, + }) + }) + }) + } +} + +function coinShiftRquest (data, marketData) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + shapeShiftRequest('shift', { method: 'POST', data}, (response) => { + dispatch(actions.hideLoadingIndication()) + if (response.error) return dispatch(actions.displayWarning(response.error)) + var message = ` + Deposit your ${response.depositType} to the address below:` + log.debug(`background.createShapeShiftTx`) + background.createShapeShiftTx(response.deposit, response.depositType) + dispatch(actions.showQrView(response.deposit, [message].concat(marketData))) + }) + } +} + +function buyWithShapeShift (data) { + return dispatch => new Promise((resolve, reject) => { + shapeShiftRequest('shift', { method: 'POST', data}, (response) => { + if (response.error) { + return reject(response.error) + } + background.createShapeShiftTx(response.deposit, response.depositType) + return resolve(response) + }) + }) +} + +function showQrView (data, message) { + return { + type: actions.SHOW_QR_VIEW, + value: { + message: message, + data: data, + }, + } +} +function reshowQrCode (data, coin) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + shapeShiftRequest('marketinfo', {pair: `${coin.toLowerCase()}_eth`}, (mktResponse) => { + if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) + + var message = [ + `Deposit your ${coin} to the address below:`, + `Deposit Limit: ${mktResponse.limit}`, + `Deposit Minimum:${mktResponse.minimum}`, + ] + + dispatch(actions.hideLoadingIndication()) + return dispatch(actions.showQrView(data, message)) + // return dispatch(actions.showModal({ + // name: 'SHAPESHIFT_DEPOSIT_TX', + // Qr: { data, message }, + // })) + }) + } +} + +function shapeShiftRequest (query, options, cb) { + var queryResponse, method + !options ? options = {} : null + options.method ? method = options.method : method = 'GET' + + var requestListner = function (request) { + try { + queryResponse = JSON.parse(this.responseText) + cb ? cb(queryResponse) : null + return queryResponse + } catch (e) { + cb ? cb({error: e}) : null + return e + } + } + + var shapShiftReq = new XMLHttpRequest() + shapShiftReq.addEventListener('load', requestListner) + shapShiftReq.open(method, `https://shapeshift.io/${query}/${options.pair ? options.pair : ''}`, true) + + if (options.method === 'POST') { + var jsonObj = JSON.stringify(options.data) + shapShiftReq.setRequestHeader('Content-Type', 'application/json') + return shapShiftReq.send(jsonObj) + } else { + return shapShiftReq.send() + } +} + +function setFeatureFlag (feature, activated, notificationType) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.setFeatureFlag(feature, activated, (err, updatedFeatureFlags) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + dispatch(actions.updateFeatureFlags(updatedFeatureFlags)) + notificationType && dispatch(actions.showModal({ name: notificationType })) + resolve(updatedFeatureFlags) + }) + }) + } +} + +function updateFeatureFlags (updatedFeatureFlags) { + return { + type: actions.UPDATE_FEATURE_FLAGS, + value: updatedFeatureFlags, + } +} + +function setPreference (preference, value) { + return dispatch => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.setPreference(preference, value, (err, updatedPreferences) => { + dispatch(actions.hideLoadingIndication()) + + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.updatePreferences(updatedPreferences)) + resolve(updatedPreferences) + }) + }) + } +} + +function updatePreferences (value) { + return { + type: actions.UPDATE_PREFERENCES, + value, + } +} + +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, + value: networkNonce, + } +} + +function updateNetworkNonce (address) { + return (dispatch) => { + return new Promise((resolve, reject) => { + global.ethQuery.getTransactionCount(address, (err, data) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + dispatch(setNetworkNonce(data)) + resolve(data) + }) + }) + } +} + +function setMouseUserState (isMouseUser) { + return { + type: actions.SET_MOUSE_USER_STATE, + value: isMouseUser, + } +} + +// Call Background Then Update +// +// A function generator for a common pattern wherein: +// We show loading indication. +// We call a background method. +// We hide loading indication. +// If it errored, we show a warning. +// If it didn't, we update the state. +function callBackgroundThenUpdateNoSpinner (method, ...args) { + return (dispatch) => { + method.call(background, ...args, (err) => { + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + forceUpdateMetamaskState(dispatch) + }) + } +} + +function callBackgroundThenUpdate (method, ...args) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + method.call(background, ...args, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + forceUpdateMetamaskState(dispatch) + }) + } +} + +function forceUpdateMetamaskState (dispatch) { + log.debug(`background.getState`) + return new Promise((resolve, reject) => { + background.getState((err, newState) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch(actions.updateMetamaskState(newState)) + resolve(newState) + }) + }) +} + +function toggleAccountMenu () { + return { + type: actions.TOGGLE_ACCOUNT_MENU, + } +} + +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()) + log.debug(`background.setUseBlockie`) + background.setUseBlockie(val, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + }) + dispatch({ + type: actions.SET_USE_BLOCKIE, + value: val, + }) + } +} + +function updateCurrentLocale (key) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return fetchLocale(key) + .then((localeMessages) => { + log.debug(`background.setCurrentLocale`) + background.setCurrentLocale(key, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + dispatch(actions.setCurrentLocale(key)) + dispatch(actions.setLocaleMessages(localeMessages)) + }) + }) + } +} + +function setCurrentLocale (key) { + return { + type: actions.SET_CURRENT_LOCALE, + value: key, + } +} + +function setLocaleMessages (localeMessages) { + return { + type: actions.SET_LOCALE_MESSAGES, + value: localeMessages, + } +} + +function updateNetworkEndpointType (networkEndpointType) { + return { + type: actions.UPDATE_NETWORK_ENDPOINT_TYPE, + value: networkEndpointType, + } +} + +function setPendingTokens (pendingTokens) { + const { customToken = {}, selectedTokens = {} } = pendingTokens + const { address, symbol, decimals } = customToken + const tokens = address && symbol && decimals + ? { ...selectedTokens, [address]: { ...customToken, isCustom: true } } + : selectedTokens + + return { + type: actions.SET_PENDING_TOKENS, + payload: tokens, + } +} + +function approveProviderRequest (tabID) { + return (dispatch) => { + background.approveProviderRequest(tabID) + } +} + +function rejectProviderRequest (tabID) { + return (dispatch) => { + background.rejectProviderRequest(tabID) + } +} + +function clearApprovedOrigins () { + return (dispatch) => { + 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/store.js b/ui/app/store/store.js new file mode 100644 index 000000000..9f12f469e --- /dev/null +++ b/ui/app/store/store.js @@ -0,0 +1,21 @@ +const createStore = require('redux').createStore +const applyMiddleware = require('redux').applyMiddleware +const thunkMiddleware = require('redux-thunk').default +const rootReducer = require('../ducks') +const createLogger = require('redux-logger').createLogger + +global.METAMASK_DEBUG = process.env.METAMASK_DEBUG + +module.exports = configureStore + +const loggerMiddleware = createLogger({ + predicate: () => global.METAMASK_DEBUG, +}) + +const middlewares = [thunkMiddleware, loggerMiddleware] + +const createStoreWithMiddleware = applyMiddleware(...middlewares)(createStore) + +function configureStore (initialState) { + return createStoreWithMiddleware(rootReducer, initialState) +} diff --git a/ui/app/util.js b/ui/app/util.js deleted file mode 100644 index 3237e5feb..000000000 --- a/ui/app/util.js +++ /dev/null @@ -1,326 +0,0 @@ -const abi = require('human-standard-token-abi') -const ethUtil = require('ethereumjs-util') -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, format = 'M/d/y \'at\' T') { - return DateTime.fromMillis(date).toFormat(format) -} - -var valueTable = { - wei: '1000000000000000000', - kwei: '1000000000000000', - mwei: '1000000000000', - gwei: '1000000000', - szabo: '1000000', - finney: '1000', - ether: '1', - kether: '0.001', - mether: '0.000001', - gether: '0.000000001', - tether: '0.000000000001', -} -var bnTable = {} -for (var currency in valueTable) { - bnTable[currency] = new ethUtil.BN(valueTable[currency], 10) -} - -module.exports = { - valuesFor: valuesFor, - addressSummary: addressSummary, - miniAddressSummary: miniAddressSummary, - isAllOneCase: isAllOneCase, - isValidAddress: isValidAddress, - isValidENSAddress, - numericBalance: numericBalance, - parseBalance: parseBalance, - formatBalance: formatBalance, - generateBalanceObject: generateBalanceObject, - dataSize: dataSize, - readableDate: readableDate, - normalizeToWei: normalizeToWei, - normalizeEthStringToWei: normalizeEthStringToWei, - normalizeNumberToWei: normalizeNumberToWei, - valueTable: valueTable, - bnTable: bnTable, - isHex: isHex, - formatDate, - bnMultiplyByFraction, - getTxFeeBn, - shortenBalance, - getContractAtAddress, - exportAsFile: exportAsFile, - isInvalidChecksumAddress, - allNull, - 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) { - if (!obj) return [] - return Object.keys(obj) - .map(function (key) { return obj[key] }) -} - -function addressSummary (address, firstSegLength = 10, lastSegLength = 4, includeHex = true) { - if (!address) return '' - let checked = checksumAddress(address) - if (!includeHex) { - checked = ethUtil.stripHexPrefix(checked) - } - return checked ? checked.slice(0, firstSegLength) + '...' + checked.slice(checked.length - lastSegLength) : '...' -} - -function miniAddressSummary (address) { - if (!address) return '' - var checked = checksumAddress(address) - return checked ? checked.slice(0, 4) + '...' + checked.slice(-4) : '...' -} - -function isValidAddress (address, network) { - var prefixed = ethUtil.addHexPrefix(address) - if (address === '0x0000000000000000000000000000000000000000') return false - return (isAllOneCase(prefixed) && ethUtil.isValidAddress(prefixed)) || ethUtil.isValidChecksumAddress(prefixed) -} - -function isValidENSAddress (address) { - return address.match(/^.{7,}\.(eth|test)$/) -} - -function isInvalidChecksumAddress (address) { - var prefixed = ethUtil.addHexPrefix(address) - if (address === '0x0000000000000000000000000000000000000000') return false - return !isAllOneCase(prefixed) && !ethUtil.isValidChecksumAddress(prefixed) && ethUtil.isValidAddress(prefixed) -} - -function isAllOneCase (address) { - if (!address) return true - var lower = address.toLowerCase() - var upper = address.toUpperCase() - return address === lower || address === upper -} - -// Takes wei Hex, returns wei BN, even if input is null -function numericBalance (balance) { - if (!balance) return new ethUtil.BN(0, 16) - var stripped = ethUtil.stripHexPrefix(balance) - return new ethUtil.BN(stripped, 16) -} - -// Takes hex, returns [beforeDecimal, afterDecimal] -function parseBalance (balance) { - var beforeDecimal, afterDecimal - const wei = numericBalance(balance) - var weiString = wei.toString() - const trailingZeros = /0+$/ - - beforeDecimal = weiString.length > 18 ? weiString.slice(0, weiString.length - 18) : '0' - afterDecimal = ('000000000000000000' + wei).slice(-18).replace(trailingZeros, '') - if (afterDecimal === '') { afterDecimal = '0' } - return [beforeDecimal, afterDecimal] -} - -// Takes wei hex, returns an object with three properties. -// Its "formatted" property is what we generally use to render values. -function formatBalance (balance, decimalsToKeep, needsParse = true, ticker = 'ETH') { - var parsed = needsParse ? parseBalance(balance) : balance.split('.') - var beforeDecimal = parsed[0] - var afterDecimal = parsed[1] - var formatted = 'None' - if (decimalsToKeep === undefined) { - if (beforeDecimal === '0') { - if (afterDecimal !== '0') { - var sigFigs = afterDecimal.match(/^0*(.{2})/) // default: grabs 2 most significant digits - if (sigFigs) { afterDecimal = sigFigs[0] } - formatted = '0.' + afterDecimal + ` ${ticker}` - } - } else { - formatted = beforeDecimal + '.' + afterDecimal.slice(0, 3) + ` ${ticker}` - } - } else { - afterDecimal += Array(decimalsToKeep).join('0') - formatted = beforeDecimal + '.' + afterDecimal.slice(0, decimalsToKeep) + ` ${ticker}` - } - return formatted -} - - -function generateBalanceObject (formattedBalance, decimalsToKeep = 1) { - var balance = formattedBalance.split(' ')[0] - var label = formattedBalance.split(' ')[1] - var beforeDecimal = balance.split('.')[0] - var afterDecimal = balance.split('.')[1] - var shortBalance = shortenBalance(balance, decimalsToKeep) - - if (beforeDecimal === '0' && afterDecimal.substr(0, 5) === '00000') { - // eslint-disable-next-line eqeqeq - if (afterDecimal == 0) { - balance = '0' - } else { - balance = '<1.0e-5' - } - } else if (beforeDecimal !== '0') { - balance = `${beforeDecimal}.${afterDecimal.slice(0, decimalsToKeep)}` - } - - return { balance, label, shortBalance } -} - -function shortenBalance (balance, decimalsToKeep = 1) { - var truncatedValue - var convertedBalance = parseFloat(balance) - if (convertedBalance > 1000000) { - truncatedValue = (balance / 1000000).toFixed(decimalsToKeep) - return `${truncatedValue}m` - } else if (convertedBalance > 1000) { - truncatedValue = (balance / 1000).toFixed(decimalsToKeep) - return `${truncatedValue}k` - } else if (convertedBalance === 0) { - return '0' - } else if (convertedBalance < 0.001) { - return '<0.001' - } else if (convertedBalance < 1) { - var stringBalance = convertedBalance.toString() - if (stringBalance.split('.')[1].length > 3) { - return convertedBalance.toFixed(3) - } else { - return stringBalance - } - } else { - return convertedBalance.toFixed(decimalsToKeep) - } -} - -function dataSize (data) { - var size = data ? ethUtil.stripHexPrefix(data).length : 0 - return size + ' bytes' -} - -// Takes a BN and an ethereum currency name, -// returns a BN in wei -function normalizeToWei (amount, currency) { - try { - return amount.mul(bnTable.wei).div(bnTable[currency]) - } catch (e) {} - return amount -} - -function normalizeEthStringToWei (str) { - const parts = str.split('.') - let eth = new ethUtil.BN(parts[0], 10).mul(bnTable.wei) - if (parts[1]) { - var decimal = parts[1] - while (decimal.length < 18) { - decimal += '0' - } - if (decimal.length > 18) { - decimal = decimal.slice(0, 18) - } - const decimalBN = new ethUtil.BN(decimal, 10) - eth = eth.add(decimalBN) - } - return eth -} - -var multiple = new ethUtil.BN('10000', 10) -function normalizeNumberToWei (n, currency) { - var enlarged = n * 10000 - var amount = new ethUtil.BN(String(enlarged), 10) - return normalizeToWei(amount, currency).div(multiple) -} - -function readableDate (ms) { - var date = new Date(ms) - var month = date.getMonth() - var day = date.getDate() - var year = date.getFullYear() - var hours = date.getHours() - var minutes = '0' + date.getMinutes() - var seconds = '0' + date.getSeconds() - - var dateStr = `${month}/${day}/${year}` - var time = `${hours}:${minutes.substr(-2)}:${seconds.substr(-2)}` - return `${dateStr} ${time}` -} - -function isHex (str) { - return Boolean(str.match(/^(0x)?[0-9a-fA-F]+$/)) -} - -function bnMultiplyByFraction (targetBN, numerator, denominator) { - const numBN = new ethUtil.BN(numerator) - const denomBN = new ethUtil.BN(denominator) - return targetBN.mul(numBN).div(denomBN) -} - -function getTxFeeBn (gas, gasPrice = MIN_GAS_PRICE_BN.toString(16), blockGasLimit) { - const gasBn = hexToBn(gas) - const gasPriceBn = hexToBn(gasPrice) - const txFeeBn = gasBn.mul(gasPriceBn) - - return txFeeBn.toString(16) -} - -function getContractAtAddress (tokenAddress) { - return global.eth.contract(abi).at(tokenAddress) -} - -function exportAsFile (filename, data, type = 'text/csv') { - // source: https://stackoverflow.com/a/33542499 by Ludovic Feltz - const blob = new Blob([data], {type}) - if (window.navigator.msSaveOrOpenBlob) { - window.navigator.msSaveBlob(blob, filename) - } else { - const elem = window.document.createElement('a') - elem.target = '_blank' - elem.href = window.URL.createObjectURL(blob) - elem.download = filename - document.body.appendChild(elem) - elem.click() - document.body.removeChild(elem) - } -} - -function allNull (obj) { - return Object.entries(obj).every(([key, value]) => value === null) -} - -function getTokenAddressFromTokenObject (token) { - return Object.values(token)[0].address.toLowerCase() -} - -/** - * Safely checksumms a potentially-null address - * - * @param {String} [address] - address to checksum - * @param {String} [network] - network id - * @returns {String} - checksummed address - * - */ -function checksumAddress (address, network) { - const checksummed = address ? ethUtil.toChecksumAddress(address) : '' - return checksummed -} - -function addressSlicer (address = '') { - if (address.length < 11) { - return address - } - - return `${address.slice(0, 6)}...${address.slice(-4)}` -} diff --git a/ui/index.js b/ui/index.js index bd9ecc28b..ac860e0db 100644 --- a/ui/index.js +++ b/ui/index.js @@ -1,10 +1,10 @@ const render = require('react-dom').render const h = require('react-hyperscript') -const Root = require('./app/root') -const actions = require('./app/actions') -const configureStore = require('./app/store') +const Root = require('./app/pages') +const actions = require('./app/store/actions') +const configureStore = require('./app/store/store') const txHelper = require('./lib/tx-helper') -const { fetchLocale } = require('./i18n-helper') +const { fetchLocale } = require('./app/helpers/utils/i18n-helper') const log = require('loglevel') module.exports = launchMetamaskUi diff --git a/ui/lib/icon-factory.js b/ui/lib/icon-factory.js index 7fadbceff..2ea943297 100644 --- a/ui/lib/icon-factory.js +++ b/ui/lib/icon-factory.js @@ -1,6 +1,6 @@ var iconFactory const isValidAddress = require('ethereumjs-util').isValidAddress -const { checksumAddress } = require('../app/util') +const { checksumAddress } = require('../app/helpers/utils/util') const contractMap = require('eth-contract-metadata') module.exports = function (jazzicon) { diff --git a/ui/lib/lost-accounts-notice.js b/ui/lib/lost-accounts-notice.js index 948b13db6..840bd8dca 100644 --- a/ui/lib/lost-accounts-notice.js +++ b/ui/lib/lost-accounts-notice.js @@ -1,4 +1,4 @@ -const summary = require('../app/util').addressSummary +const summary = require('../app/helpers/utils/util').addressSummary module.exports = function (lostAccounts) { return { diff --git a/ui/lib/tx-helper.js b/ui/lib/tx-helper.js index 260dbaa39..cdacc5ed7 100644 --- a/ui/lib/tx-helper.js +++ b/ui/lib/tx-helper.js @@ -1,4 +1,4 @@ -const valuesFor = require('../app/util').valuesFor +const valuesFor = require('../app/helpers/utils/util').valuesFor const log = require('loglevel') module.exports = function (unapprovedTxs, unapprovedMsgs, personalMsgs, typedMessages, network) { |